mirror of
https://github.com/paperless-ngx/paperless-ngx.git
synced 2026-04-21 23:39:28 +00:00
Compare commits
8 Commits
feature-up
...
dev
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
89a9e7f190 | ||
|
|
c669c3416e | ||
|
|
88430c8ab7 | ||
|
|
edfebcbe44 | ||
|
|
a89cd2d5d9 | ||
|
|
02e913b475 | ||
|
|
6017b11c42 | ||
|
|
ffaa2bb77a |
@@ -4428,23 +4428,23 @@
|
|||||||
</context-group>
|
</context-group>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/common/system-status-dialog/system-status-dialog.component.html</context>
|
<context context-type="sourcefile">src/app/components/common/system-status-dialog/system-status-dialog.component.html</context>
|
||||||
<context context-type="linenumber">176</context>
|
<context context-type="linenumber">172</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/common/system-status-dialog/system-status-dialog.component.html</context>
|
<context context-type="sourcefile">src/app/components/common/system-status-dialog/system-status-dialog.component.html</context>
|
||||||
<context context-type="linenumber">210</context>
|
<context context-type="linenumber">206</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/common/system-status-dialog/system-status-dialog.component.html</context>
|
<context context-type="sourcefile">src/app/components/common/system-status-dialog/system-status-dialog.component.html</context>
|
||||||
<context context-type="linenumber">244</context>
|
<context context-type="linenumber">240</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/common/system-status-dialog/system-status-dialog.component.html</context>
|
<context context-type="sourcefile">src/app/components/common/system-status-dialog/system-status-dialog.component.html</context>
|
||||||
<context context-type="linenumber">254</context>
|
<context context-type="linenumber">250</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/common/system-status-dialog/system-status-dialog.component.html</context>
|
<context context-type="sourcefile">src/app/components/common/system-status-dialog/system-status-dialog.component.html</context>
|
||||||
<context context-type="linenumber">292</context>
|
<context context-type="linenumber">288</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/common/toast/toast.component.html</context>
|
<context context-type="sourcefile">src/app/components/common/toast/toast.component.html</context>
|
||||||
@@ -6256,7 +6256,7 @@
|
|||||||
</context-group>
|
</context-group>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/common/system-status-dialog/system-status-dialog.component.html</context>
|
<context context-type="sourcefile">src/app/components/common/system-status-dialog/system-status-dialog.component.html</context>
|
||||||
<context context-type="linenumber">311</context>
|
<context context-type="linenumber">307</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/manage/mail/mail.component.html</context>
|
<context context-type="sourcefile">src/app/components/manage/mail/mail.component.html</context>
|
||||||
@@ -6983,75 +6983,75 @@
|
|||||||
<source>Last Updated</source>
|
<source>Last Updated</source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/common/system-status-dialog/system-status-dialog.component.html</context>
|
<context context-type="sourcefile">src/app/components/common/system-status-dialog/system-status-dialog.component.html</context>
|
||||||
<context context-type="linenumber">174</context>
|
<context context-type="linenumber">170</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="46628344485199198" datatype="html">
|
<trans-unit id="46628344485199198" datatype="html">
|
||||||
<source>Classifier</source>
|
<source>Classifier</source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/common/system-status-dialog/system-status-dialog.component.html</context>
|
<context context-type="sourcefile">src/app/components/common/system-status-dialog/system-status-dialog.component.html</context>
|
||||||
<context context-type="linenumber">179</context>
|
<context context-type="linenumber">175</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="9127131074422113272" datatype="html">
|
<trans-unit id="9127131074422113272" datatype="html">
|
||||||
<source>Run Task</source>
|
<source>Run Task</source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/common/system-status-dialog/system-status-dialog.component.html</context>
|
<context context-type="sourcefile">src/app/components/common/system-status-dialog/system-status-dialog.component.html</context>
|
||||||
<context context-type="linenumber">201</context>
|
<context context-type="linenumber">197</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/common/system-status-dialog/system-status-dialog.component.html</context>
|
<context context-type="sourcefile">src/app/components/common/system-status-dialog/system-status-dialog.component.html</context>
|
||||||
<context context-type="linenumber">235</context>
|
<context context-type="linenumber">231</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/common/system-status-dialog/system-status-dialog.component.html</context>
|
<context context-type="sourcefile">src/app/components/common/system-status-dialog/system-status-dialog.component.html</context>
|
||||||
<context context-type="linenumber">283</context>
|
<context context-type="linenumber">279</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="6096684179126491743" datatype="html">
|
<trans-unit id="6096684179126491743" datatype="html">
|
||||||
<source>Last Trained</source>
|
<source>Last Trained</source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/common/system-status-dialog/system-status-dialog.component.html</context>
|
<context context-type="sourcefile">src/app/components/common/system-status-dialog/system-status-dialog.component.html</context>
|
||||||
<context context-type="linenumber">208</context>
|
<context context-type="linenumber">204</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="6427836860962380759" datatype="html">
|
<trans-unit id="6427836860962380759" datatype="html">
|
||||||
<source>Sanity Checker</source>
|
<source>Sanity Checker</source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/common/system-status-dialog/system-status-dialog.component.html</context>
|
<context context-type="sourcefile">src/app/components/common/system-status-dialog/system-status-dialog.component.html</context>
|
||||||
<context context-type="linenumber">213</context>
|
<context context-type="linenumber">209</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="6578747070254776938" datatype="html">
|
<trans-unit id="6578747070254776938" datatype="html">
|
||||||
<source>Last Run</source>
|
<source>Last Run</source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/common/system-status-dialog/system-status-dialog.component.html</context>
|
<context context-type="sourcefile">src/app/components/common/system-status-dialog/system-status-dialog.component.html</context>
|
||||||
<context context-type="linenumber">242</context>
|
<context context-type="linenumber">238</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/common/system-status-dialog/system-status-dialog.component.html</context>
|
<context context-type="sourcefile">src/app/components/common/system-status-dialog/system-status-dialog.component.html</context>
|
||||||
<context context-type="linenumber">290</context>
|
<context context-type="linenumber">286</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="5921685253729220446" datatype="html">
|
<trans-unit id="5921685253729220446" datatype="html">
|
||||||
<source>WebSocket Connection</source>
|
<source>WebSocket Connection</source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/common/system-status-dialog/system-status-dialog.component.html</context>
|
<context context-type="sourcefile">src/app/components/common/system-status-dialog/system-status-dialog.component.html</context>
|
||||||
<context context-type="linenumber">247</context>
|
<context context-type="linenumber">243</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="8998179362936748717" datatype="html">
|
<trans-unit id="8998179362936748717" datatype="html">
|
||||||
<source>OK</source>
|
<source>OK</source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/common/system-status-dialog/system-status-dialog.component.html</context>
|
<context context-type="sourcefile">src/app/components/common/system-status-dialog/system-status-dialog.component.html</context>
|
||||||
<context context-type="linenumber">251</context>
|
<context context-type="linenumber">247</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="3804349597565969872" datatype="html">
|
<trans-unit id="3804349597565969872" datatype="html">
|
||||||
<source>AI Index</source>
|
<source>AI Index</source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/common/system-status-dialog/system-status-dialog.component.html</context>
|
<context context-type="sourcefile">src/app/components/common/system-status-dialog/system-status-dialog.component.html</context>
|
||||||
<context context-type="linenumber">260</context>
|
<context context-type="linenumber">256</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="6732151329960766506" datatype="html">
|
<trans-unit id="6732151329960766506" datatype="html">
|
||||||
|
|||||||
@@ -94,12 +94,18 @@ main {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.sidebar.slim:not(.animating) {
|
.sidebar.slim:not(.animating) {
|
||||||
|
transition: none;
|
||||||
|
|
||||||
li.nav-item span,
|
li.nav-item span,
|
||||||
.sidebar-heading span {
|
.sidebar-heading span {
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.sidebar.slim:not(.animating) ~ main.col-slim {
|
||||||
|
transition: none;
|
||||||
|
}
|
||||||
|
|
||||||
.sidebar.animating {
|
.sidebar.animating {
|
||||||
li.nav-item span,
|
li.nav-item span,
|
||||||
.sidebar-heading span {
|
.sidebar-heading span {
|
||||||
|
|||||||
@@ -159,11 +159,7 @@
|
|||||||
<button class="btn btn-sm d-flex align-items-center btn-dark text-uppercase small" [ngbPopover]="indexStatus" triggers="click mouseenter:mouseleave">
|
<button class="btn btn-sm d-flex align-items-center btn-dark text-uppercase small" [ngbPopover]="indexStatus" triggers="click mouseenter:mouseleave">
|
||||||
{{status.tasks.index_status}}
|
{{status.tasks.index_status}}
|
||||||
@if (status.tasks.index_status === 'OK') {
|
@if (status.tasks.index_status === 'OK') {
|
||||||
@if (isStale(status.tasks.index_last_modified)) {
|
<i-bs name="check-circle-fill" class="text-primary ms-2 lh-1"></i-bs>
|
||||||
<i-bs name="exclamation-triangle-fill" class="text-warning ms-2 lh-1"></i-bs>
|
|
||||||
} @else {
|
|
||||||
<i-bs name="check-circle-fill" class="text-primary ms-2 lh-1"></i-bs>
|
|
||||||
}
|
|
||||||
} @else {
|
} @else {
|
||||||
<i-bs name="exclamation-triangle-fill" class="text-danger ms-2 lh-1"></i-bs>
|
<i-bs name="exclamation-triangle-fill" class="text-danger ms-2 lh-1"></i-bs>
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ import json
|
|||||||
import logging
|
import logging
|
||||||
import operator
|
import operator
|
||||||
from contextlib import contextmanager
|
from contextlib import contextmanager
|
||||||
|
from decimal import Decimal
|
||||||
|
from decimal import InvalidOperation
|
||||||
from typing import TYPE_CHECKING
|
from typing import TYPE_CHECKING
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
@@ -291,6 +293,34 @@ class MimeTypeFilter(Filter):
|
|||||||
return qs
|
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):
|
class SelectField(serializers.CharField):
|
||||||
def __init__(self, custom_field: CustomField) -> None:
|
def __init__(self, custom_field: CustomField) -> None:
|
||||||
self._options = custom_field.extra_data["select_options"]
|
self._options = custom_field.extra_data["select_options"]
|
||||||
@@ -516,9 +546,8 @@ class CustomFieldQueryParser:
|
|||||||
value_field_name = CustomFieldInstance.get_value_field_name(
|
value_field_name = CustomFieldInstance.get_value_field_name(
|
||||||
custom_field.data_type,
|
custom_field.data_type,
|
||||||
)
|
)
|
||||||
if (
|
if custom_field.data_type == CustomField.FieldDataType.MONETARY and (
|
||||||
custom_field.data_type == CustomField.FieldDataType.MONETARY
|
op in self.EXPR_BY_CATEGORY["arithmetic"] or op in {"exact", "in"}
|
||||||
and op in self.EXPR_BY_CATEGORY["arithmetic"]
|
|
||||||
):
|
):
|
||||||
value_field_name = "value_monetary_amount"
|
value_field_name = "value_monetary_amount"
|
||||||
has_field = Q(custom_fields__field=custom_field)
|
has_field = Q(custom_fields__field=custom_field)
|
||||||
@@ -628,6 +657,13 @@ class CustomFieldQueryParser:
|
|||||||
elif custom_field.data_type == CustomField.FieldDataType.URL:
|
elif custom_field.data_type == CustomField.FieldDataType.URL:
|
||||||
# For URL fields we don't need to be strict about validation (e.g., for istartswith).
|
# For URL fields we don't need to be strict about validation (e.g., for istartswith).
|
||||||
field = serializers.CharField()
|
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:
|
else:
|
||||||
# The general case: inferred from the corresponding field in CustomFieldInstance.
|
# The general case: inferred from the corresponding field in CustomFieldInstance.
|
||||||
value_field_name = CustomFieldInstance.get_value_field_name(
|
value_field_name = CustomFieldInstance.get_value_field_name(
|
||||||
|
|||||||
@@ -3352,13 +3352,13 @@ class WorkflowSerializer(serializers.ModelSerializer[Workflow]):
|
|||||||
ManyToMany fields dont support e.g. on_delete so we need to discard unattached
|
ManyToMany fields dont support e.g. on_delete so we need to discard unattached
|
||||||
triggers and actions manually
|
triggers and actions manually
|
||||||
"""
|
"""
|
||||||
for trigger in WorkflowTrigger.objects.all():
|
WorkflowTrigger.objects.annotate(
|
||||||
if trigger.workflows.all().count() == 0:
|
workflow_count=Count("workflows"),
|
||||||
trigger.delete()
|
).filter(workflow_count=0).delete()
|
||||||
|
|
||||||
for action in WorkflowAction.objects.all():
|
WorkflowAction.objects.annotate(
|
||||||
if action.workflows.all().count() == 0:
|
workflow_count=Count("workflows"),
|
||||||
action.delete()
|
).filter(workflow_count=0).delete()
|
||||||
|
|
||||||
WorkflowActionEmail.objects.filter(action=None).delete()
|
WorkflowActionEmail.objects.filter(action=None).delete()
|
||||||
WorkflowActionWebhook.objects.filter(action=None).delete()
|
WorkflowActionWebhook.objects.filter(action=None).delete()
|
||||||
@@ -3387,16 +3387,6 @@ class WorkflowSerializer(serializers.ModelSerializer[Workflow]):
|
|||||||
|
|
||||||
return instance
|
return instance
|
||||||
|
|
||||||
def to_representation(self, instance: Workflow) -> dict[str, Any]:
|
|
||||||
data = super().to_representation(instance)
|
|
||||||
actions = instance.actions.order_by("order", "pk")
|
|
||||||
data["actions"] = WorkflowActionSerializer(
|
|
||||||
actions,
|
|
||||||
many=True,
|
|
||||||
context=self.context,
|
|
||||||
).data
|
|
||||||
return data
|
|
||||||
|
|
||||||
|
|
||||||
class TrashSerializer(SerializerWithPerms):
|
class TrashSerializer(SerializerWithPerms):
|
||||||
documents = serializers.ListField(
|
documents = serializers.ListField(
|
||||||
|
|||||||
@@ -9,6 +9,8 @@ from rest_framework.test import APITestCase
|
|||||||
|
|
||||||
from documents.models import CustomField
|
from documents.models import CustomField
|
||||||
from documents.models import Document
|
from documents.models import Document
|
||||||
|
from documents.models import SavedView
|
||||||
|
from documents.models import SavedViewFilterRule
|
||||||
from documents.serialisers import DocumentSerializer
|
from documents.serialisers import DocumentSerializer
|
||||||
from documents.tests.utils import DirectoriesMixin
|
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) #
|
# Subset check (document link field only) #
|
||||||
# ==========================================================#
|
# ==========================================================#
|
||||||
|
|||||||
@@ -99,6 +99,40 @@ class TestApiWorkflows(DirectoriesMixin, APITestCase):
|
|||||||
self.action.assign_correspondent.pk,
|
self.action.assign_correspondent.pk,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def test_api_get_workflow_actions_ordered(self) -> None:
|
||||||
|
"""
|
||||||
|
GIVEN:
|
||||||
|
- A workflow with two actions added in reverse order (order=1 before order=0)
|
||||||
|
WHEN:
|
||||||
|
- API is called to get workflows
|
||||||
|
THEN:
|
||||||
|
- Actions are returned sorted by order ascending
|
||||||
|
"""
|
||||||
|
# Created before action_first so its pk is lower — ensures pk order
|
||||||
|
# disagrees with the order field, catching regressions if order_by is removed.
|
||||||
|
action_second = WorkflowAction.objects.create(
|
||||||
|
assign_title="Second action",
|
||||||
|
order=1,
|
||||||
|
)
|
||||||
|
action_first = WorkflowAction.objects.create(
|
||||||
|
assign_title="First action",
|
||||||
|
order=0,
|
||||||
|
)
|
||||||
|
self.workflow.actions.add(action_second)
|
||||||
|
self.workflow.actions.add(action_first)
|
||||||
|
|
||||||
|
response = self.client.get(self.ENDPOINT, format="json")
|
||||||
|
|
||||||
|
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||||
|
resp_actions = response.data["results"][0]["actions"]
|
||||||
|
action_ids = [a["id"] for a in resp_actions]
|
||||||
|
self.assertIn(action_first.id, action_ids)
|
||||||
|
self.assertIn(action_second.id, action_ids)
|
||||||
|
self.assertLess(
|
||||||
|
action_ids.index(action_first.id),
|
||||||
|
action_ids.index(action_second.id),
|
||||||
|
)
|
||||||
|
|
||||||
def test_api_create_workflow(self) -> None:
|
def test_api_create_workflow(self) -> None:
|
||||||
"""
|
"""
|
||||||
GIVEN:
|
GIVEN:
|
||||||
|
|||||||
@@ -968,7 +968,10 @@ class DocumentViewSet(
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
"tags",
|
"tags",
|
||||||
"custom_fields",
|
Prefetch(
|
||||||
|
"custom_fields",
|
||||||
|
queryset=CustomFieldInstance.objects.select_related("field"),
|
||||||
|
),
|
||||||
"notes",
|
"notes",
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
@@ -4482,8 +4485,44 @@ class WorkflowViewSet(ModelViewSet[Workflow]):
|
|||||||
Workflow.objects.all()
|
Workflow.objects.all()
|
||||||
.order_by("order")
|
.order_by("order")
|
||||||
.prefetch_related(
|
.prefetch_related(
|
||||||
"triggers",
|
Prefetch(
|
||||||
"actions",
|
"triggers",
|
||||||
|
queryset=WorkflowTrigger.objects.prefetch_related(
|
||||||
|
"filter_has_tags",
|
||||||
|
"filter_has_all_tags",
|
||||||
|
"filter_has_not_tags",
|
||||||
|
"filter_has_any_correspondents",
|
||||||
|
"filter_has_not_correspondents",
|
||||||
|
"filter_has_any_document_types",
|
||||||
|
"filter_has_not_document_types",
|
||||||
|
"filter_has_any_storage_paths",
|
||||||
|
"filter_has_not_storage_paths",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Prefetch(
|
||||||
|
"actions",
|
||||||
|
queryset=WorkflowAction.objects.order_by(
|
||||||
|
"order",
|
||||||
|
"pk",
|
||||||
|
).prefetch_related(
|
||||||
|
"assign_tags",
|
||||||
|
"assign_view_users",
|
||||||
|
"assign_view_groups",
|
||||||
|
"assign_change_users",
|
||||||
|
"assign_change_groups",
|
||||||
|
"assign_custom_fields",
|
||||||
|
"remove_tags",
|
||||||
|
"remove_correspondents",
|
||||||
|
"remove_document_types",
|
||||||
|
"remove_storage_paths",
|
||||||
|
"remove_custom_fields",
|
||||||
|
"remove_owners",
|
||||||
|
"remove_view_users",
|
||||||
|
"remove_view_groups",
|
||||||
|
"remove_change_users",
|
||||||
|
"remove_change_groups",
|
||||||
|
),
|
||||||
|
),
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ msgid ""
|
|||||||
msgstr ""
|
msgstr ""
|
||||||
"Project-Id-Version: paperless-ngx\n"
|
"Project-Id-Version: paperless-ngx\n"
|
||||||
"Report-Msgid-Bugs-To: \n"
|
"Report-Msgid-Bugs-To: \n"
|
||||||
"POT-Creation-Date: 2026-04-20 20:20+0000\n"
|
"POT-Creation-Date: 2026-04-21 18:02+0000\n"
|
||||||
"PO-Revision-Date: 2022-02-17 04:17\n"
|
"PO-Revision-Date: 2022-02-17 04:17\n"
|
||||||
"Last-Translator: \n"
|
"Last-Translator: \n"
|
||||||
"Language-Team: English\n"
|
"Language-Team: English\n"
|
||||||
@@ -21,39 +21,39 @@ msgstr ""
|
|||||||
msgid "Documents"
|
msgid "Documents"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: documents/filters.py:433
|
#: documents/filters.py:463
|
||||||
msgid "Value must be valid JSON."
|
msgid "Value must be valid JSON."
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: documents/filters.py:452
|
#: documents/filters.py:482
|
||||||
msgid "Invalid custom field query expression"
|
msgid "Invalid custom field query expression"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: documents/filters.py:462
|
#: documents/filters.py:492
|
||||||
msgid "Invalid expression list. Must be nonempty."
|
msgid "Invalid expression list. Must be nonempty."
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: documents/filters.py:483
|
#: documents/filters.py:513
|
||||||
msgid "Invalid logical operator {op!r}"
|
msgid "Invalid logical operator {op!r}"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: documents/filters.py:497
|
#: documents/filters.py:527
|
||||||
msgid "Maximum number of query conditions exceeded."
|
msgid "Maximum number of query conditions exceeded."
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: documents/filters.py:562
|
#: documents/filters.py:591
|
||||||
msgid "{name!r} is not a valid custom field."
|
msgid "{name!r} is not a valid custom field."
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: documents/filters.py:599
|
#: documents/filters.py:628
|
||||||
msgid "{data_type} does not support query expr {expr!r}."
|
msgid "{data_type} does not support query expr {expr!r}."
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: documents/filters.py:707 documents/models.py:136
|
#: documents/filters.py:743 documents/models.py:136
|
||||||
msgid "Maximum nesting depth exceeded."
|
msgid "Maximum nesting depth exceeded."
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: documents/filters.py:954
|
#: documents/filters.py:990
|
||||||
msgid "Custom field not found"
|
msgid "Custom field not found"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
@@ -1352,8 +1352,8 @@ msgid "workflow runs"
|
|||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: documents/serialisers.py:463 documents/serialisers.py:815
|
#: documents/serialisers.py:463 documents/serialisers.py:815
|
||||||
#: documents/serialisers.py:2681 documents/views.py:2255
|
#: documents/serialisers.py:2681 documents/views.py:2258
|
||||||
#: documents/views.py:2324 paperless_mail/serialisers.py:143
|
#: documents/views.py:2327 paperless_mail/serialisers.py:143
|
||||||
msgid "Insufficient permissions."
|
msgid "Insufficient permissions."
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
@@ -1393,7 +1393,7 @@ msgstr ""
|
|||||||
msgid "Duplicate document identifiers are not allowed."
|
msgid "Duplicate document identifiers are not allowed."
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: documents/serialisers.py:2767 documents/views.py:4104
|
#: documents/serialisers.py:2767 documents/views.py:4107
|
||||||
#, python-format
|
#, python-format
|
||||||
msgid "Documents not found: %(ids)s"
|
msgid "Documents not found: %(ids)s"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
@@ -1661,28 +1661,28 @@ msgstr ""
|
|||||||
msgid "Unable to parse URI {value}"
|
msgid "Unable to parse URI {value}"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: documents/views.py:2135
|
#: documents/views.py:2138
|
||||||
msgid "Specify only one of text, title_search, query, or more_like_id."
|
msgid "Specify only one of text, title_search, query, or more_like_id."
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: documents/views.py:2248 documents/views.py:2321
|
#: documents/views.py:2251 documents/views.py:2324
|
||||||
msgid "Invalid more_like_id"
|
msgid "Invalid more_like_id"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: documents/views.py:4116
|
#: documents/views.py:4119
|
||||||
#, python-format
|
#, python-format
|
||||||
msgid "Insufficient permissions to share document %(id)s."
|
msgid "Insufficient permissions to share document %(id)s."
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: documents/views.py:4162
|
#: documents/views.py:4165
|
||||||
msgid "Bundle is already being processed."
|
msgid "Bundle is already being processed."
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: documents/views.py:4222
|
#: documents/views.py:4225
|
||||||
msgid "The share link bundle is still being prepared. Please try again later."
|
msgid "The share link bundle is still being prepared. Please try again later."
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: documents/views.py:4232
|
#: documents/views.py:4235
|
||||||
msgid "The share link bundle is unavailable."
|
msgid "The share link bundle is unavailable."
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user