Feature: custom fields queries (#7761)

This commit is contained in:
shamoon 2024-10-02 17:15:42 -07:00 committed by GitHub
parent 2e3637d712
commit f8d79b012f
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
26 changed files with 2130 additions and 599 deletions

View File

@ -278,39 +278,39 @@ attribute with various information about the search results:
### Filtering by custom fields
You can filter documents by their custom field values by specifying the
`custom_field_lookup` query parameter. Here are some recipes for common
`custom_field_query` query parameter. Here are some recipes for common
use cases:
1. Documents with a custom field "due" (date) between Aug 1, 2024 and
Sept 1, 2024 (inclusive):
`?custom_field_lookup=["due", "range", ["2024-08-01", "2024-09-01"]]`
`?custom_field_query=["due", "range", ["2024-08-01", "2024-09-01"]]`
2. Documents with a custom field "customer" (text) that equals "bob"
(case sensitive):
`?custom_field_lookup=["customer", "exact", "bob"]`
`?custom_field_query=["customer", "exact", "bob"]`
3. Documents with a custom field "answered" (boolean) set to `true`:
`?custom_field_lookup=["answered", "exact", true]`
`?custom_field_query=["answered", "exact", true]`
4. Documents with a custom field "favorite animal" (select) set to either
"cat" or "dog":
`?custom_field_lookup=["favorite animal", "in", ["cat", "dog"]]`
`?custom_field_query=["favorite animal", "in", ["cat", "dog"]]`
5. Documents with a custom field "address" (text) that is empty:
`?custom_field_lookup=["OR", ["address", "isnull", true], ["address", "exact", ""]]`
`?custom_field_query=["OR", ["address", "isnull", true], ["address", "exact", ""]]`
6. Documents that don't have a field called "foo":
`?custom_field_lookup=["foo", "exists", false]`
`?custom_field_query=["foo", "exists", false]`
7. Documents that have document links "references" to both document 3 and 7:
`?custom_field_lookup=["references", "contains", [3, 7]]`
`?custom_field_query=["references", "contains", [3, 7]]`
All field types support basic operations including `exact`, `in`, `isnull`,
and `exists`. String, URL, and monetary fields support case-insensitive
@ -320,22 +320,6 @@ including `gt` (>), `gte` (>=), `lt` (<), `lte` (<=), and `range`.
Lastly, document link fields support a `contains` operator that behaves
like a "is superset of" check.
!!! warning
It is possible to do case-insensitive exact match (i.e., `iexact`) and
case-sensitive substring match (i.e., `contains`, `startswith`,
`endswith`) for string, URL, and monetary fields, but
[they may not work as expected on some database backends](https://docs.djangoproject.com/en/5.1/ref/databases/#substring-matching-and-case-sensitivity).
It is also possible to use regular expressions to match string, URL, and
monetary fields, but the syntax is database-dependent, and accepting
regular expressions from untrusted sources could make your instance
vulnerable to regular expression denial of service attacks.
For these reasons the above expressions are disabled by default.
If you understand the implications, you may enable them by uncommenting
`PAPERLESS_CUSTOM_FIELD_LOOKUP_OPT_IN` in your configuration file.
### `/api/search/autocomplete/`
Get auto completions for a partial search term.

View File

@ -81,7 +81,6 @@
#PAPERLESS_THUMBNAIL_FONT_NAME=
#PAPERLESS_IGNORE_DATES=
#PAPERLESS_ENABLE_UPDATE_CHECK=
#PAPERLESS_ALLOW_CUSTOM_FIELD_LOOKUP=iexact,contains,startswith,endswith,regex,iregex
# Tika settings

View File

@ -698,7 +698,7 @@
</context-group>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/common/input/document-link/document-link.component.html</context>
<context context-type="linenumber">38</context>
<context context-type="linenumber">51</context>
</context-group>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/common/permissions-dialog/permissions-dialog.component.html</context>
@ -1031,7 +1031,7 @@
</context-group>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-list/filter-editor/filter-editor.component.ts</context>
<context context-type="linenumber">143</context>
<context context-type="linenumber">152</context>
</context-group>
</trans-unit>
<trans-unit id="8104421162933956065" datatype="html">
@ -1088,7 +1088,7 @@
</context-group>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-list/filter-editor/filter-editor.component.html</context>
<context context-type="linenumber">110</context>
<context context-type="linenumber">105</context>
</context-group>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/manage/mail/mail.component.html</context>
@ -3300,6 +3300,102 @@
<context context-type="linenumber">63</context>
</context-group>
</trans-unit>
<trans-unit id="4465085913683915434" datatype="html">
<source>True</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/common/custom-fields-query-dropdown/custom-fields-query-dropdown.component.html</context>
<context context-type="linenumber">40</context>
</context-group>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/common/custom-fields-query-dropdown/custom-fields-query-dropdown.component.html</context>
<context context-type="linenumber">73</context>
</context-group>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/common/custom-fields-query-dropdown/custom-fields-query-dropdown.component.html</context>
<context context-type="linenumber">79</context>
</context-group>
</trans-unit>
<trans-unit id="3800326155195149498" datatype="html">
<source>False</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/common/custom-fields-query-dropdown/custom-fields-query-dropdown.component.html</context>
<context context-type="linenumber">41</context>
</context-group>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/common/custom-fields-query-dropdown/custom-fields-query-dropdown.component.html</context>
<context context-type="linenumber">74</context>
</context-group>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/common/custom-fields-query-dropdown/custom-fields-query-dropdown.component.html</context>
<context context-type="linenumber">80</context>
</context-group>
</trans-unit>
<trans-unit id="7551700625201096185" datatype="html">
<source>Search docs...</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/common/custom-fields-query-dropdown/custom-fields-query-dropdown.component.html</context>
<context context-type="linenumber">96</context>
</context-group>
</trans-unit>
<trans-unit id="3184700926171002527" datatype="html">
<source>Any</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/common/custom-fields-query-dropdown/custom-fields-query-dropdown.component.html</context>
<context context-type="linenumber">126</context>
</context-group>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/common/filterable-dropdown/filterable-dropdown.component.html</context>
<context context-type="linenumber">17</context>
</context-group>
</trans-unit>
<trans-unit id="1616102757855967475" datatype="html">
<source>All</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/common/custom-fields-query-dropdown/custom-fields-query-dropdown.component.html</context>
<context context-type="linenumber">128</context>
</context-group>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/common/filterable-dropdown/filterable-dropdown.component.html</context>
<context context-type="linenumber">15</context>
</context-group>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/common/permissions-filter-dropdown/permissions-filter-dropdown.component.html</context>
<context context-type="linenumber">16</context>
</context-group>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/common/permissions-select/permissions-select.component.html</context>
<context context-type="linenumber">16</context>
</context-group>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/common/permissions-select/permissions-select.component.html</context>
<context context-type="linenumber">27</context>
</context-group>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.html</context>
<context context-type="linenumber">14</context>
</context-group>
</trans-unit>
<trans-unit id="1496549861742963591" datatype="html">
<source>Not</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/common/custom-fields-query-dropdown/custom-fields-query-dropdown.component.html</context>
<context context-type="linenumber">131</context>
</context-group>
</trans-unit>
<trans-unit id="6548676277933116532" datatype="html">
<source>Add query</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/common/custom-fields-query-dropdown/custom-fields-query-dropdown.component.html</context>
<context context-type="linenumber">150</context>
</context-group>
</trans-unit>
<trans-unit id="5599577087865387184" datatype="html">
<source>Add expression</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/common/custom-fields-query-dropdown/custom-fields-query-dropdown.component.html</context>
<context context-type="linenumber">153</context>
</context-group>
</trans-unit>
<trans-unit id="6052766076365105714" datatype="html">
<source>now</source>
<context-group purpose="location">
@ -4549,36 +4645,6 @@
<context context-type="linenumber">146</context>
</context-group>
</trans-unit>
<trans-unit id="1616102757855967475" datatype="html">
<source>All</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/common/filterable-dropdown/filterable-dropdown.component.html</context>
<context context-type="linenumber">15</context>
</context-group>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/common/permissions-filter-dropdown/permissions-filter-dropdown.component.html</context>
<context context-type="linenumber">16</context>
</context-group>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/common/permissions-select/permissions-select.component.html</context>
<context context-type="linenumber">16</context>
</context-group>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/common/permissions-select/permissions-select.component.html</context>
<context context-type="linenumber">27</context>
</context-group>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.html</context>
<context context-type="linenumber">14</context>
</context-group>
</trans-unit>
<trans-unit id="3184700926171002527" datatype="html">
<source>Any</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/common/filterable-dropdown/filterable-dropdown.component.html</context>
<context context-type="linenumber">17</context>
</context-group>
</trans-unit>
<trans-unit id="6381578200008167206" datatype="html">
<source>Include</source>
<context-group purpose="location">
@ -4668,7 +4734,7 @@
</context-group>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/common/input/document-link/document-link.component.html</context>
<context context-type="linenumber">9</context>
<context context-type="linenumber">12</context>
</context-group>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/common/input/file/file.component.html</context>
@ -4740,14 +4806,14 @@
<source>Remove link</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/common/input/document-link/document-link.component.html</context>
<context context-type="linenumber">30</context>
<context context-type="linenumber">43</context>
</context-group>
</trans-unit>
<trans-unit id="1388712764439031120" datatype="html">
<source>Open link</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/common/input/document-link/document-link.component.html</context>
<context context-type="linenumber">31</context>
<context context-type="linenumber">44</context>
</context-group>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/common/input/url/url.component.html</context>
@ -4761,6 +4827,13 @@
<context context-type="linenumber">44</context>
</context-group>
</trans-unit>
<trans-unit id="5676637575587497817" datatype="html">
<source>Search for documents</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/common/input/document-link/document-link.component.ts</context>
<context context-type="linenumber">53</context>
</context-group>
</trans-unit>
<trans-unit id="8627133593113147800" datatype="html">
<source>Selected items</source>
<context-group purpose="location">
@ -5834,7 +5907,7 @@
</context-group>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-list/filter-editor/filter-editor.component.ts</context>
<context context-type="linenumber">131</context>
<context context-type="linenumber">140</context>
</context-group>
<context-group purpose="location">
<context context-type="sourcefile">src/app/data/document.ts</context>
@ -6416,7 +6489,7 @@
</context-group>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-list/filter-editor/filter-editor.component.ts</context>
<context context-type="linenumber">139</context>
<context context-type="linenumber">148</context>
</context-group>
</trans-unit>
<trans-unit id="6475890479659129881" datatype="html">
@ -6425,10 +6498,6 @@
<context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.html</context>
<context context-type="linenumber">83</context>
</context-group>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-list/filter-editor/filter-editor.component.html</context>
<context context-type="linenumber">90</context>
</context-group>
</trans-unit>
<trans-unit id="3206542606001340679" datatype="html">
<source>Merge</source>
@ -6925,7 +6994,7 @@
</context-group>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-list/filter-editor/filter-editor.component.html</context>
<context context-type="linenumber">116</context>
<context context-type="linenumber">111</context>
</context-group>
</trans-unit>
<trans-unit id="1559883523769732271" datatype="html">
@ -6950,7 +7019,7 @@
</context-group>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-list/filter-editor/filter-editor.component.ts</context>
<context context-type="linenumber">136</context>
<context context-type="linenumber">145</context>
</context-group>
<context-group purpose="location">
<context context-type="sourcefile">src/app/data/document.ts</context>
@ -7126,161 +7195,154 @@
<source>Dates</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-list/filter-editor/filter-editor.component.html</context>
<context context-type="linenumber">100</context>
<context context-type="linenumber">95</context>
</context-group>
</trans-unit>
<trans-unit id="3100631071441658964" datatype="html">
<source>Title &amp; content</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-list/filter-editor/filter-editor.component.ts</context>
<context context-type="linenumber">134</context>
<context context-type="linenumber">143</context>
</context-group>
</trans-unit>
<trans-unit id="2649431021108393503" datatype="html">
<source>More like</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-list/filter-editor/filter-editor.component.ts</context>
<context context-type="linenumber">149</context>
<context context-type="linenumber">158</context>
</context-group>
</trans-unit>
<trans-unit id="3697582909018473071" datatype="html">
<source>equals</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-list/filter-editor/filter-editor.component.ts</context>
<context context-type="linenumber">155</context>
<context context-type="linenumber">164</context>
</context-group>
</trans-unit>
<trans-unit id="5325481293405718739" datatype="html">
<source>is empty</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-list/filter-editor/filter-editor.component.ts</context>
<context context-type="linenumber">159</context>
<context context-type="linenumber">168</context>
</context-group>
</trans-unit>
<trans-unit id="6166785695326182482" datatype="html">
<source>is not empty</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-list/filter-editor/filter-editor.component.ts</context>
<context context-type="linenumber">163</context>
<context context-type="linenumber">172</context>
</context-group>
</trans-unit>
<trans-unit id="4686622206659266699" datatype="html">
<source>greater than</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-list/filter-editor/filter-editor.component.ts</context>
<context context-type="linenumber">167</context>
<context context-type="linenumber">176</context>
</context-group>
</trans-unit>
<trans-unit id="8014012170270529279" datatype="html">
<source>less than</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-list/filter-editor/filter-editor.component.ts</context>
<context context-type="linenumber">171</context>
<context context-type="linenumber">180</context>
</context-group>
</trans-unit>
<trans-unit id="5195932016807797291" datatype="html">
<source>Correspondent: <x id="PH" equiv-text="this.correspondents.find((c) =&gt; c.id == +rule.value)?.name"/></source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-list/filter-editor/filter-editor.component.ts</context>
<context context-type="linenumber">191,193</context>
<context context-type="linenumber">200,202</context>
</context-group>
</trans-unit>
<trans-unit id="8170755470576301659" datatype="html">
<source>Without correspondent</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-list/filter-editor/filter-editor.component.ts</context>
<context context-type="linenumber">195</context>
<context context-type="linenumber">204</context>
</context-group>
</trans-unit>
<trans-unit id="317796810569008208" datatype="html">
<source>Document type: <x id="PH" equiv-text="this.documentTypes.find((dt) =&gt; dt.id == +rule.value)?.name"/></source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-list/filter-editor/filter-editor.component.ts</context>
<context context-type="linenumber">201,203</context>
<context context-type="linenumber">210,212</context>
</context-group>
</trans-unit>
<trans-unit id="4362173610367509215" datatype="html">
<source>Without document type</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-list/filter-editor/filter-editor.component.ts</context>
<context context-type="linenumber">205</context>
<context context-type="linenumber">214</context>
</context-group>
</trans-unit>
<trans-unit id="232202047340644471" datatype="html">
<source>Storage path: <x id="PH" equiv-text="this.storagePaths.find((sp) =&gt; sp.id == +rule.value)?.name"/></source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-list/filter-editor/filter-editor.component.ts</context>
<context context-type="linenumber">211,213</context>
<context context-type="linenumber">220,222</context>
</context-group>
</trans-unit>
<trans-unit id="1562820715074533164" datatype="html">
<source>Without storage path</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-list/filter-editor/filter-editor.component.ts</context>
<context context-type="linenumber">215</context>
<context context-type="linenumber">224</context>
</context-group>
</trans-unit>
<trans-unit id="8180755793012580465" datatype="html">
<source>Tag: <x id="PH" equiv-text="this.tags.find((t) =&gt; t.id == +rule.value)?.name"/></source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-list/filter-editor/filter-editor.component.ts</context>
<context context-type="linenumber">219,221</context>
<context context-type="linenumber">228,230</context>
</context-group>
</trans-unit>
<trans-unit id="6494566478302448576" datatype="html">
<source>Without any tag</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-list/filter-editor/filter-editor.component.ts</context>
<context context-type="linenumber">225</context>
<context context-type="linenumber">234</context>
</context-group>
</trans-unit>
<trans-unit id="6370692707013694620" datatype="html">
<source>Custom fields: <x id="PH" equiv-text="this.customFields.find((f) =&gt; f.id == +rule.value)?.name"/></source>
<trans-unit id="8644099678903817943" datatype="html">
<source>Custom fields query</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-list/filter-editor/filter-editor.component.ts</context>
<context context-type="linenumber">229,231</context>
</context-group>
</trans-unit>
<trans-unit id="5297600960590041873" datatype="html">
<source>Without any custom field</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-list/filter-editor/filter-editor.component.ts</context>
<context context-type="linenumber">235</context>
<context context-type="linenumber">238</context>
</context-group>
</trans-unit>
<trans-unit id="6523384805359286307" datatype="html">
<source>Title: <x id="PH" equiv-text="rule.value"/></source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-list/filter-editor/filter-editor.component.ts</context>
<context context-type="linenumber">239</context>
<context context-type="linenumber">241</context>
</context-group>
</trans-unit>
<trans-unit id="1872523635812236432" datatype="html">
<source>ASN: <x id="PH" equiv-text="rule.value"/></source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-list/filter-editor/filter-editor.component.ts</context>
<context context-type="linenumber">242</context>
<context context-type="linenumber">244</context>
</context-group>
</trans-unit>
<trans-unit id="102674688969746976" datatype="html">
<source>Owner: <x id="PH" equiv-text="rule.value"/></source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-list/filter-editor/filter-editor.component.ts</context>
<context context-type="linenumber">245</context>
<context context-type="linenumber">247</context>
</context-group>
</trans-unit>
<trans-unit id="3550877650686009106" datatype="html">
<source>Owner not in: <x id="PH" equiv-text="rule.value"/></source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-list/filter-editor/filter-editor.component.ts</context>
<context context-type="linenumber">248</context>
<context context-type="linenumber">250</context>
</context-group>
</trans-unit>
<trans-unit id="1082034558646673343" datatype="html">
<source>Without an owner</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-list/filter-editor/filter-editor.component.ts</context>
<context context-type="linenumber">251</context>
<context context-type="linenumber">253</context>
</context-group>
</trans-unit>
<trans-unit id="7210076240260527720" datatype="html">
@ -8007,6 +8069,83 @@
<context context-type="linenumber">9</context>
</context-group>
</trans-unit>
<trans-unit id="7088714514100361567" datatype="html">
<source>Equal to</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/data/custom-field-query.ts</context>
<context context-type="linenumber">24</context>
</context-group>
</trans-unit>
<trans-unit id="2841739558138901231" datatype="html">
<source>In</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/data/custom-field-query.ts</context>
<context context-type="linenumber">25</context>
</context-group>
</trans-unit>
<trans-unit id="6504828068656625171" datatype="html">
<source>Is null</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/data/custom-field-query.ts</context>
<context context-type="linenumber">26</context>
</context-group>
</trans-unit>
<trans-unit id="4112599358351148632" datatype="html">
<source>Exists</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/data/custom-field-query.ts</context>
<context context-type="linenumber">27</context>
</context-group>
</trans-unit>
<trans-unit id="6238291467288576076" datatype="html">
<source>Contains</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/data/custom-field-query.ts</context>
<context context-type="linenumber">28</context>
</context-group>
</trans-unit>
<trans-unit id="870133374397538941" datatype="html">
<source>Contains (case-insensitive)</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/data/custom-field-query.ts</context>
<context context-type="linenumber">29</context>
</context-group>
</trans-unit>
<trans-unit id="7732309408488818531" datatype="html">
<source>Greater than</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/data/custom-field-query.ts</context>
<context context-type="linenumber">30</context>
</context-group>
</trans-unit>
<trans-unit id="9087788064443057357" datatype="html">
<source>Greater than or equal to</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/data/custom-field-query.ts</context>
<context context-type="linenumber">31</context>
</context-group>
</trans-unit>
<trans-unit id="5995604223909447366" datatype="html">
<source>Less than</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/data/custom-field-query.ts</context>
<context context-type="linenumber">32</context>
</context-group>
</trans-unit>
<trans-unit id="6989379963430864867" datatype="html">
<source>Less than or equal to</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/data/custom-field-query.ts</context>
<context context-type="linenumber">33</context>
</context-group>
</trans-unit>
<trans-unit id="2348971518300945764" datatype="html">
<source>Range</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/data/custom-field-query.ts</context>
<context context-type="linenumber">34</context>
</context-group>
</trans-unit>
<trans-unit id="969459137986754249" datatype="html">
<source>Boolean</source>
<context-group purpose="location">

View File

@ -108,6 +108,7 @@ import { FileDropComponent } from './components/file-drop/file-drop.component'
import { CustomFieldsComponent } from './components/manage/custom-fields/custom-fields.component'
import { CustomFieldEditDialogComponent } from './components/common/edit-dialog/custom-field-edit-dialog/custom-field-edit-dialog.component'
import { CustomFieldsDropdownComponent } from './components/common/custom-fields-dropdown/custom-fields-dropdown.component'
import { CustomFieldsQueryDropdownComponent } from './components/common/custom-fields-query-dropdown/custom-fields-query-dropdown.component'
import { ProfileEditDialogComponent } from './components/common/profile-edit-dialog/profile-edit-dialog.component'
import { PdfViewerModule } from 'ng2-pdf-viewer'
import { DocumentLinkComponent } from './components/common/input/document-link/document-link.component'
@ -141,6 +142,7 @@ import {
arrowRightShort,
arrowUpRight,
asterisk,
braces,
bodyText,
boxArrowUp,
boxArrowUpRight,
@ -198,6 +200,7 @@ import {
link,
listTask,
listUl,
nodePlus,
pencil,
people,
peopleFill,
@ -227,6 +230,7 @@ import {
uiRadios,
upcScan,
x,
xCircle,
xLg,
} from 'ngx-bootstrap-icons'
@ -242,6 +246,7 @@ const icons = {
arrowRightShort,
arrowUpRight,
asterisk,
braces,
bodyText,
boxArrowUp,
boxArrowUpRight,
@ -299,6 +304,7 @@ const icons = {
link,
listTask,
listUl,
nodePlus,
pencil,
people,
peopleFill,
@ -328,6 +334,7 @@ const icons = {
uiRadios,
upcScan,
x,
xCircle,
xLg,
}
@ -485,6 +492,7 @@ function initializeApp(settings: SettingsService) {
CustomFieldsComponent,
CustomFieldEditDialogComponent,
CustomFieldsDropdownComponent,
CustomFieldsQueryDropdownComponent,
ProfileEditDialogComponent,
DocumentLinkComponent,
PreviewPopupComponent,

View File

@ -0,0 +1,163 @@
<div class="btn-group w-100" role="group" ngbDropdown #dropdown="ngbDropdown" (openChange)="onOpenChange($event)" [popperOptions]="popperOptions">
<button class="btn btn-sm btn-outline-primary" id="dropdown_toggle" ngbDropdownToggle [disabled]="disabled">
<i-bs name="{{icon}}"></i-bs>
<div class="d-none d-sm-inline">&nbsp;{{title}}</div>
@if (isActive) {
<pngx-clearable-badge [selected]="isActive" (cleared)="reset()"></pngx-clearable-badge>
}
</button>
<div class="px-3 shadow" ngbDropdownMenu attr.aria-labelledby="dropdown_{{name}}">
<div class="list-group list-group-flush">
@for (element of selectionModel.queries; track element.id; let i = $index) {
<div class="list-group-item px-0 d-flex flex-nowrap">
@switch (element.type) {
@case (CustomFieldQueryComponentType.Atom) {
<ng-container *ngTemplateOutlet="queryAtom; context: { atom: element }"></ng-container>
}
@case (CustomFieldQueryComponentType.Expression) {
<ng-container *ngTemplateOutlet="queryExpression; context: { expression: element }"></ng-container>
}
}
</div>
}
</div>
</div>
</div>
<ng-template #comparisonValueTemplate let-atom="atom">
@if (getCustomFieldByID(atom.field)?.data_type === CustomFieldDataType.Date) {
<input class="form-control" placeholder="yyyy-mm-dd"
[(ngModel)]="atom.value"
ngbDatepicker
#d="ngbDatepicker" />
<button class="btn btn-sm btn-outline-secondary rounded-end" (click)="d.toggle()" type="button">
<i-bs name="calendar-event"></i-bs>
</button>
} @else if (getCustomFieldByID(atom.field)?.data_type === CustomFieldDataType.Float || getCustomFieldByID(atom.field)?.data_type === CustomFieldDataType.Integer) {
<input class="w-25 form-control rounded-end" type="number" [(ngModel)]="atom.value" [disabled]="disabled">
} @else if (getCustomFieldByID(atom.field)?.data_type === CustomFieldDataType.Boolean) {
<select class="w-25 form-select rounded-end" [(ngModel)]="atom.value" [disabled]="disabled">
<option value="true" i18n>True</option>
<option value="false" i18n>False</option>
</select>
} @else if (getCustomFieldByID(atom.field)?.data_type === CustomFieldDataType.Select) {
<ng-select
class="paperless-input-select rounded-end"
[items]="getSelectOptionsForField(atom.field)"
[(ngModel)]="atom.value"
[disabled]="disabled"
(mousedown)="$event.stopImmediatePropagation()"
></ng-select>
} @else {
<input class="w-25 form-control rounded-end" type="text" [(ngModel)]="atom.value" [disabled]="disabled">
}
</ng-template>
<ng-template #queryAtom let-atom="atom">
<div class="input-group input-group-sm">
<ng-select
class="paperless-input-select"
[items]="customFields"
[(ngModel)]="atom.field"
[disabled]="disabled"
bindLabel="name"
bindValue="id"
(mousedown)="$event.stopImmediatePropagation()"
></ng-select>
<select class="w-25 form-select" [(ngModel)]="atom.operator" [disabled]="disabled">
<option *ngFor="let operator of getOperatorsForField(atom.field)" [ngValue]="operator.value">{{operator.label}}</option>
</select>
@switch (atom.operator) {
@case (CustomFieldQueryOperator.Exists) {
<select class="w-25 form-select rounded-end" [(ngModel)]="atom.value" [disabled]="disabled">
<option value="true" i18n>True</option>
<option value="false" i18n>False</option>
</select>
}
@case (CustomFieldQueryOperator.IsNull) {
<select class="w-25 form-select rounded-end" [(ngModel)]="atom.value" [disabled]="disabled">
<option value="true" i18n>True</option>
<option value="false" i18n>False</option>
</select>
}
@case (CustomFieldQueryOperator.GreaterThanOrEqual) {
<ng-container *ngTemplateOutlet="comparisonValueTemplate; context: { atom: atom }"></ng-container>
}
@case (CustomFieldQueryOperator.LessThanOrEqual) {
<ng-container *ngTemplateOutlet="comparisonValueTemplate; context: { atom: atom }"></ng-container>
}
@case (CustomFieldQueryOperator.GreaterThan) {
<ng-container *ngTemplateOutlet="comparisonValueTemplate; context: { atom: atom }"></ng-container>
}
@case (CustomFieldQueryOperator.LessThan) {
<ng-container *ngTemplateOutlet="comparisonValueTemplate; context: { atom: atom }"></ng-container>
}
@case (CustomFieldQueryOperator.Contains) {
<pngx-input-document-link [(ngModel)]="atom.value" class="w-25 form-select doc-link-select p-0" placeholder="Search docs..." i18n-placeholder [minimal]="true"></pngx-input-document-link>
}
@case (CustomFieldQueryOperator.In) {
<ng-select
class="paperless-input-select rounded-end"
[items]="getSelectOptionsForField(atom.field)"
[(ngModel)]="atom.value"
[disabled]="disabled"
[multiple]="true"
(mousedown)="$event.stopImmediatePropagation()"
></ng-select>
}
@case (CustomFieldQueryOperator.Exact) {
<ng-container *ngTemplateOutlet="comparisonValueTemplate; context: { atom: atom }"></ng-container>
}
@default {
<input class="w-25 form-control rounded-end" type="text" [(ngModel)]="atom.value" [disabled]="disabled">
}
}
<button class="btn btn-link btn-sm text-danger pe-0" type="button" (click)="removeElement(atom)" [disabled]="disabled">
<i-bs name="x-circle"></i-bs>
</button>
</div>
</ng-template>
<ng-template #queryExpression let-expression="expression">
<div class="d-flex w-100">
<div class="d-flex flex-grow-1 flex-column">
<div class="btn-group btn-group-xs" role="group">
<input [(ngModel)]="expression.operator" type="radio" class="btn-check" id="logicalOperatorOr_{{expression.id}}" name="logicalOperatorOr_{{expression.id}}" value="OR" [disabled]="expression.depth > 0 && expression.value.length < 2">
<label class="btn btn-outline-primary" for="logicalOperatorOr_{{expression.id}}" i18n>Any</label>
<input [(ngModel)]="expression.operator" type="radio" class="btn-check" id="logicalOperatorAnd_{{expression.id}}" name="logicalOperatorAnd_{{expression.id}}" value="AND" [disabled]="expression.depth > 0 && expression.value.length < 2">
<label class="btn btn-outline-primary" for="logicalOperatorAnd_{{expression.id}}" i18n>All</label>
@if (expression.negatable) {
<input [(ngModel)]="expression.operator" type="radio" class="btn-check" id="logicalOperatorNot_{{expression.id}}" name="logicalOperatorNot_{{expression.id}}" value="NOT">
<label class="btn btn-outline-secondary" for="logicalOperatorNot_{{expression.id}}" i18n>Not</label>
}
</div>
<div class="list-group list-group-flush mb-n2">
@for (element of expression.value; track element.id; let i = $index) {
<div class="list-group-item px-0 d-flex flex-nowrap">
@switch (element.type) {
@case (CustomFieldQueryComponentType.Atom) {
<ng-container *ngTemplateOutlet="queryAtom; context: { atom: element }"></ng-container>
}
@case (CustomFieldQueryComponentType.Expression) {
<ng-container *ngTemplateOutlet="queryExpression; context: { expression: element }"></ng-container>
}
}
</div>
}
</div>
</div>
<div class="btn-group-vertical ms-2 ps-2 border-start" role="group" aria-label="Vertical button group">
<button type="button" class="btn btn-sm btn-outline-secondary text-primary" title="Add query" i18n-title (click)="addAtom(expression)" [disabled]="disabled || expression.value.length === CUSTOM_FIELD_QUERY_MAX_ATOMS">
<i-bs name="node-plus"></i-bs>
</button>
<button type="button" class="btn btn-sm btn-outline-secondary text-primary" title="Add expression" i18n-title (click)="addExpression(expression)" [disabled]="disabled || expression.depth === CUSTOM_FIELD_QUERY_MAX_DEPTH">
<i-bs name="braces"></i-bs>
</button>
@if (expression.depth > 0) {
<button type="button" class="btn btn-sm btn-outline-secondary text-danger" (click)="removeElement(expression)" [disabled]="disabled">
<i-bs name="x-circle"></i-bs>
</button>
}
</div>
</div>
</ng-template>

View File

@ -0,0 +1,43 @@
.dropdown-menu {
width: 370px;
@media(min-width: 768px) {
width: 600px;
}
}
::ng-deep .ng-select-container {
border-top-right-radius: 0 !important;
border-bottom-right-radius: 0 !important;
height: 100% !important;
}
::ng-deep .rounded-end .ng-select-container {
border-top-right-radius: var(--bs-border-radius) !important;
border-bottom-right-radius: var(--bs-border-radius) !important;
border-top-left-radius: 0 !important;
border-bottom-left-radius: 0 !important;
}
::ng-deep .ng-select {
max-width: 100px;
min-width: 35%;
font-size: 14px;
}
::ng-deep .doc-link-select {
padding-top: 0 !important;
border-top-right-radius: var(--bs-border-radius) !important;
border-bottom-right-radius: var(--bs-border-radius) !important;
background-image: none !important;
.ng-select-container,
.ng-select.ng-select-opened > .ng-select-container {
border: none !important;
min-height: 34px !important;
background: none !important;
}
.ng-select {
max-width: 200px;
min-width: 140px;
}
}

View File

@ -0,0 +1,320 @@
import { ComponentFixture, TestBed } from '@angular/core/testing'
import {
CustomFieldQueriesModel,
CustomFieldsQueryDropdownComponent,
} from './custom-fields-query-dropdown.component'
import { CustomFieldsService } from 'src/app/services/rest/custom-fields.service'
import { of } from 'rxjs'
import { CustomField, CustomFieldDataType } from 'src/app/data/custom-field'
import {
CUSTOM_FIELD_QUERY_OPERATORS_BY_GROUP,
CustomFieldQueryLogicalOperator,
CustomFieldQueryOperatorGroups,
} from 'src/app/data/custom-field-query'
import { provideHttpClientTesting } from '@angular/common/http/testing'
import { provideHttpClient, withInterceptorsFromDi } from '@angular/common/http'
import { NgbDropdownModule } from '@ng-bootstrap/ng-bootstrap'
import { NgxBootstrapIconsModule, allIcons } from 'ngx-bootstrap-icons'
import {
CustomFieldQueryExpression,
CustomFieldQueryAtom,
CustomFieldQueryElement,
} from 'src/app/utils/custom-field-query-element'
const customFields = [
{
id: 1,
name: 'Test Field',
data_type: CustomFieldDataType.String,
extra_data: {},
},
{
id: 2,
name: 'Test Select Field',
data_type: CustomFieldDataType.Select,
extra_data: { select_options: ['Option 1', 'Option 2'] },
},
]
describe('CustomFieldsQueryDropdownComponent', () => {
let component: CustomFieldsQueryDropdownComponent
let fixture: ComponentFixture<CustomFieldsQueryDropdownComponent>
let customFieldsService: CustomFieldsService
beforeEach(async () => {
await TestBed.configureTestingModule({
declarations: [CustomFieldsQueryDropdownComponent],
imports: [NgbDropdownModule, NgxBootstrapIconsModule.pick(allIcons)],
providers: [
provideHttpClient(withInterceptorsFromDi()),
provideHttpClientTesting(),
],
}).compileComponents()
customFieldsService = TestBed.inject(CustomFieldsService)
jest.spyOn(customFieldsService, 'listAll').mockReturnValue(
of({
count: customFields.length,
all: customFields.map((f) => f.id),
results: customFields,
})
)
fixture = TestBed.createComponent(CustomFieldsQueryDropdownComponent)
component = fixture.componentInstance
component.icon = 'ui-radios'
fixture.detectChanges()
})
it('should initialize custom fields on creation', () => {
expect(component.customFields).toEqual(customFields)
})
it('should add an expression when opened if queries are empty', () => {
component.selectionModel.clear()
component.onOpenChange(true)
expect(component.selectionModel.queries.length).toBe(1)
})
it('should support reset the selection model', () => {
component.selectionModel.addExpression()
component.reset()
expect(component.selectionModel.isEmpty()).toBeTruthy()
})
it('should get operators for a field', () => {
const field: CustomField = {
id: 1,
name: 'Test Field',
data_type: CustomFieldDataType.String,
extra_data: {},
}
component.customFields = [field]
const operators = component.getOperatorsForField(1)
expect(operators.length).toEqual(
[
...CUSTOM_FIELD_QUERY_OPERATORS_BY_GROUP[
CustomFieldQueryOperatorGroups.Basic
],
...CUSTOM_FIELD_QUERY_OPERATORS_BY_GROUP[
CustomFieldQueryOperatorGroups.String
],
].length
)
// Fallback to basic operators if field is not found
const operators2 = component.getOperatorsForField(2)
expect(operators2.length).toEqual(
CUSTOM_FIELD_QUERY_OPERATORS_BY_GROUP[
CustomFieldQueryOperatorGroups.Basic
].length
)
})
it('should get select options for a field', () => {
const field: CustomField = {
id: 1,
name: 'Test Field',
data_type: CustomFieldDataType.Select,
extra_data: { select_options: ['Option 1', 'Option 2'] },
}
component.customFields = [field]
const options = component.getSelectOptionsForField(1)
expect(options).toEqual(['Option 1', 'Option 2'])
// Fallback to empty array if field is not found
const options2 = component.getSelectOptionsForField(2)
expect(options2).toEqual([])
})
it('should remove an element from the selection model', () => {
const expression = new CustomFieldQueryExpression()
const atom = new CustomFieldQueryAtom()
;(expression.value as CustomFieldQueryElement[]).push(atom)
component.selectionModel.addExpression(expression)
component.removeElement(atom)
expect(component.selectionModel.isEmpty()).toBeTruthy()
const expression2 = new CustomFieldQueryExpression([
CustomFieldQueryLogicalOperator.And,
[
[1, 'icontains', 'test'],
[2, 'icontains', 'test'],
],
])
component.selectionModel.addExpression(expression2)
component.removeElement(expression2)
expect(component.selectionModel.isEmpty()).toBeTruthy()
})
it('should emit selectionModelChange when model changes', () => {
const nextSpy = jest.spyOn(component.selectionModelChange, 'next')
const atom = new CustomFieldQueryAtom([1, 'icontains', 'test'])
component.selectionModel.addAtom(atom)
atom.changed.next(atom)
expect(nextSpy).toHaveBeenCalled()
})
it('should complete selection model subscription when new selection model is set', () => {
const completeSpy = jest.spyOn(component.selectionModel.changed, 'complete')
const selectionModel = new CustomFieldQueriesModel()
component.selectionModel = selectionModel
expect(completeSpy).toHaveBeenCalled()
})
it('should support adding an atom', () => {
const expression = new CustomFieldQueryExpression()
component.addAtom(expression)
expect(expression.value.length).toBe(1)
})
it('should support adding an expression', () => {
const expression = new CustomFieldQueryExpression()
component.addExpression(expression)
expect(expression.value.length).toBe(1)
})
it('should support getting a custom field by ID', () => {
expect(component.getCustomFieldByID(1)).toEqual(customFields[0])
})
it('should sanitize name from title', () => {
component.title = 'Test Title'
expect(component.name).toBe('test_title')
})
describe('CustomFieldQueriesModel', () => {
let model: CustomFieldQueriesModel
beforeEach(() => {
model = new CustomFieldQueriesModel()
})
it('should initialize with empty queries', () => {
expect(model.queries).toEqual([])
})
it('should clear queries and fire event', () => {
const nextSpy = jest.spyOn(model.changed, 'next')
model.addExpression()
model.clear()
expect(model.queries).toEqual([])
expect(nextSpy).toHaveBeenCalledWith(model)
})
it('should clear queries without firing event', () => {
const nextSpy = jest.spyOn(model.changed, 'next')
model.addExpression()
model.clear(false)
expect(model.queries).toEqual([])
expect(nextSpy).not.toHaveBeenCalled()
})
it('should validate an empty model as invalid', () => {
expect(model.isValid()).toBeFalsy()
})
it('should validate a model with valid expression as valid', () => {
const expression = new CustomFieldQueryExpression()
const atom = new CustomFieldQueryAtom([1, 'icontains', 'test'])
const atom2 = new CustomFieldQueryAtom([2, 'icontains', 'test'])
const expression2 = new CustomFieldQueryExpression()
expression2.addAtom(atom)
expression2.addAtom(atom2)
expression.addExpression(expression2)
model.addExpression(expression)
expect(model.isValid()).toBeTruthy()
})
it('should validate a model with invalid expression as invalid', () => {
const expression = new CustomFieldQueryExpression()
model.addExpression(expression)
expect(model.isValid()).toBeFalsy()
})
it('should validate an atom with in or contains operator', () => {
const atom = new CustomFieldQueryAtom([1, 'in', '[1,2,3]'])
expect(model['validateAtom'].apply(null, [atom])).toBeTruthy()
atom.operator = 'contains'
atom.value = [1, 2, 3]
expect(model['validateAtom'].apply(null, [atom])).toBeTruthy()
atom.value = null
expect(model['validateAtom'].apply(null, [atom])).toBeFalsy()
})
it('should check if model is empty', () => {
expect(model.isEmpty()).toBeTruthy()
model.addExpression()
expect(model.isEmpty()).toBeTruthy()
const atom = new CustomFieldQueryAtom([1, 'icontains', 'test'])
model.addAtom(atom)
expect(model.isEmpty()).toBeFalsy()
})
it('should add an atom to the model', () => {
const atom = new CustomFieldQueryAtom([1, 'icontains', 'test'])
model.addAtom(atom)
expect(model.queries.length).toBe(1)
expect(
(model.queries[0] as CustomFieldQueryExpression).value.length
).toBe(1)
})
it('should add an expression to the model, propagate changes', () => {
const expression = new CustomFieldQueryExpression()
model.addExpression(expression)
expect(model.queries.length).toBe(1)
const expression2 = new CustomFieldQueryExpression([
CustomFieldQueryLogicalOperator.And,
[
[1, 'icontains', 'test'],
[2, 'icontains', 'test'],
],
])
model.addExpression(expression2)
const nextSpy = jest.spyOn(model.changed, 'next')
expression2.changed.next(expression2)
expect(nextSpy).toHaveBeenCalled()
})
it('should remove an element from the model', () => {
const expression = new CustomFieldQueryExpression([
CustomFieldQueryLogicalOperator.And,
[
[1, 'icontains', 'test'],
[2, 'icontains', 'test'],
],
])
const atom = new CustomFieldQueryAtom([1, 'icontains', 'test'])
const expression2 = new CustomFieldQueryExpression([
CustomFieldQueryLogicalOperator.And,
[
[3, 'icontains', 'test'],
[4, 'icontains', 'test'],
],
])
expression.addAtom(atom)
expression2.addExpression(expression)
model.addExpression(expression2)
model.removeElement(atom)
expect(model.queries.length).toBe(1)
model.removeElement(expression2)
})
it('should fire changed event when an atom changes', () => {
const nextSpy = jest.spyOn(model.changed, 'next')
const atom = new CustomFieldQueryAtom([1, 'icontains', 'test'])
model.addAtom(atom)
atom.changed.next(atom)
expect(nextSpy).toHaveBeenCalledWith(model)
})
it('should complete changed subject when element is removed', () => {
const expression = new CustomFieldQueryExpression()
const atom = new CustomFieldQueryAtom([1, 'icontains', 'test'])
;(expression.value as CustomFieldQueryElement[]).push(atom)
model.addExpression(expression)
const completeSpy = jest.spyOn(atom.changed, 'complete')
model.removeElement(atom)
expect(completeSpy).toHaveBeenCalled()
})
})
})

View File

@ -0,0 +1,294 @@
import {
Component,
EventEmitter,
Input,
Output,
ViewChild,
} from '@angular/core'
import { NgbDropdown } from '@ng-bootstrap/ng-bootstrap'
import { Subject, first, takeUntil } from 'rxjs'
import { CustomField, CustomFieldDataType } from 'src/app/data/custom-field'
import {
CustomFieldQueryElementType,
CustomFieldQueryOperator,
CUSTOM_FIELD_QUERY_OPERATOR_GROUPS_BY_TYPE,
CUSTOM_FIELD_QUERY_OPERATORS_BY_GROUP,
CustomFieldQueryOperatorGroups,
CUSTOM_FIELD_QUERY_OPERATOR_LABELS,
CUSTOM_FIELD_QUERY_MAX_DEPTH,
CUSTOM_FIELD_QUERY_MAX_ATOMS,
} from 'src/app/data/custom-field-query'
import { CustomFieldsService } from 'src/app/services/rest/custom-fields.service'
import {
CustomFieldQueryElement,
CustomFieldQueryExpression,
CustomFieldQueryAtom,
} from 'src/app/utils/custom-field-query-element'
import { popperOptionsReenablePreventOverflow } from 'src/app/utils/popper-options'
export class CustomFieldQueriesModel {
public queries: CustomFieldQueryElement[] = []
public readonly changed = new Subject<CustomFieldQueriesModel>()
public clear(fireEvent = true) {
this.queries = []
if (fireEvent) {
this.changed.next(this)
}
}
public isValid(): boolean {
return (
this.queries.length > 0 &&
this.validateExpression(this.queries[0] as CustomFieldQueryExpression)
)
}
public isEmpty(): boolean {
return (
this.queries.length === 0 ||
(this.queries.length === 1 && this.queries[0].value.length === 0)
)
}
private validateAtom(atom: CustomFieldQueryAtom) {
let valid = !!(atom.field && atom.operator && atom.value !== null)
if (
[
CustomFieldQueryOperator.In.valueOf(),
CustomFieldQueryOperator.Contains.valueOf(),
].includes(atom.operator) &&
atom.value
) {
valid = valid && atom.value.length > 0
}
return valid
}
private validateExpression(expression: CustomFieldQueryExpression) {
return (
expression.operator &&
expression.value.length > 0 &&
(expression.value as CustomFieldQueryElement[]).every((e) =>
e.type === CustomFieldQueryElementType.Atom
? this.validateAtom(e as CustomFieldQueryAtom)
: this.validateExpression(e as CustomFieldQueryExpression)
)
)
}
public addAtom(atom: CustomFieldQueryAtom) {
if (this.queries.length === 0) {
this.addExpression()
}
;(this.queries[0].value as CustomFieldQueryElement[]).push(atom)
atom.changed.subscribe(() => {
if (atom.field && atom.operator && atom.value) {
this.changed.next(this)
}
})
}
public addExpression(
expression: CustomFieldQueryExpression = new CustomFieldQueryExpression()
) {
if (this.queries.length > 0) {
;(
(this.queries[0] as CustomFieldQueryExpression)
.value as CustomFieldQueryElement[]
).push(expression)
} else {
this.queries.push(expression)
}
expression.changed.subscribe(() => {
this.changed.next(this)
})
}
private findElement(
queryElement: CustomFieldQueryElement,
elements: any[]
): CustomFieldQueryElement {
for (let i = 0; i < elements.length; i++) {
if (elements[i] === queryElement) {
return elements.splice(i, 1)[0]
} else if (elements[i].type === CustomFieldQueryElementType.Expression) {
return this.findElement(
queryElement,
elements[i].value as CustomFieldQueryElement[]
)
}
}
}
public removeElement(queryElement: CustomFieldQueryElement) {
let foundComponent
for (let i = 0; i < this.queries.length; i++) {
let query = this.queries[i]
if (query === queryElement) {
foundComponent = this.queries.splice(i, 1)[0]
break
} else if (query.type === CustomFieldQueryElementType.Expression) {
foundComponent = this.findElement(queryElement, query.value as any[])
}
}
if (foundComponent) {
foundComponent.changed.complete()
if (this.isEmpty()) {
this.clear()
}
this.changed.next(this)
}
}
}
@Component({
selector: 'pngx-custom-fields-query-dropdown',
templateUrl: './custom-fields-query-dropdown.component.html',
styleUrls: ['./custom-fields-query-dropdown.component.scss'],
})
export class CustomFieldsQueryDropdownComponent {
public CustomFieldQueryComponentType = CustomFieldQueryElementType
public CustomFieldQueryOperator = CustomFieldQueryOperator
public CustomFieldDataType = CustomFieldDataType
public CUSTOM_FIELD_QUERY_MAX_DEPTH = CUSTOM_FIELD_QUERY_MAX_DEPTH
public CUSTOM_FIELD_QUERY_MAX_ATOMS = CUSTOM_FIELD_QUERY_MAX_ATOMS
public popperOptions = popperOptionsReenablePreventOverflow
@Input()
title: string
@Input()
filterPlaceholder: string = ''
@Input()
icon: string
@Input()
allowSelectNone: boolean = false
@Input()
editing = false
@Input()
applyOnClose = false
get name(): string {
return this.title ? this.title.replace(/\s/g, '_').toLowerCase() : null
}
@Input()
disabled: boolean = false
@ViewChild('dropdown') dropdown: NgbDropdown
private _selectionModel: CustomFieldQueriesModel
@Input()
set selectionModel(model: CustomFieldQueriesModel) {
if (this._selectionModel) {
this._selectionModel.changed.complete()
}
model.changed.subscribe(() => {
this.onModelChange()
})
this._selectionModel = model
}
get selectionModel(): CustomFieldQueriesModel {
return this._selectionModel
}
private onModelChange() {
if (this.selectionModel.isEmpty() || this.selectionModel.isValid()) {
this.selectionModelChange.next(this.selectionModel)
this.selectionModel.isEmpty() && this.dropdown?.close()
}
}
@Output()
selectionModelChange = new EventEmitter<CustomFieldQueriesModel>()
customFields: CustomField[] = []
private unsubscribeNotifier: Subject<any> = new Subject()
constructor(protected customFieldsService: CustomFieldsService) {
this.selectionModel = new CustomFieldQueriesModel()
this.getFields()
this.reset()
}
ngOnDestroy(): void {
this.unsubscribeNotifier.next(this)
this.unsubscribeNotifier.complete()
}
public onOpenChange(open: boolean) {
if (open && this.selectionModel.queries.length === 0) {
this.selectionModel.addExpression()
}
}
public get isActive(): boolean {
return (
(this.selectionModel.queries[0] as CustomFieldQueryExpression)?.value
?.length > 0
)
}
private getFields() {
this.customFieldsService
.listAll()
.pipe(first(), takeUntil(this.unsubscribeNotifier))
.subscribe((result) => {
this.customFields = result.results
})
}
public getCustomFieldByID(id: number): CustomField {
return this.customFields.find((field) => field.id === id)
}
public addAtom(expression: CustomFieldQueryExpression) {
expression.addAtom()
}
public addExpression(expression: CustomFieldQueryExpression) {
expression.addExpression()
}
public removeElement(element: CustomFieldQueryElement) {
this.selectionModel.removeElement(element)
}
public reset() {
this.selectionModel.clear(false)
this.selectionModel.changed.next(this.selectionModel)
}
getOperatorsForField(
fieldID: number
): Array<{ value: string; label: string }> {
const field = this.customFields.find((field) => field.id === fieldID)
const groups: CustomFieldQueryOperatorGroups[] = field
? CUSTOM_FIELD_QUERY_OPERATOR_GROUPS_BY_TYPE[field.data_type]
: [CustomFieldQueryOperatorGroups.Basic]
const operators = groups.flatMap(
(group) => CUSTOM_FIELD_QUERY_OPERATORS_BY_GROUP[group]
)
return operators.map((operator) => ({
value: operator,
label: CUSTOM_FIELD_QUERY_OPERATOR_LABELS[operator],
}))
}
getSelectOptionsForField(fieldID: number): string[] {
const field = this.customFields.find((field) => field.id === fieldID)
if (field) {
return field.extra_data['select_options']
}
return []
}
}

View File

@ -1,50 +1,57 @@
<div class="mb-3 paperless-input-select" [class.disabled]="disabled">
<div class="row">
<div class="d-flex align-items-center position-relative hidden-button-container" [class.col-md-3]="horizontal">
@if (title) {
<label class="form-label" [class.mb-md-0]="horizontal" [for]="inputId">{{title}}</label>
}
@if (removable) {
<button type="button" class="btn btn-sm btn-danger position-absolute left-0" (click)="removed.emit(this)">
<i-bs name="x"></i-bs>&nbsp;<ng-container i18n>Remove</ng-container>
</button>
}
</div>
<div [class.col-md-9]="horizontal">
<div>
<ng-select name="inputId" [(ngModel)]="selectedDocuments"
[disabled]="disabled"
[items]="foundDocuments$ | async"
placeholder="Search for documents"
[notFoundText]="notFoundText"
[multiple]="true"
bindValue="id"
[compareWith]="compareDocuments"
[trackByFn]="trackByFn"
[minTermLength]="2"
[loading]="loading"
[typeahead]="documentsInput$"
(change)="onChange(selectedDocuments)">
<ng-template ng-label-tmp let-document="item">
<div class="d-flex align-items-center">
<button class="btn p-0 lh-1" (click)="unselect(document)" title="Remove link" i18n-title><i-bs name="x"></i-bs></button>
<a routerLink="/documents/{{document.id}}" class="badge bg-light text-primary" (mousedown)="$event.stopImmediatePropagation();" title="Open link" i18n-title>
<i-bs width="0.9em" height="0.9em" name="file-text"></i-bs>&nbsp;<span>{{document.title}}</span>
</a>
</div>
</ng-template>
<ng-template ng-loadingspinner-tmp>
<div class="spinner-border spinner-border-sm fw-normal ms-2 me-auto" role="status"></div>
<div class="visually-hidden" i18n>Loading...</div>
</ng-template>
<ng-template ng-option-tmp let-document="item" let-index="index" let-search="searchTerm">
<div>{{document.title}} <small class="text-muted">({{document.created | customDate:'shortDate'}})</small></div>
</ng-template>
</ng-select>
@if (minimal) {
<ng-container *ngTemplateOutlet="select"></ng-container>
} @else {
<div class="mb-3 paperless-input-select" [class.disabled]="disabled">
<div class="row">
<div class="d-flex align-items-center position-relative hidden-button-container" [class.col-md-3]="horizontal">
@if (title) {
<label class="form-label" [class.mb-md-0]="horizontal" [for]="inputId">{{title}}</label>
}
@if (removable) {
<button type="button" class="btn btn-sm btn-danger position-absolute left-0" (click)="removed.emit(this)">
<i-bs name="x"></i-bs>&nbsp;<ng-container i18n>Remove</ng-container>
</button>
}
</div>
<div [class.col-md-9]="horizontal">
<ng-container *ngTemplateOutlet="select"></ng-container>
@if (hint) {
<small class="form-text text-muted">{{hint}}</small>
}
</div>
@if (hint) {
<small class="form-text text-muted">{{hint}}</small>
}
</div>
</div>
</div>
}
<ng-template #select>
<ng-select name="inputId" [(ngModel)]="selectedDocuments"
[disabled]="disabled"
[items]="foundDocuments$ | async"
[placeholder]="placeholder"
[notFoundText]="notFoundText"
[multiple]="true"
bindValue="id"
[compareWith]="compareDocuments"
[trackByFn]="trackByFn"
[minTermLength]="2"
[loading]="loading"
[typeahead]="documentsInput$"
(mousedown)="$event.stopImmediatePropagation()"
(change)="onChange(selectedDocuments)">
<ng-template ng-label-tmp let-document="item">
<div class="d-flex align-items-center">
<button class="btn p-0 lh-1" (click)="unselect(document)" title="Remove link" i18n-title><i-bs name="x"></i-bs></button>
<a routerLink="/documents/{{document.id}}" class="badge bg-light text-primary" (mousedown)="$event.stopImmediatePropagation();" title="Open link" i18n-title>
<i-bs width="0.9em" height="0.9em" name="file-text"></i-bs>&nbsp;<span>{{document.title}}</span>
</a>
</div>
</ng-template>
<ng-template ng-loadingspinner-tmp>
<div class="spinner-border spinner-border-sm fw-normal ms-2 me-auto" role="status"></div>
<div class="visually-hidden" i18n>Loading...</div>
</ng-template>
<ng-template ng-option-tmp let-document="item" let-index="index" let-search="searchTerm">
<div>{{document.title}} <small class="text-muted">({{document.created | customDate:'shortDate'}})</small></div>
</ng-template>
</ng-select>
</ng-template>

View File

@ -46,6 +46,12 @@ export class DocumentLinkComponent
@Input()
parentDocumentID: number
@Input()
minimal: boolean = false
@Input()
placeholder: string = $localize`Search for documents`
constructor(private documentsService: DocumentService) {
super()
}

View File

@ -140,7 +140,7 @@
} @else {
@if (list.displayMode === DisplayMode.LARGE_CARDS) {
<div>
@for (d of list.documents; track trackByDocumentId($index, d)) {
@for (d of list.documents; track d.id) {
<pngx-document-card-large
[selected]="list.isSelected(d)"
(toggleSelected)="toggleSelected(d, $event)"
@ -269,7 +269,7 @@
</tr>
</thead>
<tbody>
@for (d of list.documents; track trackByDocumentId($index, d)) {
@for (d of list.documents; track d.id) {
<tr (click)="toggleSelected(d, $event); $event.stopPropagation();" (dblclick)="openDocumentDetail(d)" [ngClass]="list.isSelected(d) ? 'table-row-selected' : ''">
<td>
<div class="form-check">
@ -364,7 +364,7 @@
}
@if (list.displayMode === DisplayMode.SMALL_CARDS) {
<div class="row row-cols-paperless-cards">
@for (d of list.documents; track trackByDocumentId($index, d)) {
@for (d of list.documents; track d.id) {
<pngx-document-card-small class="p-0"
[selected]="list.isSelected(d)"
(toggleSelected)="toggleSelected(d, $event)"

View File

@ -383,10 +383,6 @@ export class DocumentListComponent
])
}
trackByDocumentId(index, item: Document) {
return item.id
}
get notesEnabled(): boolean {
return this.settingsService.get(SETTINGS_KEYS.NOTES_ENABLED)
}

View File

@ -86,15 +86,10 @@
}
@if (permissionsService.currentUserCan(PermissionAction.View, PermissionType.CustomField) && customFields.length > 0) {
<pngx-filterable-dropdown class="flex-fill" title="Custom fields" icon="ui-radios" i18n-title
filterPlaceholder="Filter custom fields" i18n-filterPlaceholder
[items]="customFields"
[manyToOne]="true"
[(selectionModel)]="customFieldSelectionModel"
<pngx-custom-fields-query-dropdown class="flex-fill" title="Custom fields" icon="ui-radios" i18n-title
[(selectionModel)]="customFieldQueriesModel"
(selectionModelChange)="updateRules()"
(opened)="onCustomFieldsDropdownOpen()"
[documentCounts]="customFieldDocumentCounts"
[allowSelectNone]="true"></pngx-filterable-dropdown>
></pngx-custom-fields-query-dropdown>
}
<pngx-dates-dropdown
title="Dates" i18n-title

View File

@ -17,7 +17,7 @@ import {
NgbDropdownItem,
NgbTypeaheadModule,
} from '@ng-bootstrap/ng-bootstrap'
import { NgSelectComponent } from '@ng-select/ng-select'
import { NgSelectComponent, NgSelectModule } from '@ng-select/ng-select'
import { of, throwError } from 'rxjs'
import {
FILTER_TITLE,
@ -55,6 +55,7 @@ import {
FILTER_HAS_ANY_CUSTOM_FIELDS,
FILTER_DOES_NOT_HAVE_CUSTOM_FIELDS,
FILTER_HAS_CUSTOM_FIELDS_ALL,
FILTER_CUSTOM_FIELDS_QUERY,
} from 'src/app/data/filter-rule-type'
import { Correspondent } from 'src/app/data/correspondent'
import { DocumentType } from 'src/app/data/document-type'
@ -95,6 +96,12 @@ import { CustomFieldsService } from 'src/app/services/rest/custom-fields.service
import { RouterModule } from '@angular/router'
import { SearchService } from 'src/app/services/rest/search.service'
import { provideHttpClient, withInterceptorsFromDi } from '@angular/common/http'
import { CustomFieldsQueryDropdownComponent } from '../../common/custom-fields-query-dropdown/custom-fields-query-dropdown.component'
import {
CustomFieldQueryLogicalOperator,
CustomFieldQueryOperator,
} from 'src/app/data/custom-field-query'
import { CustomFieldQueryAtom } from 'src/app/utils/custom-field-query-element'
const tags: Tag[] = [
{
@ -181,6 +188,7 @@ describe('FilterEditorComponent', () => {
ToggleableDropdownButtonComponent,
DatesDropdownComponent,
CustomDatePipe,
CustomFieldsQueryDropdownComponent,
],
imports: [
RouterModule,
@ -190,6 +198,7 @@ describe('FilterEditorComponent', () => {
NgbDatepickerModule,
NgxBootstrapIconsModule.pick(allIcons),
NgbTypeaheadModule,
NgSelectModule,
],
providers: [
FilterPipe,
@ -838,108 +847,79 @@ describe('FilterEditorComponent', () => {
]
}))
it('should ingest filter rules for has all custom fields', fakeAsync(() => {
expect(component.customFieldSelectionModel.getSelectedItems()).toHaveLength(
0
)
it('should ingest filter rules for custom fields all', fakeAsync(() => {
expect(component.customFieldQueriesModel.isEmpty()).toBeTruthy()
component.filterRules = [
{
rule_type: FILTER_HAS_CUSTOM_FIELDS_ALL,
value: '42',
},
{
rule_type: FILTER_HAS_CUSTOM_FIELDS_ALL,
value: '43',
value: '42,43',
},
]
expect(component.customFieldSelectionModel.logicalOperator).toEqual(
LogicalOperator.And
expect(component.customFieldQueriesModel.queries[0].operator).toEqual(
CustomFieldQueryLogicalOperator.And
)
expect(component.customFieldSelectionModel.getSelectedItems()).toEqual(
custom_fields
)
// coverage
component.filterRules = [
{
rule_type: FILTER_HAS_CUSTOM_FIELDS_ALL,
value: null,
},
]
component.toggleTag(2) // coverage
expect(component.customFieldQueriesModel.queries[0].value.length).toEqual(2)
expect(
(
component.customFieldQueriesModel.queries[0]
.value[0] as CustomFieldQueryAtom
).serialize()
).toEqual(['42', CustomFieldQueryOperator.Exists, 'true'])
}))
it('should ingest filter rules for has any custom fields', fakeAsync(() => {
expect(component.customFieldSelectionModel.getSelectedItems()).toHaveLength(
0
)
expect(component.customFieldQueriesModel.isEmpty()).toBeTruthy()
component.filterRules = [
{
rule_type: FILTER_HAS_CUSTOM_FIELDS_ANY,
value: '42',
},
{
rule_type: FILTER_HAS_CUSTOM_FIELDS_ANY,
value: '43',
value: '42,43',
},
]
expect(component.customFieldSelectionModel.logicalOperator).toEqual(
LogicalOperator.Or
expect(component.customFieldQueriesModel.queries[0].operator).toEqual(
CustomFieldQueryLogicalOperator.Or
)
expect(component.customFieldSelectionModel.getSelectedItems()).toEqual(
custom_fields
)
// coverage
component.filterRules = [
{
rule_type: FILTER_HAS_CUSTOM_FIELDS_ANY,
value: null,
},
]
expect(component.customFieldQueriesModel.queries[0].value.length).toEqual(2)
expect(
(
component.customFieldQueriesModel.queries[0]
.value[0] as CustomFieldQueryAtom
).serialize()
).toEqual(['42', CustomFieldQueryOperator.Exists, 'true'])
}))
it('should ingest filter rules for has any custom field', fakeAsync(() => {
expect(component.customFieldSelectionModel.getSelectedItems()).toHaveLength(
0
)
it('should ingest filter rules for custom field queries', fakeAsync(() => {
expect(component.customFieldQueriesModel.isEmpty()).toBeTruthy()
component.filterRules = [
{
rule_type: FILTER_HAS_ANY_CUSTOM_FIELDS,
value: '1',
rule_type: FILTER_CUSTOM_FIELDS_QUERY,
value: '["AND", [[42, "exists", "true"],[43, "exists", "true"]]]',
},
]
expect(component.customFieldSelectionModel.getSelectedItems()).toHaveLength(
1
expect(component.customFieldQueriesModel.queries[0].operator).toEqual(
CustomFieldQueryLogicalOperator.And
)
expect(component.customFieldSelectionModel.get(null)).toBeTruthy()
}))
expect(component.customFieldQueriesModel.queries[0].value.length).toEqual(2)
expect(
(
component.customFieldQueriesModel.queries[0]
.value[0] as CustomFieldQueryAtom
).serialize()
).toEqual([42, CustomFieldQueryOperator.Exists, 'true'])
it('should ingest filter rules for exclude tag(s)', fakeAsync(() => {
expect(component.customFieldSelectionModel.getExcludedItems()).toHaveLength(
0
)
// atom
component.filterRules = [
{
rule_type: FILTER_DOES_NOT_HAVE_CUSTOM_FIELDS,
value: '42',
},
{
rule_type: FILTER_DOES_NOT_HAVE_CUSTOM_FIELDS,
value: '43',
},
]
expect(component.customFieldSelectionModel.logicalOperator).toEqual(
LogicalOperator.And
)
expect(component.customFieldSelectionModel.getExcludedItems()).toEqual(
custom_fields
)
// coverage
component.filterRules = [
{
rule_type: FILTER_DOES_NOT_HAVE_CUSTOM_FIELDS,
value: null,
rule_type: FILTER_CUSTOM_FIELDS_QUERY,
value: '[42, "exists", "true"]',
},
]
expect(component.customFieldQueriesModel.queries[0].value.length).toEqual(1)
expect(
(
component.customFieldQueriesModel.queries[0]
.value[0] as CustomFieldQueryAtom
).serialize()
).toEqual([42, CustomFieldQueryOperator.Exists, 'true'])
}))
it('should ingest filter rules for owner', fakeAsync(() => {
@ -1453,71 +1433,37 @@ describe('FilterEditorComponent', () => {
])
}))
it('should convert user input to correct filter rules on custom field select not assigned', fakeAsync(() => {
const customFieldsFilterableDropdown = fixture.debugElement.queryAll(
By.directive(FilterableDropdownComponent)
)[4]
customFieldsFilterableDropdown.triggerEventHandler('opened')
const customFieldButton = customFieldsFilterableDropdown.queryAll(
By.directive(ToggleableDropdownButtonComponent)
)[0]
customFieldButton.triggerEventHandler('toggle')
fixture.detectChanges()
expect(component.filterRules).toEqual([
{
rule_type: FILTER_HAS_ANY_CUSTOM_FIELDS,
value: 'false',
},
])
}))
it('should convert user input to correct filter rules on custom field selections', fakeAsync(() => {
const customFieldsFilterableDropdown = fixture.debugElement.queryAll(
By.directive(FilterableDropdownComponent)
)[4] // CF dropdown
customFieldsFilterableDropdown.triggerEventHandler('opened')
const customFieldButtons = customFieldsFilterableDropdown.queryAll(
By.directive(ToggleableDropdownButtonComponent)
const customFieldsQueryDropdown = fixture.debugElement.queryAll(
By.directive(CustomFieldsQueryDropdownComponent)
)[0]
const customFieldToggleButton = customFieldsQueryDropdown.query(
By.css('button')
)
customFieldButtons[1].triggerEventHandler('toggle')
customFieldButtons[2].triggerEventHandler('toggle')
customFieldToggleButton.triggerEventHandler('click')
fixture.detectChanges()
expect(component.filterRules).toEqual([
{
rule_type: FILTER_HAS_CUSTOM_FIELDS_ALL,
value: custom_fields[0].id.toString(),
},
{
rule_type: FILTER_HAS_CUSTOM_FIELDS_ALL,
value: custom_fields[1].id.toString(),
},
])
const toggleOperatorButtons = customFieldsFilterableDropdown.queryAll(
By.css('input[type=radio]')
const customFieldButtons = customFieldsQueryDropdown.queryAll(
By.css('button')
)
toggleOperatorButtons[1].nativeElement.checked = true
toggleOperatorButtons[1].triggerEventHandler('change')
customFieldButtons[1].triggerEventHandler('click')
fixture.detectChanges()
const query = component.customFieldQueriesModel
.queries[0] as CustomFieldQueryAtom
query.field = custom_fields[0].id
const fieldSelect: NgSelectComponent = customFieldsQueryDropdown.queryAll(
By.directive(NgSelectComponent)
)[0].componentInstance
fieldSelect.open()
const options = customFieldsQueryDropdown.queryAll(By.css('.ng-option'))
options[0].nativeElement.click()
expect(component.customFieldQueriesModel.queries[0].value.length).toEqual(1)
expect(component.filterRules).toEqual([
{
rule_type: FILTER_HAS_CUSTOM_FIELDS_ANY,
value: custom_fields[0].id.toString(),
},
{
rule_type: FILTER_HAS_CUSTOM_FIELDS_ANY,
value: custom_fields[1].id.toString(),
},
])
customFieldButtons[2].triggerEventHandler('exclude')
fixture.detectChanges()
expect(component.filterRules).toEqual([
{
rule_type: FILTER_HAS_CUSTOM_FIELDS_ALL,
value: custom_fields[0].id.toString(),
},
{
rule_type: FILTER_DOES_NOT_HAVE_CUSTOM_FIELDS,
value: custom_fields[1].id.toString(),
rule_type: FILTER_CUSTOM_FIELDS_QUERY,
value: JSON.stringify([
CustomFieldQueryLogicalOperator.Or,
[[custom_fields[0].id, 'exists', 'true']],
]),
},
])
}))
@ -1930,21 +1876,11 @@ describe('FilterEditorComponent', () => {
component.filterRules = [
{
rule_type: FILTER_HAS_CUSTOM_FIELDS_ALL,
value: '42',
rule_type: FILTER_CUSTOM_FIELDS_QUERY,
value: '["AND",[["42","exists","true"],["43","exists","true"]]]',
},
]
expect(component.generateFilterName()).toEqual(
`Custom fields: ${custom_fields[0].name}`
)
component.filterRules = [
{
rule_type: FILTER_HAS_ANY_CUSTOM_FIELDS,
value: 'false',
},
]
expect(component.generateFilterName()).toEqual('Without any custom field')
expect(component.generateFilterName()).toEqual(`Custom fields query`)
component.filterRules = [
{

View File

@ -12,7 +12,7 @@ import {
import { Tag } from 'src/app/data/tag'
import { Correspondent } from 'src/app/data/correspondent'
import { DocumentType } from 'src/app/data/document-type'
import { Observable, Subject, Subscription, from } from 'rxjs'
import { Observable, Subject, from } from 'rxjs'
import {
catchError,
debounceTime,
@ -62,7 +62,7 @@ import {
FILTER_HAS_CUSTOM_FIELDS_ANY,
FILTER_HAS_CUSTOM_FIELDS_ALL,
FILTER_HAS_ANY_CUSTOM_FIELDS,
FILTER_DOES_NOT_HAVE_CUSTOM_FIELDS,
FILTER_CUSTOM_FIELDS_QUERY,
} from 'src/app/data/filter-rule-type'
import {
FilterableDropdownSelectionModel,
@ -92,6 +92,15 @@ import { ComponentWithPermissions } from '../../with-permissions/with-permission
import { CustomFieldsService } from 'src/app/services/rest/custom-fields.service'
import { CustomField } from 'src/app/data/custom-field'
import { SearchService } from 'src/app/services/rest/search.service'
import {
CustomFieldQueryLogicalOperator,
CustomFieldQueryOperator,
} from 'src/app/data/custom-field-query'
import { CustomFieldQueriesModel } from '../../common/custom-fields-query-dropdown/custom-fields-query-dropdown.component'
import {
CustomFieldQueryExpression,
CustomFieldQueryAtom,
} from 'src/app/utils/custom-field-query-element'
const TEXT_FILTER_TARGET_TITLE = 'title'
const TEXT_FILTER_TARGET_TITLE_CONTENT = 'title-content'
@ -225,15 +234,8 @@ export class FilterEditorComponent
return $localize`Without any tag`
}
case FILTER_HAS_CUSTOM_FIELDS_ALL:
return $localize`Custom fields: ${
this.customFields.find((f) => f.id == +rule.value)?.name
}`
case FILTER_HAS_ANY_CUSTOM_FIELDS:
if (rule.value == 'false') {
return $localize`Without any custom field`
}
case FILTER_CUSTOM_FIELDS_QUERY:
return $localize`Custom fields query`
case FILTER_TITLE:
return $localize`Title: ${rule.value}`
@ -321,7 +323,7 @@ export class FilterEditorComponent
correspondentSelectionModel = new FilterableDropdownSelectionModel()
documentTypeSelectionModel = new FilterableDropdownSelectionModel()
storagePathSelectionModel = new FilterableDropdownSelectionModel()
customFieldSelectionModel = new FilterableDropdownSelectionModel()
customFieldQueriesModel = new CustomFieldQueriesModel()
dateCreatedBefore: string
dateCreatedAfter: string
@ -356,7 +358,7 @@ export class FilterEditorComponent
this.storagePathSelectionModel.clear(false)
this.tagSelectionModel.clear(false)
this.correspondentSelectionModel.clear(false)
this.customFieldSelectionModel.clear(false)
this.customFieldQueriesModel.clear(false)
this._textFilter = null
this._moreLikeId = null
this.dateAddedBefore = null
@ -523,34 +525,45 @@ export class FilterEditorComponent
false
)
break
case FILTER_CUSTOM_FIELDS_QUERY:
try {
const query = JSON.parse(rule.value)
if (Array.isArray(query)) {
if (query.length === 2) {
// expression
this.customFieldQueriesModel.addExpression(
new CustomFieldQueryExpression(query as any)
)
} else if (query.length === 3) {
// atom
this.customFieldQueriesModel.addAtom(
new CustomFieldQueryAtom(query as any)
)
}
}
} catch (e) {
// error handled by list view service
}
break
// Legacy custom field filters
case FILTER_HAS_CUSTOM_FIELDS_ALL:
this.customFieldSelectionModel.logicalOperator = LogicalOperator.And
this.customFieldSelectionModel.set(
rule.value ? +rule.value : null,
ToggleableItemState.Selected,
false
this.customFieldQueriesModel.addExpression(
new CustomFieldQueryExpression([
CustomFieldQueryLogicalOperator.And,
rule.value
.split(',')
.map((id) => [id, CustomFieldQueryOperator.Exists, 'true']),
])
)
break
case FILTER_HAS_CUSTOM_FIELDS_ANY:
this.customFieldSelectionModel.logicalOperator = LogicalOperator.Or
this.customFieldSelectionModel.set(
rule.value ? +rule.value : null,
ToggleableItemState.Selected,
false
)
break
case FILTER_HAS_ANY_CUSTOM_FIELDS:
this.customFieldSelectionModel.set(
null,
ToggleableItemState.Selected,
false
)
break
case FILTER_DOES_NOT_HAVE_CUSTOM_FIELDS:
this.customFieldSelectionModel.set(
rule.value ? +rule.value : null,
ToggleableItemState.Excluded,
false
this.customFieldQueriesModel.addExpression(
new CustomFieldQueryExpression([
CustomFieldQueryLogicalOperator.Or,
rule.value
.split(',')
.map((id) => [id, CustomFieldQueryOperator.Exists, 'true']),
])
)
break
case FILTER_ASN_ISNULL:
@ -768,34 +781,14 @@ export class FilterEditorComponent
})
})
}
if (this.customFieldSelectionModel.isNoneSelected()) {
let queries = this.customFieldQueriesModel.queries.map((query) =>
query.serialize()
)
if (queries.length > 0) {
filterRules.push({
rule_type: FILTER_HAS_ANY_CUSTOM_FIELDS,
value: 'false',
rule_type: FILTER_CUSTOM_FIELDS_QUERY,
value: JSON.stringify(queries[0]),
})
} else {
const customFieldFilterType =
this.customFieldSelectionModel.logicalOperator == LogicalOperator.And
? FILTER_HAS_CUSTOM_FIELDS_ALL
: FILTER_HAS_CUSTOM_FIELDS_ANY
this.customFieldSelectionModel
.getSelectedItems()
.filter((field) => field.id)
.forEach((field) => {
filterRules.push({
rule_type: customFieldFilterType,
value: field.id?.toString(),
})
})
this.customFieldSelectionModel
.getExcludedItems()
.filter((field) => field.id)
.forEach((field) => {
filterRules.push({
rule_type: FILTER_DOES_NOT_HAVE_CUSTOM_FIELDS,
value: field.id?.toString(),
})
})
}
if (this.dateCreatedBefore) {
filterRules.push({
@ -1079,10 +1072,6 @@ export class FilterEditorComponent
this.storagePathSelectionModel.apply()
}
onCustomFieldsDropdownOpen() {
this.customFieldSelectionModel.apply()
}
updateTextFilter(text, updateRules = true) {
this._textFilter = text
if (updateRules) {

View File

@ -0,0 +1,127 @@
import { CustomFieldDataType } from './custom-field'
export enum CustomFieldQueryLogicalOperator {
And = 'AND',
Or = 'OR',
Not = 'NOT',
}
export enum CustomFieldQueryOperator {
Exact = 'exact',
In = 'in',
IsNull = 'isnull',
Exists = 'exists',
Contains = 'contains',
IContains = 'icontains',
GreaterThan = 'gt',
GreaterThanOrEqual = 'gte',
LessThan = 'lt',
LessThanOrEqual = 'lte',
Range = 'range',
}
export const CUSTOM_FIELD_QUERY_OPERATOR_LABELS = {
[CustomFieldQueryOperator.Exact]: $localize`Equal to`,
[CustomFieldQueryOperator.In]: $localize`In`,
[CustomFieldQueryOperator.IsNull]: $localize`Is null`,
[CustomFieldQueryOperator.Exists]: $localize`Exists`,
[CustomFieldQueryOperator.Contains]: $localize`Contains`,
[CustomFieldQueryOperator.IContains]: $localize`Contains (case-insensitive)`,
[CustomFieldQueryOperator.GreaterThan]: $localize`Greater than`,
[CustomFieldQueryOperator.GreaterThanOrEqual]: $localize`Greater than or equal to`,
[CustomFieldQueryOperator.LessThan]: $localize`Less than`,
[CustomFieldQueryOperator.LessThanOrEqual]: $localize`Less than or equal to`,
[CustomFieldQueryOperator.Range]: $localize`Range`,
}
export enum CustomFieldQueryOperatorGroups {
Basic = 'basic',
String = 'string',
Arithmetic = 'arithmetic',
Containment = 'containment',
Subset = 'subset',
Date = 'date',
}
// Modified from filters.py > SUPPORTED_EXPR_OPERATORS
export const CUSTOM_FIELD_QUERY_OPERATORS_BY_GROUP = {
[CustomFieldQueryOperatorGroups.Basic]: [
CustomFieldQueryOperator.Exists,
CustomFieldQueryOperator.IsNull,
CustomFieldQueryOperator.Exact,
],
[CustomFieldQueryOperatorGroups.String]: [CustomFieldQueryOperator.IContains],
[CustomFieldQueryOperatorGroups.Arithmetic]: [
CustomFieldQueryOperator.GreaterThan,
CustomFieldQueryOperator.GreaterThanOrEqual,
CustomFieldQueryOperator.LessThan,
CustomFieldQueryOperator.LessThanOrEqual,
],
[CustomFieldQueryOperatorGroups.Containment]: [
CustomFieldQueryOperator.Contains,
],
[CustomFieldQueryOperatorGroups.Subset]: [CustomFieldQueryOperator.In],
[CustomFieldQueryOperatorGroups.Date]: [
CustomFieldQueryOperator.GreaterThanOrEqual,
CustomFieldQueryOperator.LessThanOrEqual,
],
}
// filters.py > SUPPORTED_EXPR_CATEGORIES
export const CUSTOM_FIELD_QUERY_OPERATOR_GROUPS_BY_TYPE = {
[CustomFieldDataType.String]: [
CustomFieldQueryOperatorGroups.Basic,
CustomFieldQueryOperatorGroups.String,
],
[CustomFieldDataType.Url]: [
CustomFieldQueryOperatorGroups.Basic,
CustomFieldQueryOperatorGroups.String,
],
[CustomFieldDataType.Date]: [
CustomFieldQueryOperatorGroups.Basic,
CustomFieldQueryOperatorGroups.Date,
],
[CustomFieldDataType.Boolean]: [CustomFieldQueryOperatorGroups.Basic],
[CustomFieldDataType.Integer]: [
CustomFieldQueryOperatorGroups.Basic,
CustomFieldQueryOperatorGroups.Arithmetic,
],
[CustomFieldDataType.Float]: [
CustomFieldQueryOperatorGroups.Basic,
CustomFieldQueryOperatorGroups.Arithmetic,
],
[CustomFieldDataType.Monetary]: [
CustomFieldQueryOperatorGroups.Basic,
CustomFieldQueryOperatorGroups.String,
CustomFieldQueryOperatorGroups.Arithmetic,
],
[CustomFieldDataType.DocumentLink]: [
CustomFieldQueryOperatorGroups.Basic,
CustomFieldQueryOperatorGroups.Containment,
],
[CustomFieldDataType.Select]: [
CustomFieldQueryOperatorGroups.Basic,
CustomFieldQueryOperatorGroups.Subset,
],
}
export const CUSTOM_FIELD_QUERY_VALUE_TYPES_BY_OPERATOR = {
[CustomFieldQueryOperator.Exact]: 'string|boolean',
[CustomFieldQueryOperator.IsNull]: 'boolean',
[CustomFieldQueryOperator.Exists]: 'boolean',
[CustomFieldQueryOperator.IContains]: 'string',
[CustomFieldQueryOperator.GreaterThanOrEqual]: 'string|number',
[CustomFieldQueryOperator.LessThanOrEqual]: 'string|number',
[CustomFieldQueryOperator.GreaterThan]: 'number',
[CustomFieldQueryOperator.LessThan]: 'number',
[CustomFieldQueryOperator.Contains]: 'array',
[CustomFieldQueryOperator.In]: 'array',
}
export const CUSTOM_FIELD_QUERY_MAX_DEPTH = 4
export const CUSTOM_FIELD_QUERY_MAX_ATOMS = 5
export enum CustomFieldQueryElementType {
Atom = 'Atom',
Expression = 'Expression',
}

View File

@ -55,6 +55,8 @@ export const FILTER_HAS_CUSTOM_FIELDS_ANY = 39
export const FILTER_DOES_NOT_HAVE_CUSTOM_FIELDS = 40
export const FILTER_HAS_ANY_CUSTOM_FIELDS = 41
export const FILTER_CUSTOM_FIELDS_QUERY = 42
export const FILTER_RULE_TYPES: FilterRuleType[] = [
{
id: FILTER_TITLE,
@ -317,6 +319,12 @@ export const FILTER_RULE_TYPES: FilterRuleType[] = [
multi: false,
default: true,
},
{
id: FILTER_CUSTOM_FIELDS_QUERY,
filtervar: 'custom_field_query',
datatype: 'string',
multi: false,
},
]
export interface FilterRuleType {

View File

@ -0,0 +1,245 @@
import {
CustomFieldQueryElement,
CustomFieldQueryAtom,
CustomFieldQueryExpression,
} from './custom-field-query-element'
import {
CustomFieldQueryElementType,
CustomFieldQueryLogicalOperator,
CustomFieldQueryOperator,
} from '../data/custom-field-query'
import { fakeAsync, tick } from '@angular/core/testing'
describe('CustomFieldQueryElement', () => {
it('should initialize with correct type and id', () => {
const element = new CustomFieldQueryElement(
CustomFieldQueryElementType.Atom
)
expect(element.type).toBe(CustomFieldQueryElementType.Atom)
expect(element.id).toBeDefined()
})
it('should trigger changed on operator change', () => {
const element = new CustomFieldQueryElement(
CustomFieldQueryElementType.Atom
)
element.changed.subscribe((changedElement) => {
expect(changedElement).toBe(element)
})
element.operator = CustomFieldQueryOperator.Exists
})
it('should trigger changed subject on value change', () => {
const element = new CustomFieldQueryElement(
CustomFieldQueryElementType.Atom
)
element.changed.subscribe((changedElement) => {
expect(changedElement).toBe(element)
})
element.value = 'new value'
})
it('should throw error on serialize call', () => {
const element = new CustomFieldQueryElement(
CustomFieldQueryElementType.Atom
)
expect(() => element.serialize()).toThrow('Implemented in subclass')
})
})
describe('CustomFieldQueryAtom', () => {
it('should initialize with correct field, operator, and value', () => {
const atom = new CustomFieldQueryAtom([1, 'operator', 'value'])
expect(atom.field).toBe(1)
expect(atom.operator).toBe('operator')
expect(atom.value).toBe('value')
})
it('should trigger changed subject on field change', () => {
const atom = new CustomFieldQueryAtom()
atom.changed.subscribe((changedAtom) => {
expect(changedAtom).toBe(atom)
})
atom.field = 2
})
it('should set value to null if operator is not found in CUSTOM_FIELD_QUERY_VALUE_TYPES_BY_OPERATOR', () => {
const atom = new CustomFieldQueryAtom()
atom.operator = 'nonexistent_operator'
expect(atom.value).toBeNull()
})
it('should set value to empty string if new type is string', () => {
const atom = new CustomFieldQueryAtom()
atom.operator = CustomFieldQueryOperator.IContains
expect(atom.value).toBe('')
})
it('should set value to "true" if new type is boolean', () => {
const atom = new CustomFieldQueryAtom()
atom.operator = CustomFieldQueryOperator.Exists
expect(atom.value).toBe('true')
})
it('should set value to empty array if new type is array', () => {
const atom = new CustomFieldQueryAtom()
atom.operator = CustomFieldQueryOperator.In
expect(atom.value).toEqual([])
})
it('should try to set existing value to number if new type is number', () => {
const atom = new CustomFieldQueryAtom()
atom.value = '42'
atom.operator = CustomFieldQueryOperator.GreaterThan
expect(atom.value).toBe('42')
// fallback to null if value is not parseable
atom.value = 'not_a_number'
atom.operator = CustomFieldQueryOperator.GreaterThan
expect(atom.value).toBeNull()
})
it('should change boolean values to empty string if operator is not boolean', () => {
const atom = new CustomFieldQueryAtom()
atom.value = 'true'
atom.operator = CustomFieldQueryOperator.Exact
expect(atom.value).toBe('')
})
it('should serialize correctly', () => {
const atom = new CustomFieldQueryAtom([1, 'operator', 'value'])
expect(atom.serialize()).toEqual([1, 'operator', 'value'])
})
it('should emit changed on value change after debounce', fakeAsync(() => {
const atom = new CustomFieldQueryAtom()
const changeSpy = jest.spyOn(atom.changed, 'next')
atom.value = 'new value'
tick(1000)
expect(changeSpy).toHaveBeenCalled()
}))
})
describe('CustomFieldQueryExpression', () => {
it('should initialize with default operator and empty value', () => {
const expression = new CustomFieldQueryExpression()
expect(expression.operator).toBe(CustomFieldQueryLogicalOperator.Or)
expect(expression.value).toEqual([])
})
it('should initialize with correct operator and value, propagate changes', () => {
const expression = new CustomFieldQueryExpression([
CustomFieldQueryLogicalOperator.And,
[
[1, 'exists', 'true'],
[2, 'exists', 'true'],
],
])
expect(expression.operator).toBe(CustomFieldQueryLogicalOperator.And)
expect(expression.value.length).toBe(2)
// propagate changes
const expressionChangeSpy = jest.spyOn(expression.changed, 'next')
;(expression.value[0] as CustomFieldQueryAtom).changed.next(
expression.value[0] as any
)
expect(expressionChangeSpy).toHaveBeenCalled()
const expression2 = new CustomFieldQueryExpression([
CustomFieldQueryLogicalOperator.Not,
[[CustomFieldQueryLogicalOperator.Or, []]],
])
const expressionChangeSpy2 = jest.spyOn(expression2.changed, 'next')
;(expression2.value[0] as CustomFieldQueryExpression).changed.next(
expression2.value[0] as any
)
expect(expressionChangeSpy2).toHaveBeenCalled()
})
it('should initialize with a sub-expression i.e. NOT', () => {
const expression = new CustomFieldQueryExpression([
CustomFieldQueryLogicalOperator.Not,
[
'AND',
[
[1, 'exists', 'true'],
[2, 'exists', 'true'],
],
],
])
expect(expression.value).toHaveLength(1)
const changedSpy = jest.spyOn(expression.changed, 'next')
;(expression.value[0] as CustomFieldQueryExpression).changed.next(
expression.value[0] as any
)
expect(changedSpy).toHaveBeenCalled()
})
it('should add atom correctly, propagate changes', () => {
const expression = new CustomFieldQueryExpression()
const atom = new CustomFieldQueryAtom([
1,
CustomFieldQueryOperator.Exists,
'true',
])
expression.addAtom(atom)
expect(expression.value).toContain(atom)
const changeSpy = jest.spyOn(expression.changed, 'next')
atom.changed.next(atom)
expect(changeSpy).toHaveBeenCalled()
// coverage
expression.addAtom()
})
it('should add expression correctly, propagate changes', () => {
const expression = new CustomFieldQueryExpression()
const subExpression = new CustomFieldQueryExpression([
CustomFieldQueryLogicalOperator.Or,
[],
])
expression.addExpression(subExpression)
expect(expression.value).toContain(subExpression)
const changeSpy = jest.spyOn(expression.changed, 'next')
subExpression.changed.next(subExpression)
expect(changeSpy).toHaveBeenCalled()
// coverage
expression.addExpression()
})
it('should serialize correctly', () => {
const expression = new CustomFieldQueryExpression([
CustomFieldQueryLogicalOperator.And,
[[1, 'exists', 'true']],
])
expect(expression.serialize()).toEqual([
CustomFieldQueryLogicalOperator.And,
[[1, 'exists', 'true']],
])
})
it('should serialize NOT expressions correctly', () => {
const expression = new CustomFieldQueryExpression()
expression.addExpression(
new CustomFieldQueryExpression([
CustomFieldQueryLogicalOperator.And,
[
[1, 'exists', 'true'],
[2, 'exists', 'true'],
],
])
)
expression.operator = CustomFieldQueryLogicalOperator.Not
const serialized = expression.serialize()
expect(serialized[0]).toBe(CustomFieldQueryLogicalOperator.Not)
expect(serialized[1][0]).toBe(CustomFieldQueryLogicalOperator.And)
expect(serialized[1][1].length).toBe(2)
})
it('should be negatable if it has one child which is an expression', () => {
const expression = new CustomFieldQueryExpression([
CustomFieldQueryLogicalOperator.Not,
[[CustomFieldQueryLogicalOperator.Or, []]],
])
expect(expression.negatable).toBe(true)
})
})

View File

@ -0,0 +1,210 @@
import { Subject, debounceTime, distinctUntilChanged } from 'rxjs'
import { v4 as uuidv4 } from 'uuid'
import {
CustomFieldQueryElementType,
CUSTOM_FIELD_QUERY_VALUE_TYPES_BY_OPERATOR,
CustomFieldQueryLogicalOperator,
CustomFieldQueryOperator,
} from '../data/custom-field-query'
export class CustomFieldQueryElement {
public readonly type: CustomFieldQueryElementType
public changed: Subject<CustomFieldQueryElement>
protected valueModelChanged: Subject<
string | string[] | number[] | CustomFieldQueryElement[]
>
public depth: number = 0
public id: string = uuidv4()
constructor(type: CustomFieldQueryElementType) {
this.type = type
this.changed = new Subject<CustomFieldQueryElement>()
this.valueModelChanged = new Subject<string | CustomFieldQueryElement[]>()
this.connectValueModelChanged()
}
protected connectValueModelChanged() {
// Allows overriding in subclasses
this.valueModelChanged.subscribe(() => {
this.changed.next(this)
})
}
public serialize() {
throw new Error('Implemented in subclass')
}
protected _operator: string = null
public set operator(value: string) {
this._operator = value
this.changed.next(this)
}
public get operator(): string {
return this._operator
}
protected _value: string | string[] | number[] | CustomFieldQueryElement[] =
null
public set value(
value: string | string[] | number[] | CustomFieldQueryElement[]
) {
this._value = value
this.valueModelChanged.next(value)
}
public get value(): string | string[] | number[] | CustomFieldQueryElement[] {
return this._value
}
}
export class CustomFieldQueryAtom extends CustomFieldQueryElement {
protected _field: number
set field(field: any) {
this._field = parseInt(field, 10)
this.changed.next(this)
}
get field(): number {
return this._field
}
override set operator(operator: string) {
const newTypes: string[] =
CUSTOM_FIELD_QUERY_VALUE_TYPES_BY_OPERATOR[operator]?.split('|')
if (!newTypes) {
this.value = null
} else {
if (!newTypes.includes(typeof this.value)) {
switch (newTypes[0]) {
case 'string':
this.value = ''
break
case 'boolean':
this.value = 'true'
break
case 'array':
this.value = []
break
case 'number':
const num = parseFloat(this.value as string)
this.value = isNaN(num) ? null : num.toString()
break
}
} else if (
['true', 'false'].includes(this.value as string) &&
newTypes.includes('string')
) {
this.value = ''
}
}
super.operator = operator
}
override get operator(): string {
// why?
return super.operator
}
constructor(queryArray: [number, string, string] = [null, null, null]) {
super(CustomFieldQueryElementType.Atom)
;[this._field, this._operator, this._value] = queryArray
}
protected override connectValueModelChanged(): void {
this.valueModelChanged
.pipe(debounceTime(1000), distinctUntilChanged())
.subscribe(() => {
this.changed.next(this)
})
}
public override serialize() {
return [this._field, this._operator, this._value]
}
}
export class CustomFieldQueryExpression extends CustomFieldQueryElement {
protected _value: string[] | number[] | CustomFieldQueryElement[]
constructor(
expressionArray: [CustomFieldQueryLogicalOperator, any[]] = [
CustomFieldQueryLogicalOperator.Or,
null,
]
) {
super(CustomFieldQueryElementType.Expression)
let values
;[this._operator, values] = expressionArray
if (!values || values.length === 0) {
this._value = []
} else if (values?.length > 0 && values[0] instanceof Array) {
this._value = values.map((value) => {
if (value.length === 3) {
const atom = new CustomFieldQueryAtom(value)
atom.depth = this.depth + 1
atom.changed.subscribe(() => {
this.changed.next(this)
})
return atom
} else {
const expression = new CustomFieldQueryExpression(value)
expression.depth = this.depth + 1
expression.changed.subscribe(() => {
this.changed.next(this)
})
return expression
}
})
} else {
const expression = new CustomFieldQueryExpression(values as any)
expression.depth = this.depth + 1
expression.changed.subscribe(() => {
this.changed.next(this)
})
this._value = [expression]
}
}
public override serialize() {
let value
value = this._value.map((element) => element.serialize())
// If the expression is negated it should have only one child which is an expression
if (
this._operator === CustomFieldQueryLogicalOperator.Not &&
value.length === 1
) {
value = value[0]
}
return [this._operator, value]
}
public addAtom(
atom: CustomFieldQueryAtom = new CustomFieldQueryAtom([
null,
CustomFieldQueryOperator.Exists,
'true',
])
) {
atom.depth = this.depth + 1
;(this._value as CustomFieldQueryElement[]).push(atom)
atom.changed.subscribe(() => {
this.changed.next(this)
})
}
public addExpression(
expression: CustomFieldQueryExpression = new CustomFieldQueryExpression()
) {
expression.depth = this.depth + 1
;(this._value as CustomFieldQueryElement[]).push(expression)
expression.changed.subscribe(() => {
this.changed.next(this)
})
}
public get negatable(): boolean {
return (
this.value.length === 1 &&
(this.value[0] as CustomFieldQueryElement).type ===
CustomFieldQueryElementType.Expression
)
}
}

View File

@ -2,13 +2,17 @@ import { convertToParamMap } from '@angular/router'
import { FilterRule } from '../data/filter-rule'
import {
FILTER_CORRESPONDENT,
FILTER_CUSTOM_FIELDS_QUERY,
FILTER_HAS_ANY_TAG,
FILTER_HAS_CUSTOM_FIELDS_ALL,
FILTER_HAS_CUSTOM_FIELDS_ANY,
FILTER_HAS_TAGS_ALL,
} from '../data/filter-rule-type'
import { paramsToViewState } from './query-params'
import { paramsToViewState, transformLegacyFilterRules } from './query-params'
import { paramsFromViewState } from './query-params'
import { queryParamsFromFilterRules } from './query-params'
import { filterRulesFromQueryParams } from './query-params'
import { CustomFieldQueryLogicalOperator } from '../data/custom-field-query'
const tags__id__all = '9'
const filterRules: FilterRule[] = [
@ -193,4 +197,58 @@ describe('QueryParams Utils', () => {
},
])
})
it('should transform legacy filter rules', () => {
let filterRules: FilterRule[] = [
{
rule_type: FILTER_HAS_CUSTOM_FIELDS_ANY,
value: '1',
},
{
rule_type: FILTER_HAS_CUSTOM_FIELDS_ANY,
value: '2',
},
]
let transformedFilterRules = transformLegacyFilterRules(filterRules)
expect(transformedFilterRules).toEqual([
{
rule_type: FILTER_CUSTOM_FIELDS_QUERY,
value: JSON.stringify([
CustomFieldQueryLogicalOperator.Or,
[
[1, 'exists', true],
[2, 'exists', true],
],
]),
},
])
filterRules = [
{
rule_type: FILTER_HAS_CUSTOM_FIELDS_ALL,
value: '3',
},
{
rule_type: FILTER_HAS_CUSTOM_FIELDS_ALL,
value: '4',
},
]
transformedFilterRules = transformLegacyFilterRules(filterRules)
expect(transformedFilterRules).toEqual([
{
rule_type: FILTER_CUSTOM_FIELDS_QUERY,
value: JSON.stringify([
CustomFieldQueryLogicalOperator.And,
[
[3, 'exists', true],
[4, 'exists', true],
],
]),
},
])
})
})

View File

@ -1,7 +1,17 @@
import { ParamMap, Params } from '@angular/router'
import { FilterRule } from '../data/filter-rule'
import { FilterRuleType, FILTER_RULE_TYPES } from '../data/filter-rule-type'
import {
FilterRuleType,
FILTER_RULE_TYPES,
FILTER_HAS_CUSTOM_FIELDS_ANY,
FILTER_CUSTOM_FIELDS_QUERY,
FILTER_HAS_CUSTOM_FIELDS_ALL,
} from '../data/filter-rule-type'
import { ListViewState } from '../services/document-list-view.service'
import {
CustomFieldQueryLogicalOperator,
CustomFieldQueryOperator,
} from '../data/custom-field-query'
const SORT_FIELD_PARAMETER = 'sort'
const SORT_REVERSE_PARAMETER = 'reverse'
@ -40,6 +50,49 @@ export function paramsToViewState(queryParams: ParamMap): ListViewState {
}
}
export function transformLegacyFilterRules(
filterRules: FilterRule[]
): FilterRule[] {
const LEGACY_CUSTOM_FIELD_FILTER_RULE_TYPES = [
FILTER_HAS_CUSTOM_FIELDS_ANY,
FILTER_HAS_CUSTOM_FIELDS_ALL,
]
if (
filterRules.filter((rule) =>
LEGACY_CUSTOM_FIELD_FILTER_RULE_TYPES.includes(rule.rule_type)
).length
) {
const anyRules = filterRules.filter(
(rule) => rule.rule_type === FILTER_HAS_CUSTOM_FIELDS_ANY
)
const allRules = filterRules.filter(
(rule) => rule.rule_type === FILTER_HAS_CUSTOM_FIELDS_ALL
)
const customFieldQueryLogicalOperator = allRules.length
? CustomFieldQueryLogicalOperator.And
: CustomFieldQueryLogicalOperator.Or
const valueRules = allRules.length ? allRules : anyRules
const customFieldQueryExpression = [
customFieldQueryLogicalOperator,
[
...valueRules.map((rule) => [
parseInt(rule.value),
CustomFieldQueryOperator.Exists,
true,
]),
],
]
filterRules.push({
rule_type: FILTER_CUSTOM_FIELDS_QUERY,
value: JSON.stringify(customFieldQueryExpression),
})
}
// TODO: can we support FILTER_DOES_NOT_HAVE_CUSTOM_FIELDS or FILTER_HAS_ANY_CUSTOM_FIELDS?
return filterRules.filter(
(rule) => !LEGACY_CUSTOM_FIELD_FILTER_RULE_TYPES.includes(rule.rule_type)
)
}
export function filterRulesFromQueryParams(
queryParams: ParamMap
): FilterRule[] {
@ -77,7 +130,9 @@ export function filterRulesFromQueryParams(
})
)
})
filterRulesFromQueryParams = transformLegacyFilterRules(
filterRulesFromQueryParams
)
return filterRulesFromQueryParams
}

View File

@ -29,13 +29,15 @@ from documents.models import Log
from documents.models import ShareLink
from documents.models import StoragePath
from documents.models import Tag
from paperless import settings
CHAR_KWARGS = ["istartswith", "iendswith", "icontains", "iexact"]
ID_KWARGS = ["in", "exact"]
INT_KWARGS = ["exact", "gt", "gte", "lt", "lte", "isnull"]
DATE_KWARGS = ["year", "month", "day", "date__gt", "gt", "date__lt", "lt"]
CUSTOM_FIELD_QUERY_MAX_DEPTH = 10
CUSTOM_FIELD_QUERY_MAX_ATOMS = 20
class CorrespondentFilterSet(FilterSet):
class Meta:
@ -234,19 +236,13 @@ def handle_validation_prefix(func: Callable):
return wrapper
class CustomFieldLookupParser:
class CustomFieldQueryParser:
EXPR_BY_CATEGORY = {
"basic": ["exact", "in", "isnull", "exists"],
"string": [
"iexact",
"contains",
"icontains",
"startswith",
"istartswith",
"endswith",
"iendswith",
"regex",
"iregex",
],
"arithmetic": [
"gt",
@ -258,23 +254,6 @@ class CustomFieldLookupParser:
"containment": ["contains"],
}
# These string lookup expressions are problematic. We shall disable
# them by default unless the user explicitly opts in.
STR_EXPR_DISABLED_BY_DEFAULT = [
# SQLite: is case-sensitive outside the ASCII range
"iexact",
# SQLite: behaves the same as icontains
"contains",
# SQLite: behaves the same as istartswith
"startswith",
# SQLite: behaves the same as iendswith
"endswith",
# Syntax depends on database backends, can be exploited for ReDoS
"regex",
# Syntax depends on database backends, can be exploited for ReDoS
"iregex",
]
SUPPORTED_EXPR_CATEGORIES = {
CustomField.FieldDataType.STRING: ("basic", "string"),
CustomField.FieldDataType.URL: ("basic", "string"),
@ -282,7 +261,7 @@ class CustomFieldLookupParser:
CustomField.FieldDataType.BOOL: ("basic",),
CustomField.FieldDataType.INT: ("basic", "arithmetic"),
CustomField.FieldDataType.FLOAT: ("basic", "arithmetic"),
CustomField.FieldDataType.MONETARY: ("basic", "string"),
CustomField.FieldDataType.MONETARY: ("basic", "string", "arithmetic"),
CustomField.FieldDataType.DOCUMENTLINK: ("basic", "containment"),
CustomField.FieldDataType.SELECT: ("basic",),
}
@ -371,7 +350,7 @@ class CustomFieldLookupParser:
elif len(expr) == 3:
return self._parse_atom(*expr)
raise serializers.ValidationError(
[_("Invalid custom field lookup expression")],
[_("Invalid custom field query expression")],
)
@handle_validation_prefix
@ -416,13 +395,7 @@ class CustomFieldLookupParser:
self._atom_count += 1
if self._atom_count > self._max_atom_count:
raise serializers.ValidationError(
[
_(
"Maximum number of query conditions exceeded. You can raise "
"the limit by setting PAPERLESS_CUSTOM_FIELD_LOOKUP_MAX_ATOMS "
"in your configuration file.",
),
],
[_("Maximum number of query conditions exceeded.")],
)
custom_field = self._get_custom_field(id_or_name, validation_prefix="0")
@ -444,6 +417,11 @@ class CustomFieldLookupParser:
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"]
):
value_field_name = "value_monetary_amount"
has_field = Q(custom_fields__field=custom_field)
# Our special exists operator.
@ -494,22 +472,6 @@ class CustomFieldLookupParser:
# Check if the operator is supported for the current data_type.
supported = False
for category in self.SUPPORTED_EXPR_CATEGORIES[custom_field.data_type]:
if (
category == "string"
and op in self.STR_EXPR_DISABLED_BY_DEFAULT
and op not in settings.CUSTOM_FIELD_LOOKUP_OPT_IN
):
raise serializers.ValidationError(
[
_(
"{expr!r} is disabled by default because it does not "
"behave consistently across database backends, or can "
"cause security risks. If you understand the implications "
"you may enabled it by adding it to "
"`PAPERLESS_CUSTOM_FIELD_LOOKUP_OPT_IN`.",
).format(expr=op),
],
)
if op in self.EXPR_BY_CATEGORY[category]:
supported = True
break
@ -527,7 +489,7 @@ class CustomFieldLookupParser:
if not supported:
raise serializers.ValidationError(
[
_("{data_type} does not support lookup expr {expr!r}.").format(
_("{data_type} does not support query expr {expr!r}.").format(
data_type=custom_field.data_type,
expr=raw_op,
),
@ -548,7 +510,7 @@ class CustomFieldLookupParser:
custom_field.data_type == CustomField.FieldDataType.DATE
and prefix in self.DATE_COMPONENTS
):
# DateField admits lookups in the form of `year__exact`, etc. These take integers.
# DateField admits queries in the form of `year__exact`, etc. These take integers.
field = serializers.IntegerField()
elif custom_field.data_type == CustomField.FieldDataType.DOCUMENTLINK:
# We can be more specific here and make sure the value is a list.
@ -610,7 +572,7 @@ class CustomFieldLookupParser:
custom_fields__value_document_ids__isnull=False,
)
# First we lookup reverse links from the requested documents.
# First we look up reverse links from the requested documents.
links = CustomFieldInstance.objects.filter(
document_id__in=value,
field__data_type=CustomField.FieldDataType.DOCUMENTLINK,
@ -635,22 +597,14 @@ class CustomFieldLookupParser:
# guard against queries that are too deeply nested
self._current_depth += 1
if self._current_depth > self._max_query_depth:
raise serializers.ValidationError(
[
_(
"Maximum nesting depth exceeded. You can raise the limit "
"by setting PAPERLESS_CUSTOM_FIELD_LOOKUP_MAX_DEPTH in "
"your configuration file.",
),
],
)
raise serializers.ValidationError([_("Maximum nesting depth exceeded.")])
try:
yield
finally:
self._current_depth -= 1
class CustomFieldLookupFilter(Filter):
class CustomFieldQueryFilter(Filter):
def __init__(self, validation_prefix):
"""
A filter that filters documents based on custom field name and value.
@ -665,10 +619,10 @@ class CustomFieldLookupFilter(Filter):
if not value:
return qs
parser = CustomFieldLookupParser(
parser = CustomFieldQueryParser(
self._validation_prefix,
max_query_depth=settings.CUSTOM_FIELD_LOOKUP_MAX_DEPTH,
max_atom_count=settings.CUSTOM_FIELD_LOOKUP_MAX_ATOMS,
max_query_depth=CUSTOM_FIELD_QUERY_MAX_DEPTH,
max_atom_count=CUSTOM_FIELD_QUERY_MAX_ATOMS,
)
q, annotations = parser.parse(value)
@ -722,7 +676,7 @@ class DocumentFilterSet(FilterSet):
exclude=True,
)
custom_field_lookup = CustomFieldLookupFilter("custom_field_lookup")
custom_field_query = CustomFieldQueryFilter("custom_field_query")
shared_by__id = SharedByUser()

View File

@ -0,0 +1,95 @@
# Generated by Django 5.1.1 on 2024-09-29 16:26
import django.db.models.functions.comparison
import django.db.models.functions.text
from django.db import migrations
from django.db import models
class Migration(migrations.Migration):
dependencies = [
("documents", "1053_document_page_count"),
]
operations = [
migrations.AddField(
model_name="customfieldinstance",
name="value_monetary_amount",
field=models.GeneratedField(
db_persist=True,
expression=models.Case(
models.When(
then=django.db.models.functions.comparison.Cast(
django.db.models.functions.text.Substr("value_monetary", 1),
output_field=models.DecimalField(
decimal_places=2,
max_digits=65,
),
),
value_monetary__regex="^\\d+",
),
default=django.db.models.functions.comparison.Cast(
django.db.models.functions.text.Substr("value_monetary", 4),
output_field=models.DecimalField(
decimal_places=2,
max_digits=65,
),
),
output_field=models.DecimalField(decimal_places=2, max_digits=65),
),
output_field=models.DecimalField(decimal_places=2, max_digits=65),
),
),
migrations.AlterField(
model_name="savedviewfilterrule",
name="rule_type",
field=models.PositiveIntegerField(
choices=[
(0, "title contains"),
(1, "content contains"),
(2, "ASN is"),
(3, "correspondent is"),
(4, "document type is"),
(5, "is in inbox"),
(6, "has tag"),
(7, "has any tag"),
(8, "created before"),
(9, "created after"),
(10, "created year is"),
(11, "created month is"),
(12, "created day is"),
(13, "added before"),
(14, "added after"),
(15, "modified before"),
(16, "modified after"),
(17, "does not have tag"),
(18, "does not have ASN"),
(19, "title or content contains"),
(20, "fulltext query"),
(21, "more like this"),
(22, "has tags in"),
(23, "ASN greater than"),
(24, "ASN less than"),
(25, "storage path is"),
(26, "has correspondent in"),
(27, "does not have correspondent in"),
(28, "has document type in"),
(29, "does not have document type in"),
(30, "has storage path in"),
(31, "does not have storage path in"),
(32, "owner is"),
(33, "has owner in"),
(34, "does not have owner"),
(35, "does not have owner in"),
(36, "has custom field value"),
(37, "is shared by me"),
(38, "has custom fields"),
(39, "has custom field in"),
(40, "does not have custom field in"),
(41, "does not have custom field"),
(42, "custom fields query"),
],
verbose_name="rule type",
),
),
]

View File

@ -22,6 +22,9 @@ from multiselectfield import MultiSelectField
if settings.AUDIT_LOG_ENABLED:
from auditlog.registry import auditlog
from django.db.models import Case
from django.db.models.functions import Cast
from django.db.models.functions import Substr
from django_softdelete.models import SoftDeleteModel
from documents.data_models import DocumentSource
@ -519,6 +522,7 @@ class SavedViewFilterRule(models.Model):
(39, _("has custom field in")),
(40, _("does not have custom field in")),
(41, _("does not have custom field")),
(42, _("custom fields query")),
]
saved_view = models.ForeignKey(
@ -921,6 +925,27 @@ class CustomFieldInstance(models.Model):
value_monetary = models.CharField(null=True, max_length=128)
value_monetary_amount = models.GeneratedField(
expression=Case(
# If the value starts with a number and no currency symbol, use the whole string
models.When(
value_monetary__regex=r"^\d+",
then=Cast(
Substr("value_monetary", 1),
output_field=models.DecimalField(decimal_places=2, max_digits=65),
),
),
# If the value starts with a 3-char currency symbol, use the rest of the string
default=Cast(
Substr("value_monetary", 4),
output_field=models.DecimalField(decimal_places=2, max_digits=65),
),
output_field=models.DecimalField(decimal_places=2, max_digits=65),
),
output_field=models.DecimalField(decimal_places=2, max_digits=65),
db_persist=True,
)
value_document_ids = models.JSONField(null=True)
value_select = models.PositiveSmallIntegerField(null=True)

View File

@ -1,11 +1,9 @@
import json
import re
from collections.abc import Callable
from datetime import date
from unittest.mock import Mock
from urllib.parse import quote
import pytest
from django.contrib.auth.models import User
from rest_framework.test import APITestCase
@ -13,7 +11,6 @@ from documents.models import CustomField
from documents.models import Document
from documents.serialisers import DocumentSerializer
from documents.tests.utils import DirectoriesMixin
from paperless import settings
class DocumentWrapper:
@ -31,11 +28,7 @@ class DocumentWrapper:
return self._document.custom_fields.get(field__name=custom_field).value
def string_expr_opted_in(op):
return op in settings.CUSTOM_FIELD_LOOKUP_OPT_IN
class TestDocumentSearchApi(DirectoriesMixin, APITestCase):
class TestCustomFieldsSearch(DirectoriesMixin, APITestCase):
def setUp(self):
super().setUp()
@ -111,6 +104,7 @@ class TestDocumentSearchApi(DirectoriesMixin, APITestCase):
self._create_document(monetary_field="USD100.00")
self._create_document(monetary_field="USD1.00")
self._create_document(monetary_field="EUR50.00")
self._create_document(monetary_field="101.00")
# CustomField.FieldDataType.DOCUMENTLINK
self._create_document(documentlink_field=None)
@ -188,7 +182,7 @@ class TestDocumentSearchApi(DirectoriesMixin, APITestCase):
"/api/documents/?"
+ "&".join(
(
f"custom_field_lookup={query_string}",
f"custom_field_query={query_string}",
"ordering=archive_serial_number",
"page=1",
f"page_size={len(self.documents)}",
@ -212,7 +206,7 @@ class TestDocumentSearchApi(DirectoriesMixin, APITestCase):
"/api/documents/?"
+ "&".join(
(
f"custom_field_lookup={query_string}",
f"custom_field_query={query_string}",
"ordering=archive_serial_number",
"page=1",
f"page_size={len(self.documents)}",
@ -313,32 +307,6 @@ class TestDocumentSearchApi(DirectoriesMixin, APITestCase):
# ==========================================================#
# Expressions for string, URL, and monetary fields #
# ==========================================================#
@pytest.mark.skipif(
not string_expr_opted_in("iexact"),
reason="iexact expr is disabled.",
)
def test_iexact(self):
self._assert_query_match_predicate(
["string_field", "iexact", "paperless"],
lambda document: "string_field" in document
and document["string_field"] is not None
and document["string_field"].lower() == "paperless",
)
@pytest.mark.skipif(
not string_expr_opted_in("contains"),
reason="contains expr is disabled.",
)
def test_contains(self):
# WARNING: SQLite treats "contains" as "icontains"!
# You should avoid "contains" unless you know what you are doing!
self._assert_query_match_predicate(
["string_field", "contains", "aper"],
lambda document: "string_field" in document
and document["string_field"] is not None
and "aper" in document["string_field"],
)
def test_icontains(self):
self._assert_query_match_predicate(
["string_field", "icontains", "aper"],
@ -347,20 +315,6 @@ class TestDocumentSearchApi(DirectoriesMixin, APITestCase):
and "aper" in document["string_field"].lower(),
)
@pytest.mark.skipif(
not string_expr_opted_in("startswith"),
reason="startswith expr is disabled.",
)
def test_startswith(self):
# WARNING: SQLite treats "startswith" as "istartswith"!
# You should avoid "startswith" unless you know what you are doing!
self._assert_query_match_predicate(
["string_field", "startswith", "paper"],
lambda document: "string_field" in document
and document["string_field"] is not None
and document["string_field"].startswith("paper"),
)
def test_istartswith(self):
self._assert_query_match_predicate(
["string_field", "istartswith", "paper"],
@ -369,20 +323,6 @@ class TestDocumentSearchApi(DirectoriesMixin, APITestCase):
and document["string_field"].lower().startswith("paper"),
)
@pytest.mark.skipif(
not string_expr_opted_in("endswith"),
reason="endswith expr is disabled.",
)
def test_endswith(self):
# WARNING: SQLite treats "endswith" as "iendswith"!
# You should avoid "endswith" unless you know what you are doing!
self._assert_query_match_predicate(
["string_field", "iendswith", "less"],
lambda document: "string_field" in document
and document["string_field"] is not None
and document["string_field"].lower().endswith("less"),
)
def test_iendswith(self):
self._assert_query_match_predicate(
["string_field", "iendswith", "less"],
@ -391,32 +331,6 @@ class TestDocumentSearchApi(DirectoriesMixin, APITestCase):
and document["string_field"].lower().endswith("less"),
)
@pytest.mark.skipif(
not string_expr_opted_in("regex"),
reason="regex expr is disabled.",
)
def test_regex(self):
# WARNING: the regex syntax is database dependent!
self._assert_query_match_predicate(
["string_field", "regex", r"^p.+s$"],
lambda document: "string_field" in document
and document["string_field"] is not None
and re.match(r"^p.+s$", document["string_field"]),
)
@pytest.mark.skipif(
not string_expr_opted_in("iregex"),
reason="iregex expr is disabled.",
)
def test_iregex(self):
# WARNING: the regex syntax is database dependent!
self._assert_query_match_predicate(
["string_field", "iregex", r"^p.+s$"],
lambda document: "string_field" in document
and document["string_field"] is not None
and re.match(r"^p.+s$", document["string_field"], re.IGNORECASE),
)
def test_url_field_istartswith(self):
# URL fields supports all of the expressions above.
# Just showing one of them here.
@ -427,28 +341,6 @@ class TestDocumentSearchApi(DirectoriesMixin, APITestCase):
and document["url_field"].startswith("http://"),
)
@pytest.mark.skipif(
not string_expr_opted_in("iregex"),
reason="regex expr is disabled.",
)
def test_monetary_field_iregex(self):
# Monetary fields supports all of the expressions above.
# Just showing one of them here.
#
# Unfortunately we can't do arithmetic comparisons on monetary field,
# but you are welcome to use regex to do some of that.
# E.g., USD between 100.00 and 999.99:
self._assert_query_match_predicate(
["monetary_field", "regex", r"USD[1-9][0-9]{2}\.[0-9]{2}"],
lambda document: "monetary_field" in document
and document["monetary_field"] is not None
and re.match(
r"USD[1-9][0-9]{2}\.[0-9]{2}",
document["monetary_field"],
re.IGNORECASE,
),
)
# ==========================================================#
# Arithmetic comparisons #
# ==========================================================#
@ -502,6 +394,17 @@ class TestDocumentSearchApi(DirectoriesMixin, APITestCase):
and document["date_field"].year >= 2024,
)
def test_gt_monetary(self):
self._assert_query_match_predicate(
["monetary_field", "gt", "99"],
lambda document: "monetary_field" in document
and document["monetary_field"] is not None
and (
document["monetary_field"] == "USD100.00" # With currency symbol
or document["monetary_field"] == "101.00" # No currency symbol
),
)
# ==========================================================#
# Subset check (document link field only) #
# ==========================================================#
@ -586,68 +489,57 @@ class TestDocumentSearchApi(DirectoriesMixin, APITestCase):
def test_invalid_json(self):
self._assert_validation_error(
"not valid json",
["custom_field_lookup"],
["custom_field_query"],
"must be valid JSON",
)
def test_invalid_expression(self):
self._assert_validation_error(
json.dumps("valid json but not valid expr"),
["custom_field_lookup"],
"Invalid custom field lookup expression",
["custom_field_query"],
"Invalid custom field query expression",
)
def test_invalid_custom_field_name(self):
self._assert_validation_error(
json.dumps(["invalid name", "iexact", "foo"]),
["custom_field_lookup", "0"],
["custom_field_query", "0"],
"is not a valid custom field",
)
def test_invalid_operator(self):
self._assert_validation_error(
json.dumps(["integer_field", "iexact", "foo"]),
["custom_field_lookup", "1"],
"does not support lookup expr",
["custom_field_query", "1"],
"does not support query expr",
)
def test_invalid_value(self):
self._assert_validation_error(
json.dumps(["select_field", "exact", "not an option"]),
["custom_field_lookup", "2"],
["custom_field_query", "2"],
"integer",
)
def test_invalid_logical_operator(self):
self._assert_validation_error(
json.dumps(["invalid op", ["integer_field", "gt", 0]]),
["custom_field_lookup", "0"],
["custom_field_query", "0"],
"Invalid logical operator",
)
def test_invalid_expr_list(self):
self._assert_validation_error(
json.dumps(["AND", "not a list"]),
["custom_field_lookup", "1"],
["custom_field_query", "1"],
"Invalid expression list",
)
def test_invalid_operator_prefix(self):
self._assert_validation_error(
json.dumps(["integer_field", "foo__gt", 0]),
["custom_field_lookup", "1"],
"does not support lookup expr",
)
@pytest.mark.skipif(
string_expr_opted_in("regex"),
reason="user opted into allowing regex expr",
)
def test_disabled_operator(self):
self._assert_validation_error(
json.dumps(["string_field", "regex", r"^p.+s$"]),
["custom_field_lookup", "1"],
"disabled by default",
["custom_field_query", "1"],
"does not support query expr",
)
def test_query_too_deep(self):
@ -656,7 +548,7 @@ class TestDocumentSearchApi(DirectoriesMixin, APITestCase):
query = ["NOT", query]
self._assert_validation_error(
json.dumps(query),
["custom_field_lookup", *(["1"] * 10)],
["custom_field_query", *(["1"] * 10)],
"Maximum nesting depth exceeded",
)
@ -665,6 +557,6 @@ class TestDocumentSearchApi(DirectoriesMixin, APITestCase):
query = ["AND", [atom for _ in range(21)]]
self._assert_validation_error(
json.dumps(query),
["custom_field_lookup", "1", "20"],
["custom_field_query", "1", "20"],
"Maximum number of query conditions exceeded",
)

View File

@ -1195,20 +1195,3 @@ EMAIL_ENABLE_GPG_DECRYPTOR: Final[bool] = __get_boolean(
# Soft Delete #
###############################################################################
EMPTY_TRASH_DELAY = max(__get_int("PAPERLESS_EMPTY_TRASH_DELAY", 30), 1)
###############################################################################
# custom_field_lookup Filter Settings #
###############################################################################
CUSTOM_FIELD_LOOKUP_OPT_IN = __get_list(
"PAPERLESS_CUSTOM_FIELD_LOOKUP_OPT_IN",
default=[],
)
CUSTOM_FIELD_LOOKUP_MAX_DEPTH = __get_int(
"PAPERLESS_CUSTOM_FIELD_LOOKUP_MAX_DEPTH",
default=10,
)
CUSTOM_FIELD_LOOKUP_MAX_ATOMS = __get_int(
"PAPERLESS_CUSTOM_FIELD_LOOKUP_MAX_ATOMS",
default=20,
)