From 6017b11c42ca587b8c69f2dfd86d48a5ae295e71 Mon Sep 17 00:00:00 2001 From: Trenton H <797416+stumpylog@users.noreply.github.com> Date: Tue, 21 Apr 2026 10:24:51 -0700 Subject: [PATCH 1/8] Fix: Prefetches the custom field instance and the custom field all at once (#12617) --- src/documents/views.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/documents/views.py b/src/documents/views.py index 51b1abe96..a96c24502 100644 --- a/src/documents/views.py +++ b/src/documents/views.py @@ -968,7 +968,10 @@ class DocumentViewSet( ), ), "tags", - "custom_fields", + Prefetch( + "custom_fields", + queryset=CustomFieldInstance.objects.select_related("field"), + ), "notes", ) ) From 02e913b475d9b5c274d0d568c2dbfefd91deb24c Mon Sep 17 00:00:00 2001 From: GitHub Actions <41898282+github-actions[bot]@users.noreply.github.com> Date: Tue, 21 Apr 2026 17:26:25 +0000 Subject: [PATCH 2/8] Auto translate strings --- src/locale/en_US/LC_MESSAGES/django.po | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/src/locale/en_US/LC_MESSAGES/django.po b/src/locale/en_US/LC_MESSAGES/django.po index b10678c41..ea12a4191 100644 --- a/src/locale/en_US/LC_MESSAGES/django.po +++ b/src/locale/en_US/LC_MESSAGES/django.po @@ -2,7 +2,7 @@ msgid "" msgstr "" "Project-Id-Version: paperless-ngx\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2026-04-20 20:20+0000\n" +"POT-Creation-Date: 2026-04-21 17:25+0000\n" "PO-Revision-Date: 2022-02-17 04:17\n" "Last-Translator: \n" "Language-Team: English\n" @@ -1352,8 +1352,8 @@ msgid "workflow runs" msgstr "" #: documents/serialisers.py:463 documents/serialisers.py:815 -#: documents/serialisers.py:2681 documents/views.py:2255 -#: documents/views.py:2324 paperless_mail/serialisers.py:143 +#: documents/serialisers.py:2681 documents/views.py:2258 +#: documents/views.py:2327 paperless_mail/serialisers.py:143 msgid "Insufficient permissions." msgstr "" @@ -1393,7 +1393,7 @@ msgstr "" msgid "Duplicate document identifiers are not allowed." msgstr "" -#: documents/serialisers.py:2767 documents/views.py:4104 +#: documents/serialisers.py:2767 documents/views.py:4107 #, python-format msgid "Documents not found: %(ids)s" msgstr "" @@ -1661,28 +1661,28 @@ msgstr "" msgid "Unable to parse URI {value}" msgstr "" -#: documents/views.py:2135 +#: documents/views.py:2138 msgid "Specify only one of text, title_search, query, or more_like_id." msgstr "" -#: documents/views.py:2248 documents/views.py:2321 +#: documents/views.py:2251 documents/views.py:2324 msgid "Invalid more_like_id" msgstr "" -#: documents/views.py:4116 +#: documents/views.py:4119 #, python-format msgid "Insufficient permissions to share document %(id)s." msgstr "" -#: documents/views.py:4162 +#: documents/views.py:4165 msgid "Bundle is already being processed." msgstr "" -#: documents/views.py:4222 +#: documents/views.py:4225 msgid "The share link bundle is still being prepared. Please try again later." msgstr "" -#: documents/views.py:4232 +#: documents/views.py:4235 msgid "The share link bundle is unavailable." msgstr "" 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 3/8] 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) # # ==========================================================# From edfebcbe44ba9242c68267c2fd13938ab1e9967e Mon Sep 17 00:00:00 2001 From: GitHub Actions <41898282+github-actions[bot]@users.noreply.github.com> Date: Tue, 21 Apr 2026 18:02:57 +0000 Subject: [PATCH 4/8] Auto translate strings --- src/locale/en_US/LC_MESSAGES/django.po | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/src/locale/en_US/LC_MESSAGES/django.po b/src/locale/en_US/LC_MESSAGES/django.po index ea12a4191..b12190756 100644 --- a/src/locale/en_US/LC_MESSAGES/django.po +++ b/src/locale/en_US/LC_MESSAGES/django.po @@ -2,7 +2,7 @@ msgid "" msgstr "" "Project-Id-Version: paperless-ngx\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2026-04-21 17:25+0000\n" +"POT-Creation-Date: 2026-04-21 18:02+0000\n" "PO-Revision-Date: 2022-02-17 04:17\n" "Last-Translator: \n" "Language-Team: English\n" @@ -21,39 +21,39 @@ msgstr "" msgid "Documents" msgstr "" -#: documents/filters.py:433 +#: documents/filters.py:463 msgid "Value must be valid JSON." msgstr "" -#: documents/filters.py:452 +#: documents/filters.py:482 msgid "Invalid custom field query expression" msgstr "" -#: documents/filters.py:462 +#: documents/filters.py:492 msgid "Invalid expression list. Must be nonempty." msgstr "" -#: documents/filters.py:483 +#: documents/filters.py:513 msgid "Invalid logical operator {op!r}" msgstr "" -#: documents/filters.py:497 +#: documents/filters.py:527 msgid "Maximum number of query conditions exceeded." msgstr "" -#: documents/filters.py:562 +#: documents/filters.py:591 msgid "{name!r} is not a valid custom field." msgstr "" -#: documents/filters.py:599 +#: documents/filters.py:628 msgid "{data_type} does not support query expr {expr!r}." msgstr "" -#: documents/filters.py:707 documents/models.py:136 +#: documents/filters.py:743 documents/models.py:136 msgid "Maximum nesting depth exceeded." msgstr "" -#: documents/filters.py:954 +#: documents/filters.py:990 msgid "Custom field not found" msgstr "" From 88430c8ab78c513f0d7e31d38dc685bec5d0a93c Mon Sep 17 00:00:00 2001 From: shamoon <4887959+shamoon@users.noreply.github.com> Date: Tue, 21 Apr 2026 14:49:04 -0700 Subject: [PATCH 5/8] Tweak: remove 'stale' indicator for index in system status (#12616) --- .../system-status-dialog.component.html | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/src-ui/src/app/components/common/system-status-dialog/system-status-dialog.component.html b/src-ui/src/app/components/common/system-status-dialog/system-status-dialog.component.html index 2e30a5a24..d57485214 100644 --- a/src-ui/src/app/components/common/system-status-dialog/system-status-dialog.component.html +++ b/src-ui/src/app/components/common/system-status-dialog/system-status-dialog.component.html @@ -159,11 +159,7 @@