Compare commits

..

21 Commits

Author SHA1 Message Date
Trenton H
7f74348164 Also pre-fetch and use lighter exists() instead of count 2025-10-10 11:21:08 -07:00
Trenton H
7ed488faa9 Change how the workflow matching is handled, with the goal to reduce queries, make queries cheaper and reduce function complexity 2025-10-10 10:42:00 -07:00
shamoon
914c007103 Update docs 2025-10-08 08:50:48 -07:00
shamoon
f3e749511e Refactor workflow trigger conditions to filters 2025-10-08 08:42:21 -07:00
shamoon
e715a78b63 Update test_workflows.py 2025-10-07 14:07:01 -07:00
shamoon
1b8033209a 100% frontend coverage 2025-10-07 14:04:20 -07:00
shamoon
3828d07ec6 More backend coverage 2025-10-07 13:48:06 -07:00
shamoon
9c4d09c91c More frontend coverage 2025-10-07 13:45:04 -07:00
shamoon
ea6fdc78e6 Merge branch 'dev' into feature-wf-conditions 2025-10-07 13:35:57 -07:00
shamoon
979ccf4c51 Update workflow-edit-dialog.component.ts 2025-10-07 13:35:16 -07:00
shamoon
1c75c4d94b Good sonar 2025-10-07 13:31:17 -07:00
shamoon
3ac5efd86a Ok big refactor, also some subscription handling 2025-10-07 12:59:33 -07:00
shamoon
9dcb74fda0 Some more frontend coverage 2025-10-07 12:28:37 -07:00
shamoon
e759ca58c3 Add initial query atom 2025-10-07 12:16:11 -07:00
shamoon
88fcc5f339 Support CF queries! 2025-10-07 11:53:47 -07:00
shamoon
3d9cf696a7 Frontend coverage 2025-10-07 10:53:52 -07:00
shamoon
4cf9d7d567 Fix pattern required 2025-10-07 10:47:03 -07:00
shamoon
b323c180be Lots of cleanup, looking good. Simplify 2025-10-07 10:29:42 -07:00
shamoon
0fe5ca9b60 Update workflow-edit-dialog.component.html 2025-10-07 09:59:35 -07:00
shamoon
4965480958 Add negation for other things, universal query builder 2025-10-07 09:42:00 -07:00
shamoon
1fed785c7d Initial crack 2025-10-07 09:20:37 -07:00
18 changed files with 2282 additions and 493 deletions

View File

@@ -5,7 +5,7 @@
# Purpose: Compiles the frontend # Purpose: Compiles the frontend
# Notes: # Notes:
# - Does PNPM stuff with Typescript and such # - Does PNPM stuff with Typescript and such
FROM --platform=$BUILDPLATFORM docker.io/node:20-trixie-slim AS compile-frontend FROM --platform=$BUILDPLATFORM docker.io/node:20-bookworm-slim AS compile-frontend
COPY ./src-ui /src/src-ui COPY ./src-ui /src/src-ui
@@ -170,8 +170,20 @@ RUN set -eux \
&& apt-get update \ && apt-get update \
&& apt-get install --yes --quiet --no-install-recommends ${RUNTIME_PACKAGES} \ && apt-get install --yes --quiet --no-install-recommends ${RUNTIME_PACKAGES} \
&& echo "Installing pre-built updates" \ && echo "Installing pre-built updates" \
&& curl --fail --silent --no-progress-meter --show-error --location --remote-name-all \ && curl --fail --silent --no-progress-meter --show-error --location --remote-name-all --parallel --parallel-max 4 \
https://github.com/paperless-ngx/builder/releases/download/jbig2enc-v${JBIG2ENC_VERSION}/jbig2enc_${JBIG2ENC_VERSION}-1_${TARGETARCH}.deb \ https://github.com/paperless-ngx/builder/releases/download/qpdf-${QPDF_VERSION}/libqpdf29_${QPDF_VERSION}-1_${TARGETARCH}.deb \
https://github.com/paperless-ngx/builder/releases/download/qpdf-${QPDF_VERSION}/qpdf_${QPDF_VERSION}-1_${TARGETARCH}.deb \
https://github.com/paperless-ngx/builder/releases/download/ghostscript-${GS_VERSION}/libgs10_${GS_VERSION}.dfsg-1_${TARGETARCH}.deb \
https://github.com/paperless-ngx/builder/releases/download/ghostscript-${GS_VERSION}/ghostscript_${GS_VERSION}.dfsg-1_${TARGETARCH}.deb \
https://github.com/paperless-ngx/builder/releases/download/ghostscript-${GS_VERSION}/libgs10-common_${GS_VERSION}.dfsg-1_all.deb \
https://github.com/paperless-ngx/builder/releases/download/jbig2enc-${JBIG2ENC_VERSION}/jbig2enc_${JBIG2ENC_VERSION}-1_${TARGETARCH}.deb \
&& echo "Installing qpdf ${QPDF_VERSION}" \
&& dpkg --install ./libqpdf29_${QPDF_VERSION}-1_${TARGETARCH}.deb \
&& dpkg --install ./qpdf_${QPDF_VERSION}-1_${TARGETARCH}.deb \
&& echo "Installing Ghostscript ${GS_VERSION}" \
&& dpkg --install ./libgs10-common_${GS_VERSION}.dfsg-1_all.deb \
&& dpkg --install ./libgs10_${GS_VERSION}.dfsg-1_${TARGETARCH}.deb \
&& dpkg --install ./ghostscript_${GS_VERSION}.dfsg-1_${TARGETARCH}.deb \
&& echo "Installing jbig2enc" \ && echo "Installing jbig2enc" \
&& dpkg --install ./jbig2enc_${JBIG2ENC_VERSION}-1_${TARGETARCH}.deb \ && dpkg --install ./jbig2enc_${JBIG2ENC_VERSION}-1_${TARGETARCH}.deb \
&& echo "Configuring imagemagick" \ && echo "Configuring imagemagick" \

319
dev.txt
View File

@@ -1,319 +0,0 @@
adduser 3.134
apt 2.6.1
base-files 12.4+deb12u11
base-passwd 3.6.1
bash 5.2.15-2+b8
bsdutils 1:2.38.1-5+deb12u3
ca-certificates 20230311+deb12u1
coreutils 9.1-1
curl 7.88.1-10+deb12u12
dash 0.5.12-2
debconf 1.5.82
debian-archive-keyring 2023.3+deb12u2
debianutils 5.7-0.5~deb12u1
diffutils 1:3.8-4
dirmngr 2.2.40-1.1
dpkg 1.21.22
e2fsprogs 1.47.0-2
file 1:5.44-3
findutils 4.9.0-4
fontconfig 2.14.1-4
fontconfig-config 2.14.1-4
fonts-liberation 1:1.07.4-11
fonts-urw-base35 20200910-7
gcc-12-base 12.2.0-14+deb12u1
gettext 0.21-12
gettext-base 0.21-12
ghostscript 10.03.1~dfsg-1
gnupg 2.2.40-1.1
gnupg-l10n 2.2.40-1.1
gnupg-utils 2.2.40-1.1
gosu 1.14-1+b10
gpg 2.2.40-1.1
gpg-agent 2.2.40-1.1
gpg-wks-client 2.2.40-1.1
gpg-wks-server 2.2.40-1.1
gpgconf 2.2.40-1.1
gpgsm 2.2.40-1.1
gpgv 2.2.40-1.1
grep 3.8-5
gzip 1.12-1
hicolor-icon-theme 0.17-2
hostname 3.23+nmu1
icc-profiles-free 2.0.1+dfsg-1.1
imagemagick 8:6.9.11.60+dfsg-1.6+deb12u3
imagemagick-6-common 8:6.9.11.60+dfsg-1.6+deb12u3
imagemagick-6.q16 8:6.9.11.60+dfsg-1.6+deb12u3
init-system-helpers 1.65.2
jbig2dec 0.19-3
jbig2enc 0.30-1
libacl1 2.3.1-3
libaom3 3.6.0-1+deb12u1
libapt-pkg6.0 2.6.1
libarchive13 3.6.2-1+deb12u2
libassuan0 2.5.5-5
libattr1 1:2.5.1-4
libaudit-common 1:3.0.9-1
libaudit1 1:3.0.9-1
libavahi-client3 0.8-10+deb12u1
libavahi-common-data 0.8-10+deb12u1
libavahi-common3 0.8-10+deb12u1
libavcodec59 7:5.1.6-0+deb12u1
libavformat59 7:5.1.6-0+deb12u1
libavutil57 7:5.1.6-0+deb12u1
libblkid1 2.38.1-5+deb12u3
libbluray2 1:1.3.4-1
libbrotli1 1.0.9-2+b6
libbsd0 0.11.7-2
libbz2-1.0 1.0.8-5+b1
libc-bin 2.36-9+deb12u10
libc6 2.36-9+deb12u10
libcairo-gobject2 1.16.0-7
libcairo2 1.16.0-7
libcap-ng0 0.8.3-1+b3
libcap2 1:2.66-4+deb12u1
libchromaprint1 1.5.1-2+b1
libcjson1 1.7.15-1+deb12u2
libcodec2-1.0 1.0.5-1
libcom-err2 1.47.0-2
libconfig-inifiles-perl 3.000003-2
libcrypt1 1:4.4.33-2
libcups2 2.4.2-3+deb12u8
libcurl4 7.88.1-10+deb12u12
libdatrie1 0.2.13-2+b1
libdav1d6 1.0.0-2+deb12u1
libdb5.3 5.3.28+dfsg2-1
libdbus-1-3 1.14.10-1~deb12u1
libde265-0 1.0.11-1+deb12u2
libdebconfclient0 0.270
libdeflate0 1.14-1
libdrm-common 2.4.114-1
libdrm2 2.4.114-1+b1
libedit2 3.1-20221030-2
libexpat1 2.5.0-1+deb12u1
libext2fs2 1.47.0-2
libffi8 3.4.4-1
libfftw3-double3 3.3.10-1
libfontconfig1 2.14.1-4
libfontenc1 1:1.1.4-1
libfreetype6 2.12.1+dfsg-5+deb12u4
libfribidi0 1.0.8-2.1
libgcc-s1 12.2.0-14+deb12u1
libgcrypt20 1.10.1-3
libgdbm-compat4 1.23-3
libgdbm6 1.23-3
libgdk-pixbuf-2.0-0 2.42.10+dfsg-1+deb12u2
libgdk-pixbuf2.0-common 2.42.10+dfsg-1+deb12u2
libgif7 5.2.1-2.5
libglib2.0-0 2.74.6-2+deb12u6
libgme0 0.6.3-6
libgmp10 2:6.2.1+dfsg1-1.1
libgnutls30 3.7.9-2+deb12u5
libgomp1 12.2.0-14+deb12u1
libgpg-error0 1.46-1
libgraphite2-3 1.3.14-1
libgs-common 10.0.0~dfsg-11+deb12u7
libgs10 10.03.1~dfsg-1
libgs10-common 10.03.1~dfsg-1
libgsm1 1.0.22-1
libgssapi-krb5-2 1.20.1-2+deb12u3
libharfbuzz0b 6.0.0+dfsg-3
libheif1 1.15.1-1+deb12u1
libhogweed6 3.8.1-2
libhwy1 1.0.3-3+deb12u1
libice6 2:1.0.10-1
libicu72 72.1-3+deb12u1
libidn12 1.41-1
libidn2-0 2.3.3-1+b1
libijs-0.35 0.35-15
libimagequant0 2.17.0-1
libjbig0 2.1-6.1
libjbig2dec0 0.19-3
libjpeg62-turbo 1:2.1.5-2
libjxl0.7 0.7.0-10+deb12u1
libk5crypto3 1.20.1-2+deb12u3
libkeyutils1 1.6.3-2
libkrb5-3 1.20.1-2+deb12u3
libkrb5support0 1.20.1-2+deb12u3
libksba8 1.6.3-2
liblcms2-2 2.14-2
libldap-2.5-0 2.5.13+dfsg-5
liblept5 1.82.0-3+b3
liblerc4 4.0.0+ds-2
liblqr-1-0 0.4.2-2.1
libltdl7 2.4.7-7~deb12u1
liblz4-1 1.9.4-1
liblzma5 5.4.1-1
libmagic-mgc 1:5.44-3
libmagic1 1:5.44-3
libmagickcore-6.q16-6 8:6.9.11.60+dfsg-1.6+deb12u3
libmagickwand-6.q16-6 8:6.9.11.60+dfsg-1.6+deb12u3
libmariadb3 1:10.11.11-0+deb12u1
libmbedcrypto7 2.28.3-1
libmd0 1.0.4-2
libmfx1 22.5.4-1
libmount1 2.38.1-5+deb12u3
libmp3lame0 3.100-6
libmpg123-0 1.31.2-1+deb12u1
libncurses6 6.4-4
libncursesw6 6.4-4
libnettle8 3.8.1-2
libnghttp2-14 1.52.0-1+deb12u2
libnorm1 1.5.9+dfsg-2
libnpth0 1.6-3
libnsl2 1.3.0-2
libnspr4 2:4.35-1
libnss3 2:3.87.1-1+deb12u1
libnuma1 2.0.16-1
libogg0 1.3.5-3
libopenjp2-7 2.5.0-2+deb12u1
libopenmpt0 0.6.9-1
libopus0 1.3.1-3
libp11-kit0 0.24.1-2
libpam-modules 1.5.2-6+deb12u1
libpam-modules-bin 1.5.2-6+deb12u1
libpam-runtime 1.5.2-6+deb12u1
libpam0g 1.5.2-6+deb12u1
libpango-1.0-0 1.50.12+ds-1
libpangocairo-1.0-0 1.50.12+ds-1
libpangoft2-1.0-0 1.50.12+ds-1
libpaper1 1.1.29
libpcre2-8-0 10.42-1
libperl5.36 5.36.0-7+deb12u2
libpgm-5.3-0 5.3.128~dfsg-2
libpixman-1-0 0.42.2-1
libpng16-16 1.6.39-2
libpoppler126 22.12.0-2+deb12u1
libpq5 15.13-0+deb12u1
libpsl5 0.21.2-1
libqpdf29 11.9.0-1
librabbitmq4 0.11.0-1+deb12u1
librav1e0 0.5.1-6
libreadline8 8.2-1.3
librist4 0.2.7+dfsg-1
librsvg2-2 2.54.7+dfsg-1~deb12u1
librtmp1 2.4+20151223.gitfa8646d.1-2+b2
libsasl2-2 2.1.28+dfsg-10
libsasl2-modules-db 2.1.28+dfsg-10
libseccomp2 2.5.4-1+deb12u1
libselinux1 3.4-1+b6
libsemanage-common 3.4-1
libsemanage2 3.4-1+b5
libsepol2 3.4-2.1
libshine3 3.1.1-2
libsm6 2:1.2.3-1
libsmartcols1 2.38.1-5+deb12u3
libsnappy1v5 1.1.9-3
libsodium23 1.0.18-1
libsoxr0 0.1.3-4
libspeex1 1.2.1-2
libsqlite3-0 3.40.1-2+deb12u1
libsrt1.5-gnutls 1.5.1-1+deb12u1
libss2 1.47.0-2
libssh-gcrypt-4 0.10.6-0+deb12u1
libssh2-1 1.10.0-3+b1
libssl3 3.0.17-1~deb12u1
libstdc++6 12.2.0-14+deb12u1
libsvtav1enc1 1.4.1+dfsg-1
libswresample4 7:5.1.6-0+deb12u1
libsystemd0 252.38-1~deb12u1
libtasn1-6 4.19.0-2+deb12u1
libtesseract5 5.3.0-2
libthai-data 0.1.29-1
libthai0 0.1.29-1
libtheora0 1.1.1+dfsg.1-16.1+b1
libtiff6 4.5.0-6+deb12u2
libtinfo6 6.4-4
libtirpc-common 1.3.3+ds-1
libtirpc3 1.3.3+ds-1
libtwolame0 0.4.0-2
libudev1 252.38-1~deb12u1
libudfread0 1.1.2-1
libunistring2 1.0-2
libuuid1 2.38.1-5+deb12u3
libv4l-0 1.22.1-5+b2
libv4lconvert0 1.22.1-5+b2
libva-drm2 2.17.0-1
libva-x11-2 2.17.0-1
libva2 2.17.0-1
libvdpau1 1.5-2
libvorbis0a 1.3.7-1
libvorbisenc2 1.3.7-1
libvorbisfile3 1.3.7-1
libvpx7 1.12.0-1+deb12u4
libwebp7 1.2.4-0.2+deb12u1
libwebpdemux2 1.2.4-0.2+deb12u1
libwebpmux3 1.2.4-0.2+deb12u1
libx11-6 2:1.8.4-2+deb12u2
libx11-data 2:1.8.4-2+deb12u2
libx11-xcb1 2:1.8.4-2+deb12u2
libx264-164 2:0.164.3095+gitbaee400-3
libx265-199 3.5-2+b1
libxau6 1:1.0.9-1
libxcb-dri3-0 1.15-1
libxcb-render0 1.15-1
libxcb-shm0 1.15-1
libxcb1 1.15-1
libxdmcp6 1:1.1.2-3
libxext6 2:1.3.4-1+b1
libxfixes3 1:6.0.0-2
libxml2 2.9.14+dfsg-1.3~deb12u2
libxrender1 1:0.9.10-1.1
libxslt1.1 1.1.35-1+deb12u1
libxt6 1:1.2.1-1.1
libxvidcore4 2:1.3.7-1
libxxhash0 0.8.1-1
libzbar0 0.23.92-7+deb12u1
libzmq5 4.3.4-6
libzstd1 1.5.4+dfsg2-5
libzvbi-common 0.2.41-1
libzvbi0 0.2.41-1
login 1:4.13+dfsg1-1+deb12u1
logsave 1.47.0-2
mariadb-client 1:10.11.11-0+deb12u1
mariadb-client-core 1:10.11.11-0+deb12u1
mariadb-common 1:10.11.11-0+deb12u1
mawk 1.3.4.20200120-3.1
media-types 10.0.0
mount 2.38.1-5+deb12u3
mysql-common 5.8+1.1.0
ncurses-base 6.4-4
ncurses-bin 6.4-4
netbase 6.4
ocl-icd-libopencl1 2.3.1-1
openssl 3.0.17-1~deb12u1
passwd 1:4.13+dfsg1-1+deb12u1
perl 5.36.0-7+deb12u2
perl-base 5.36.0-7+deb12u2
perl-modules-5.36 5.36.0-7+deb12u2
pinentry-curses 1.2.1-1
pngquant 2.17.0-1
poppler-data 0.4.12-1
poppler-utils 22.12.0-2+deb12u1
postgresql-client 15+248
postgresql-client-15 15.13-0+deb12u1
postgresql-client-common 248
qpdf 11.9.0-1
readline-common 8.2-1.3
sed 4.9-1
sensible-utils 0.0.17+nmu1
shared-mime-info 2.2-1
sysvinit-utils 3.06-4
tar 1.34+dfsg-1.2+deb12u1
tesseract-ocr 5.3.0-2
tesseract-ocr-deu 1:4.1.0-2
tesseract-ocr-eng 1:4.1.0-2
tesseract-ocr-fra 1:4.1.0-2
tesseract-ocr-ita 1:4.1.0-2
tesseract-ocr-osd 1:4.1.0-2
tesseract-ocr-spa 1:4.1.0-2
tzdata 2025b-0+deb12u1
ucf 3.0043+nmu1+deb12u1
unpaper 7.0.0-0.1
usr-is-merged 37~deb12u1
util-linux 2.38.1-5+deb12u3
util-linux-extra 2.38.1-5+deb12u3
x11-common 1:7.7+23
xfonts-encodings 1:1.0.4-2.2
xfonts-utils 1:7.7+6
zlib1g 1:1.2.13.dfsg-1

View File

@@ -462,15 +462,24 @@ flowchart TD
Workflows allow you to filter by: Workflows allow you to filter by:
- Source, e.g. documents uploaded via consume folder, API (& the web UI) and mail fetch - Source, e.g. documents uploaded via consume folder, API (& the web UI) and mail fetch
- File name, including wildcards e.g. \*.pdf will apply to all pdfs - File name, including wildcards e.g. \*.pdf will apply to all pdfs.
- File path, including wildcards. Note that enabling `PAPERLESS_CONSUMER_RECURSIVE` would allow, for - File path, including wildcards. Note that enabling `PAPERLESS_CONSUMER_RECURSIVE` would allow, for
example, automatically assigning documents to different owners based on the upload directory. example, automatically assigning documents to different owners based on the upload directory.
- Mail rule. Choosing this option will force 'mail fetch' to be the workflow source. - Mail rule. Choosing this option will force 'mail fetch' to be the workflow source.
- Content matching (`Added`, `Updated` and `Scheduled` triggers only). Filter document content using the matching settings. - Content matching (`Added`, `Updated` and `Scheduled` triggers only). Filter document content using the matching settings.
- Tags (`Added`, `Updated` and `Scheduled` triggers only). Filter for documents with any of the specified tags
- Document type (`Added`, `Updated` and `Scheduled` triggers only). Filter documents with this doc type There are also 'advanced' filters available for `Added`, `Updated` and `Scheduled` triggers:
- Correspondent (`Added`, `Updated` and `Scheduled` triggers only). Filter documents with this correspondent
- Storage path (`Added`, `Updated` and `Scheduled` triggers only). Filter documents with this storage path - Any Tags: Filter for documents with any of the specified tags.
- All Tags: Filter for documents with all of the specified tags.
- No Tags: Filter for documents with none of the specified tags.
- Document type: Filter documents with this document type.
- Not Document types: Filter documents without any of these document types.
- Correspondent: Filter documents with this correspondent.
- Not Correspondents: Filter documents without any of these correspondents.
- Storage path: Filter documents with this storage path.
- Not Storage paths: Filter documents without any of these storage paths.
- Custom field query: Filter documents with a custom field query (the same as used for the document list filters).
### Workflow Actions ### Workflow Actions

View File

@@ -1,28 +1,36 @@
<div class="btn-group w-100" role="group" ngbDropdown #dropdown="ngbDropdown" (openChange)="onOpenChange($event)" [popperOptions]="popperOptions"> @if (useDropdown) {
<button class="btn btn-sm btn-outline-primary" id="dropdown_toggle" ngbDropdownToggle [disabled]="disabled"> <div class="btn-group w-100" role="group" ngbDropdown #dropdown="ngbDropdown" (openChange)="onOpenChange($event)" [popperOptions]="popperOptions">
<i-bs name="{{icon}}"></i-bs> <button class="btn btn-sm btn-outline-primary" id="dropdown_toggle" ngbDropdownToggle [disabled]="disabled">
<div class="d-none d-sm-inline">&nbsp;{{title}}</div> <i-bs name="{{icon}}"></i-bs>
@if (isActive) { <div class="d-none d-sm-inline">&nbsp;{{title}}</div>
<pngx-clearable-badge [selected]="isActive" (cleared)="reset()"></pngx-clearable-badge> @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>
} }
</button>
<div class="px-3 shadow" ngbDropdownMenu attr.aria-labelledby="dropdown_{{name}}">
<ng-container *ngTemplateOutlet="list; context: { queries: selectionModel.queries }"></ng-container>
</div> </div>
</div> </div>
</div> } @else {
<ng-container *ngTemplateOutlet="list; context: { queries: selectionModel.queries }"></ng-container>
}
<ng-template #list let-queries="queries">
<div class="list-group list-group-flush">
@for (element of 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>
</ng-template>
<ng-template #comparisonValueTemplate let-atom="atom"> <ng-template #comparisonValueTemplate let-atom="atom">
@if (getCustomFieldByID(atom.field)?.data_type === CustomFieldDataType.Date) { @if (getCustomFieldByID(atom.field)?.data_type === CustomFieldDataType.Date) {

View File

@@ -120,6 +120,12 @@ export class CustomFieldQueriesModel {
}) })
} }
addInitialAtom() {
this.addAtom(
new CustomFieldQueryAtom([null, CustomFieldQueryOperator.Exists, 'true'])
)
}
private findElement( private findElement(
queryElement: CustomFieldQueryElement, queryElement: CustomFieldQueryElement,
elements: any[] elements: any[]
@@ -206,6 +212,9 @@ export class CustomFieldsQueryDropdownComponent extends LoadingComponentWithPerm
@Input() @Input()
applyOnClose = false applyOnClose = false
@Input()
useDropdown: boolean = true
get name(): string { get name(): string {
return this.title ? this.title.replace(/\s/g, '_').toLowerCase() : null return this.title ? this.title.replace(/\s/g, '_').toLowerCase() : null
} }
@@ -258,13 +267,7 @@ export class CustomFieldsQueryDropdownComponent extends LoadingComponentWithPerm
public onOpenChange(open: boolean) { public onOpenChange(open: boolean) {
if (open) { if (open) {
if (this.selectionModel.queries.length === 0) { if (this.selectionModel.queries.length === 0) {
this.selectionModel.addAtom( this.selectionModel.addInitialAtom()
new CustomFieldQueryAtom([
null,
CustomFieldQueryOperator.Exists,
'true',
])
)
} }
if ( if (
this.selectionModel.queries.length === 1 && this.selectionModel.queries.length === 1 &&

View File

@@ -156,31 +156,97 @@
<p class="small" i18n>Trigger for documents that match <em>all</em> filters specified below.</p> <p class="small" i18n>Trigger for documents that match <em>all</em> filters specified below.</p>
<div class="row"> <div class="row">
<div class="col"> <div class="col">
<pngx-input-text i18n-title title="Filter filename" formControlName="filter_filename" i18n-hint hint="Apply to documents that match this filename. Wildcards such as *.pdf or *invoice* are allowed. Case insensitive." [error]="error?.filter_filename"></pngx-input-text> <pngx-input-text i18n-title title="Filter filename" formControlName="filter_filename" horizontal="true" i18n-hint hint="Apply to documents that match this filename. Wildcards such as *.pdf or *invoice* are allowed. Case insensitive." [error]="error?.filter_filename"></pngx-input-text>
@if (formGroup.get('type').value === WorkflowTriggerType.Consumption) { @if (formGroup.get('type').value === WorkflowTriggerType.Consumption) {
<pngx-input-select i18n-title title="Filter sources" [items]="sourceOptions" [multiple]="true" formControlName="sources" [error]="error?.sources"></pngx-input-select> <pngx-input-select i18n-title title="Filter sources" [items]="sourceOptions" horizontal="true" [multiple]="true" formControlName="sources" [error]="error?.sources"></pngx-input-select>
<pngx-input-text i18n-title title="Filter path" formControlName="filter_path" i18n-hint hint="Apply to documents that match this path. Wildcards specified as * are allowed. Case-normalized.</a>" [error]="error?.filter_path"></pngx-input-text> <pngx-input-text i18n-title title="Filter path" formControlName="filter_path" horizontal="true" i18n-hint hint="Apply to documents that match this path. Wildcards specified as * are allowed. Case-normalized.</a>" [error]="error?.filter_path"></pngx-input-text>
<pngx-input-select i18n-title title="Filter mail rule" [items]="mailRules" [allowNull]="true" formControlName="filter_mailrule" i18n-hint hint="Apply to documents consumed via this mail rule." [error]="error?.filter_mailrule"></pngx-input-select> <pngx-input-select i18n-title title="Filter mail rule" [items]="mailRules" horizontal="true" [allowNull]="true" formControlName="filter_mailrule" i18n-hint hint="Apply to documents consumed via this mail rule." [error]="error?.filter_mailrule"></pngx-input-select>
} }
@if (formGroup.get('type').value === WorkflowTriggerType.DocumentAdded || formGroup.get('type').value === WorkflowTriggerType.DocumentUpdated || formGroup.get('type').value === WorkflowTriggerType.Scheduled) { @if (formGroup.get('type').value === WorkflowTriggerType.DocumentAdded || formGroup.get('type').value === WorkflowTriggerType.DocumentUpdated || formGroup.get('type').value === WorkflowTriggerType.Scheduled) {
<pngx-input-select i18n-title title="Content matching algorithm" [items]="getMatchingAlgorithms()" formControlName="matching_algorithm"></pngx-input-select> <pngx-input-select i18n-title title="Content matching algorithm" horizontal="true" [items]="getMatchingAlgorithms()" formControlName="matching_algorithm"></pngx-input-select>
@if (patternRequired) { @if (matchingPatternRequired(formGroup)) {
<pngx-input-text i18n-title title="Content matching pattern" formControlName="match" [error]="error?.match"></pngx-input-text> <pngx-input-text i18n-title title="Content matching pattern" horizontal="true" formControlName="match" [error]="error?.match"></pngx-input-text>
} }
@if (patternRequired) { @if (matchingPatternRequired(formGroup)) {
<pngx-input-check i18n-title title="Case insensitive" formControlName="is_insensitive"></pngx-input-check> <pngx-input-check i18n-title title="Case insensitive" horizontal="true" formControlName="is_insensitive"></pngx-input-check>
} }
} }
</div> </div>
@if (formGroup.get('type').value === WorkflowTriggerType.DocumentAdded || formGroup.get('type').value === WorkflowTriggerType.DocumentUpdated || formGroup.get('type').value === WorkflowTriggerType.Scheduled) {
<div class="col-md-6">
<pngx-input-tags [allowCreate]="false" i18n-title title="Has any of tags" formControlName="filter_has_tags"></pngx-input-tags>
<pngx-input-select i18n-title title="Has correspondent" [items]="correspondents" [allowNull]="true" formControlName="filter_has_correspondent"></pngx-input-select>
<pngx-input-select i18n-title title="Has document type" [items]="documentTypes" [allowNull]="true" formControlName="filter_has_document_type"></pngx-input-select>
<pngx-input-select i18n-title title="Has storage path" [items]="storagePaths" [allowNull]="true" formControlName="filter_has_storage_path"></pngx-input-select>
</div>
}
</div> </div>
@if (formGroup.get('type').value === WorkflowTriggerType.DocumentAdded || formGroup.get('type').value === WorkflowTriggerType.DocumentUpdated || formGroup.get('type').value === WorkflowTriggerType.Scheduled) {
<div class="row mt-3">
<div class="col">
<div class="trigger-filters mb-3">
<div class="d-flex align-items-center">
<label class="form-label mb-0" i18n>Advanced Filters</label>
<button
type="button"
class="btn btn-sm btn-outline-primary ms-auto"
(click)="addFilter(formGroup)"
[disabled]="!canAddFilter(formGroup)"
>
<i-bs name="plus-circle"></i-bs>&nbsp;<span i18n>Add filter</span>
</button>
</div>
<ul class="mt-2 list-group filters" formArrayName="filters">
@if (getFiltersFormArray(formGroup).length === 0) {
<p class="text-muted small" i18n>No advanced workflow filters defined.</p>
}
@for (filter of getFiltersFormArray(formGroup).controls; track filter; let filterIndex = $index) {
<li [formGroupName]="filterIndex" class="list-group-item">
<div class="d-flex align-items-center gap-2">
<div class="w-25">
<pngx-input-select
i18n-title
[items]="getFilterTypeOptions(formGroup, filterIndex)"
formControlName="type"
[allowNull]="false"
></pngx-input-select>
</div>
<div class="flex-grow-1">
@if (isTagsFilter(filter.get('type').value)) {
<pngx-input-tags
[allowCreate]="false"
[title]="null"
formControlName="values"
></pngx-input-tags>
} @else if (
isCustomFieldQueryFilter(filter.get('type').value)
) {
<pngx-custom-fields-query-dropdown
[selectionModel]="getCustomFieldQueryModel(filter)"
(selectionModelChange)="onCustomFieldQuerySelectionChange(filter, $event)"
[useDropdown]="false"
></pngx-custom-fields-query-dropdown>
@if (!isCustomFieldQueryValid(filter)) {
<div class="text-danger small" i18n>
Complete the custom field query configuration.
</div>
}
} @else {
<pngx-input-select
[items]="getFilterSelectItems(filter.get('type').value)"
[allowNull]="true"
[multiple]="isSelectMultiple(filter.get('type').value)"
formControlName="values"
></pngx-input-select>
}
</div>
<button
type="button"
class="btn btn-link text-danger p-0"
(click)="removeFilter(formGroup, filterIndex)"
>
<i-bs name="trash"></i-bs><span class="ms-1" i18n>Delete</span>
</button>
</div>
</li>
}
</ul>
</div>
</div>
</div>
}
</div> </div>
</ng-template> </ng-template>

View File

@@ -7,3 +7,7 @@
.accordion-button { .accordion-button {
font-size: 1rem; font-size: 1rem;
} }
:host ::ng-deep .filters .paperless-input-select.mb-3 {
margin-bottom: 0 !important;
}

View File

@@ -11,8 +11,14 @@ import {
import { NgbActiveModal, NgbModule } from '@ng-bootstrap/ng-bootstrap' import { NgbActiveModal, NgbModule } from '@ng-bootstrap/ng-bootstrap'
import { NgSelectModule } from '@ng-select/ng-select' import { NgSelectModule } from '@ng-select/ng-select'
import { of } from 'rxjs' import { of } from 'rxjs'
import { CustomFieldQueriesModel } from 'src/app/components/common/custom-fields-query-dropdown/custom-fields-query-dropdown.component'
import { CustomFieldDataType } from 'src/app/data/custom-field' import { CustomFieldDataType } from 'src/app/data/custom-field'
import { MATCHING_ALGORITHMS, MATCH_AUTO } from 'src/app/data/matching-model' import { CustomFieldQueryLogicalOperator } from 'src/app/data/custom-field-query'
import {
MATCHING_ALGORITHMS,
MATCH_AUTO,
MATCH_NONE,
} from 'src/app/data/matching-model'
import { Workflow } from 'src/app/data/workflow' import { Workflow } from 'src/app/data/workflow'
import { import {
WorkflowAction, WorkflowAction,
@@ -31,6 +37,7 @@ import { DocumentTypeService } from 'src/app/services/rest/document-type.service
import { MailRuleService } from 'src/app/services/rest/mail-rule.service' import { MailRuleService } from 'src/app/services/rest/mail-rule.service'
import { StoragePathService } from 'src/app/services/rest/storage-path.service' import { StoragePathService } from 'src/app/services/rest/storage-path.service'
import { SettingsService } from 'src/app/services/settings.service' import { SettingsService } from 'src/app/services/settings.service'
import { CustomFieldQueryExpression } from 'src/app/utils/custom-field-query-element'
import { ConfirmButtonComponent } from '../../confirm-button/confirm-button.component' import { ConfirmButtonComponent } from '../../confirm-button/confirm-button.component'
import { NumberComponent } from '../../input/number/number.component' import { NumberComponent } from '../../input/number/number.component'
import { PermissionsGroupComponent } from '../../input/permissions/permissions-group/permissions-group.component' import { PermissionsGroupComponent } from '../../input/permissions/permissions-group/permissions-group.component'
@@ -43,6 +50,7 @@ import { EditDialogMode } from '../edit-dialog.component'
import { import {
DOCUMENT_SOURCE_OPTIONS, DOCUMENT_SOURCE_OPTIONS,
SCHEDULE_DATE_FIELD_OPTIONS, SCHEDULE_DATE_FIELD_OPTIONS,
TriggerFilterType,
WORKFLOW_ACTION_OPTIONS, WORKFLOW_ACTION_OPTIONS,
WORKFLOW_TYPE_OPTIONS, WORKFLOW_TYPE_OPTIONS,
WorkflowEditDialogComponent, WorkflowEditDialogComponent,
@@ -375,6 +383,562 @@ describe('WorkflowEditDialogComponent', () => {
expect(component.objectForm.get('actions').value[0].webhook).toBeNull() expect(component.objectForm.get('actions').value[0].webhook).toBeNull()
}) })
it('should require matching pattern when algorithm is not none', () => {
const triggerGroup = new FormGroup({
matching_algorithm: new FormControl(MATCH_AUTO),
match: new FormControl(''),
})
expect(component.matchingPatternRequired(triggerGroup)).toBe(true)
triggerGroup.get('matching_algorithm').setValue(MATCHING_ALGORITHMS[0].id)
expect(component.matchingPatternRequired(triggerGroup)).toBe(true)
triggerGroup.get('matching_algorithm').setValue(MATCH_NONE)
expect(component.matchingPatternRequired(triggerGroup)).toBe(false)
})
it('should map filter builder values into trigger filters on save', () => {
component.object = undefined
component.addTrigger()
const triggerGroup = component.triggerFields.at(0)
component.addFilter(triggerGroup as FormGroup)
component.addFilter(triggerGroup as FormGroup)
component.addFilter(triggerGroup as FormGroup)
const filters = component.getFiltersFormArray(triggerGroup as FormGroup)
expect(filters.length).toBe(3)
filters.at(0).get('values').setValue([1])
filters.at(1).get('values').setValue([2, 3])
filters.at(2).get('values').setValue([4])
const addFilterOfType = (type: TriggerFilterType) => {
const newFilter = component.addFilter(triggerGroup as FormGroup)
newFilter.get('type').setValue(type)
return newFilter
}
const correspondentIs = addFilterOfType(TriggerFilterType.CorrespondentIs)
correspondentIs.get('values').setValue(1)
const correspondentNot = addFilterOfType(TriggerFilterType.CorrespondentNot)
correspondentNot.get('values').setValue([1])
const documentTypeIs = addFilterOfType(TriggerFilterType.DocumentTypeIs)
documentTypeIs.get('values').setValue(1)
const documentTypeNot = addFilterOfType(TriggerFilterType.DocumentTypeNot)
documentTypeNot.get('values').setValue([1])
const storagePathIs = addFilterOfType(TriggerFilterType.StoragePathIs)
storagePathIs.get('values').setValue(1)
const storagePathNot = addFilterOfType(TriggerFilterType.StoragePathNot)
storagePathNot.get('values').setValue([1])
const customFieldFilter = addFilterOfType(
TriggerFilterType.CustomFieldQuery
)
const customFieldQuery = JSON.stringify(['AND', [[1, 'exact', 'test']]])
customFieldFilter.get('values').setValue(customFieldQuery)
const formValues = component['getFormValues']()
expect(formValues.triggers[0].filter_has_tags).toEqual([1])
expect(formValues.triggers[0].filter_has_all_tags).toEqual([2, 3])
expect(formValues.triggers[0].filter_has_not_tags).toEqual([4])
expect(formValues.triggers[0].filter_has_correspondent).toEqual(1)
expect(formValues.triggers[0].filter_has_not_correspondents).toEqual([1])
expect(formValues.triggers[0].filter_has_document_type).toEqual(1)
expect(formValues.triggers[0].filter_has_not_document_types).toEqual([1])
expect(formValues.triggers[0].filter_has_storage_path).toEqual(1)
expect(formValues.triggers[0].filter_has_not_storage_paths).toEqual([1])
expect(formValues.triggers[0].filter_custom_field_query).toEqual(
customFieldQuery
)
expect(formValues.triggers[0].filters).toBeUndefined()
})
it('should ignore empty and null filter values when mapping filters', () => {
component.object = undefined
component.addTrigger()
const triggerGroup = component.triggerFields.at(0) as FormGroup
const tagsFilter = component.addFilter(triggerGroup)
tagsFilter.get('type').setValue(TriggerFilterType.TagsAny)
tagsFilter.get('values').setValue([])
const correspondentFilter = component.addFilter(triggerGroup)
correspondentFilter.get('type').setValue(TriggerFilterType.CorrespondentIs)
correspondentFilter.get('values').setValue(null)
const formValues = component['getFormValues']()
expect(formValues.triggers[0].filter_has_tags).toEqual([])
expect(formValues.triggers[0].filter_has_correspondent).toBeNull()
})
it('should derive single select filters from array values', () => {
component.object = undefined
component.addTrigger()
const triggerGroup = component.triggerFields.at(0) as FormGroup
const addFilterOfType = (type: TriggerFilterType, value: any) => {
const filter = component.addFilter(triggerGroup)
filter.get('type').setValue(type)
filter.get('values').setValue(value)
}
addFilterOfType(TriggerFilterType.CorrespondentIs, [5])
addFilterOfType(TriggerFilterType.DocumentTypeIs, [6])
addFilterOfType(TriggerFilterType.StoragePathIs, [7])
const formValues = component['getFormValues']()
expect(formValues.triggers[0].filter_has_correspondent).toEqual(5)
expect(formValues.triggers[0].filter_has_document_type).toEqual(6)
expect(formValues.triggers[0].filter_has_storage_path).toEqual(7)
})
it('should convert multi-value filter values when aggregating filters', () => {
component.object = undefined
component.addTrigger()
const triggerGroup = component.triggerFields.at(0) as FormGroup
const setFilter = (type: TriggerFilterType, value: number): void => {
const filter = component.addFilter(triggerGroup) as FormGroup
filter.get('type').setValue(type)
filter.get('values').setValue(value)
}
setFilter(TriggerFilterType.TagsAll, 11)
setFilter(TriggerFilterType.TagsNone, 12)
setFilter(TriggerFilterType.CorrespondentNot, 13)
setFilter(TriggerFilterType.DocumentTypeNot, 14)
setFilter(TriggerFilterType.StoragePathNot, 15)
const formValues = component['getFormValues']()
expect(formValues.triggers[0].filter_has_all_tags).toEqual([11])
expect(formValues.triggers[0].filter_has_not_tags).toEqual([12])
expect(formValues.triggers[0].filter_has_not_correspondents).toEqual([13])
expect(formValues.triggers[0].filter_has_not_document_types).toEqual([14])
expect(formValues.triggers[0].filter_has_not_storage_paths).toEqual([15])
})
it('should reuse filter type options and update disabled state', () => {
component.object = undefined
component.addTrigger()
const triggerGroup = component.triggerFields.at(0) as FormGroup
component.addFilter(triggerGroup)
const optionsFirst = component.getFilterTypeOptions(triggerGroup, 0)
const optionsSecond = component.getFilterTypeOptions(triggerGroup, 0)
expect(optionsFirst).toBe(optionsSecond)
// to force disabled flag
component.addFilter(triggerGroup)
const filterArray = component.getFiltersFormArray(triggerGroup)
const firstFilter = filterArray.at(0)
firstFilter.get('type').setValue(TriggerFilterType.CorrespondentIs)
component.addFilter(triggerGroup)
const updatedFilters = component.getFiltersFormArray(triggerGroup)
const secondFilter = updatedFilters.at(1)
const options = component.getFilterTypeOptions(triggerGroup, 1)
const correspondentIsOption = options.find(
(option) => option.id === TriggerFilterType.CorrespondentIs
)
expect(correspondentIsOption.disabled).toBe(true)
firstFilter.get('type').setValue(TriggerFilterType.DocumentTypeNot)
secondFilter.get('type').setValue(TriggerFilterType.TagsAll)
const postChangeOptions = component.getFilterTypeOptions(triggerGroup, 1)
const correspondentOptionAfter = postChangeOptions.find(
(option) => option.id === TriggerFilterType.CorrespondentIs
)
expect(correspondentOptionAfter.disabled).toBe(false)
})
it('should keep multi-entry filter options enabled and allow duplicates', () => {
component.object = undefined
component.addTrigger()
const triggerGroup = component.triggerFields.at(0) as FormGroup
component.filterDefinitions = [
{
id: TriggerFilterType.TagsAny,
name: 'Any tags',
inputType: 'tags',
allowMultipleEntries: true,
allowMultipleValues: true,
} as any,
{
id: TriggerFilterType.CorrespondentIs,
name: 'Correspondent is',
inputType: 'select',
allowMultipleEntries: false,
allowMultipleValues: false,
selectItems: 'correspondents',
} as any,
]
const firstFilter = component.addFilter(triggerGroup)
firstFilter.get('type').setValue(TriggerFilterType.TagsAny)
const secondFilter = component.addFilter(triggerGroup)
expect(secondFilter).not.toBeNull()
const options = component.getFilterTypeOptions(triggerGroup, 1)
const multiEntryOption = options.find(
(option) => option.id === TriggerFilterType.TagsAny
)
expect(multiEntryOption.disabled).toBe(false)
expect(component.canAddFilter(triggerGroup)).toBe(true)
})
it('should return null when no filter definitions remain available', () => {
component.object = undefined
component.addTrigger()
const triggerGroup = component.triggerFields.at(0) as FormGroup
component.filterDefinitions = [
{
id: TriggerFilterType.TagsAny,
name: 'Any tags',
inputType: 'tags',
allowMultipleEntries: false,
allowMultipleValues: true,
} as any,
{
id: TriggerFilterType.CorrespondentIs,
name: 'Correspondent is',
inputType: 'select',
allowMultipleEntries: false,
allowMultipleValues: false,
selectItems: 'correspondents',
} as any,
]
const firstFilter = component.addFilter(triggerGroup)
firstFilter.get('type').setValue(TriggerFilterType.TagsAny)
const secondFilter = component.addFilter(triggerGroup)
secondFilter.get('type').setValue(TriggerFilterType.CorrespondentIs)
expect(component.canAddFilter(triggerGroup)).toBe(false)
expect(component.addFilter(triggerGroup)).toBeNull()
})
it('should skip filter definitions without handlers when building form array', () => {
const originalDefinitions = component.filterDefinitions
component.filterDefinitions = [
{
id: 999,
name: 'Unsupported',
inputType: 'text',
allowMultipleEntries: false,
allowMultipleValues: false,
} as any,
]
const trigger = {
filter_has_tags: [],
filter_has_all_tags: [],
filter_has_not_tags: [],
filter_has_not_correspondents: [],
filter_has_not_document_types: [],
filter_has_not_storage_paths: [],
filter_has_correspondent: null,
filter_has_document_type: null,
filter_has_storage_path: null,
filter_custom_field_query: null,
} as any
const filters = component['buildFiltersFormArray'](trigger)
expect(filters.length).toBe(0)
component.filterDefinitions = originalDefinitions
})
it('should return null when adding filter for unknown trigger form group', () => {
expect(component.addFilter(new FormGroup({}) as any)).toBeNull()
})
it('should ignore remove filter calls for unknown trigger form group', () => {
expect(() =>
component.removeFilter(new FormGroup({}) as any, 0)
).not.toThrow()
})
it('should teardown custom field query model when removing a custom field filter', () => {
component.object = undefined
component.addTrigger()
const triggerGroup = component.triggerFields.at(0) as FormGroup
component.addFilter(triggerGroup)
const filters = component.getFiltersFormArray(triggerGroup)
const filterGroup = filters.at(0) as FormGroup
filterGroup.get('type').setValue(TriggerFilterType.CustomFieldQuery)
const model = component.getCustomFieldQueryModel(filterGroup)
expect(model).toBeDefined()
expect(
component['getStoredCustomFieldQueryModel'](filterGroup as any)
).toBe(model)
component.removeFilter(triggerGroup, 0)
expect(
component['getStoredCustomFieldQueryModel'](filterGroup as any)
).toBeNull()
})
it('should return readable filter names', () => {
expect(component.getFilterName(TriggerFilterType.TagsAny)).toBe(
'Has any of these tags'
)
expect(component.getFilterName(999 as any)).toBe('')
})
it('should build filter form array from existing trigger filters', () => {
const trigger = workflow.triggers[0]
trigger.filter_has_tags = [1]
trigger.filter_has_all_tags = [2, 3]
trigger.filter_has_not_tags = [4]
trigger.filter_has_correspondent = 5 as any
trigger.filter_has_not_correspondents = [6] as any
trigger.filter_has_document_type = 7 as any
trigger.filter_has_not_document_types = [8] as any
trigger.filter_has_storage_path = 9 as any
trigger.filter_has_not_storage_paths = [10] as any
trigger.filter_custom_field_query = JSON.stringify([
'AND',
[[1, 'exact', 'value']],
]) as any
component.object = workflow
component.ngOnInit()
const triggerGroup = component.triggerFields.at(0) as FormGroup
const filters = component.getFiltersFormArray(triggerGroup)
expect(filters.length).toBe(10)
const customFieldFilter = filters.at(9) as FormGroup
expect(customFieldFilter.get('type').value).toBe(
TriggerFilterType.CustomFieldQuery
)
const model = component.getCustomFieldQueryModel(customFieldFilter)
expect(model.isValid()).toBe(true)
})
it('should expose select metadata helpers', () => {
expect(component.isSelectMultiple(TriggerFilterType.CorrespondentNot)).toBe(
true
)
expect(component.isSelectMultiple(TriggerFilterType.CorrespondentIs)).toBe(
false
)
component.correspondents = [{ id: 1, name: 'C1' } as any]
component.documentTypes = [{ id: 2, name: 'DT' } as any]
component.storagePaths = [{ id: 3, name: 'SP' } as any]
expect(
component.getFilterSelectItems(TriggerFilterType.CorrespondentIs)
).toEqual(component.correspondents)
expect(
component.getFilterSelectItems(TriggerFilterType.DocumentTypeIs)
).toEqual(component.documentTypes)
expect(
component.getFilterSelectItems(TriggerFilterType.StoragePathIs)
).toEqual(component.storagePaths)
expect(component.getFilterSelectItems(TriggerFilterType.TagsAll)).toEqual(
[]
)
expect(
component.isCustomFieldQueryFilter(TriggerFilterType.CustomFieldQuery)
).toBe(true)
})
it('should return empty select items when definition is missing', () => {
const originalDefinitions = component.filterDefinitions
component.filterDefinitions = []
expect(
component.getFilterSelectItems(TriggerFilterType.CorrespondentIs)
).toEqual([])
component.filterDefinitions = originalDefinitions
})
it('should return empty select items when definition has unknown source', () => {
const originalDefinitions = component.filterDefinitions
component.filterDefinitions = [
{
id: TriggerFilterType.CorrespondentIs,
name: 'Correspondent is',
inputType: 'select',
allowMultipleEntries: false,
allowMultipleValues: false,
selectItems: 'unknown',
} as any,
]
expect(
component.getFilterSelectItems(TriggerFilterType.CorrespondentIs)
).toEqual([])
component.filterDefinitions = originalDefinitions
})
it('should handle custom field query selection change and validation states', () => {
const formGroup = new FormGroup({
values: new FormControl(null),
})
const model = new CustomFieldQueriesModel()
const changeSpy = jest.spyOn(
component as any,
'onCustomFieldQueryModelChanged'
)
component.onCustomFieldQuerySelectionChange(formGroup, model)
expect(changeSpy).toHaveBeenCalledWith(formGroup, model)
expect(component.isCustomFieldQueryValid(formGroup)).toBe(true)
component['setCustomFieldQueryModel'](formGroup as any, model as any)
const validSpy = jest.spyOn(model, 'isValid').mockReturnValue(false)
const emptySpy = jest.spyOn(model, 'isEmpty').mockReturnValue(false)
expect(component.isCustomFieldQueryValid(formGroup)).toBe(false)
expect(validSpy).toHaveBeenCalled()
validSpy.mockReturnValue(true)
emptySpy.mockReturnValue(true)
expect(component.isCustomFieldQueryValid(formGroup)).toBe(true)
emptySpy.mockReturnValue(false)
expect(component.isCustomFieldQueryValid(formGroup)).toBe(true)
component['clearCustomFieldQueryModel'](formGroup as any)
})
it('should recover from invalid custom field query json and update control on changes', () => {
const filterGroup = new FormGroup({
values: new FormControl('not-json'),
})
component['ensureCustomFieldQueryModel'](filterGroup, 'not-json')
const model = component['getStoredCustomFieldQueryModel'](
filterGroup as any
)
expect(model).toBeDefined()
expect(model.queries.length).toBeGreaterThan(0)
const valuesControl = filterGroup.get('values')
expect(valuesControl.value).toBeNull()
const expression = new CustomFieldQueryExpression([
CustomFieldQueryLogicalOperator.And,
[[1, 'exact', 'value']],
])
model.queries = [expression]
jest.spyOn(model, 'isValid').mockReturnValue(true)
jest.spyOn(model, 'isEmpty').mockReturnValue(false)
model.changed.next(model)
expect(valuesControl.value).toEqual(JSON.stringify(expression.serialize()))
component['clearCustomFieldQueryModel'](filterGroup as any)
})
it('should handle custom field query model change edge cases', () => {
const groupWithoutControl = new FormGroup({})
const dummyModel = {
isValid: jest.fn().mockReturnValue(true),
isEmpty: jest.fn().mockReturnValue(false),
}
expect(() =>
component['onCustomFieldQueryModelChanged'](
groupWithoutControl as any,
dummyModel as any
)
).not.toThrow()
const groupWithControl = new FormGroup({
values: new FormControl('initial'),
})
const emptyModel = {
isValid: jest.fn().mockReturnValue(true),
isEmpty: jest.fn().mockReturnValue(true),
}
component['onCustomFieldQueryModelChanged'](
groupWithControl as any,
emptyModel as any
)
expect(groupWithControl.get('values').value).toBeNull()
})
it('should normalize filter values for single and multi selects', () => {
expect(
component['normalizeFilterValue'](TriggerFilterType.TagsAny)
).toEqual([])
expect(
component['normalizeFilterValue'](TriggerFilterType.TagsAny, 5)
).toEqual([5])
expect(
component['normalizeFilterValue'](TriggerFilterType.TagsAny, [5, 6])
).toEqual([5, 6])
expect(
component['normalizeFilterValue'](TriggerFilterType.CorrespondentIs, [7])
).toEqual(7)
expect(
component['normalizeFilterValue'](TriggerFilterType.CorrespondentIs, 8)
).toEqual(8)
const customFieldJson = JSON.stringify(['AND', [[1, 'exact', 'test']]])
expect(
component['normalizeFilterValue'](
TriggerFilterType.CustomFieldQuery,
customFieldJson
)
).toEqual(customFieldJson)
const customFieldObject = ['AND', [[1, 'exact', 'other']]]
expect(
component['normalizeFilterValue'](
TriggerFilterType.CustomFieldQuery,
customFieldObject
)
).toEqual(JSON.stringify(customFieldObject))
expect(
component['normalizeFilterValue'](
TriggerFilterType.CustomFieldQuery,
false
)
).toBeNull()
})
it('should add and remove filter form groups', () => {
component['changeDetector'] = { detectChanges: jest.fn() } as any
component.object = undefined
component.addTrigger()
const triggerGroup = component.triggerFields.at(0) as FormGroup
component.addFilter(triggerGroup)
component.removeFilter(triggerGroup, 0)
expect(component.getFiltersFormArray(triggerGroup).length).toBe(0)
component.addFilter(triggerGroup)
const filterArrayAfterAdd = component.getFiltersFormArray(triggerGroup)
filterArrayAfterAdd.at(0).get('type').setValue(TriggerFilterType.TagsAll)
expect(component.getFiltersFormArray(triggerGroup).length).toBe(1)
})
it('should remove selected custom field from the form group', () => { it('should remove selected custom field from the form group', () => {
const formGroup = new FormGroup({ const formGroup = new FormGroup({
assign_custom_fields: new FormControl([1, 2, 3]), assign_custom_fields: new FormControl([1, 2, 3]),

View File

@@ -6,6 +6,7 @@ import {
import { NgTemplateOutlet } from '@angular/common' import { NgTemplateOutlet } from '@angular/common'
import { Component, OnInit, inject } from '@angular/core' import { Component, OnInit, inject } from '@angular/core'
import { import {
AbstractControl,
FormArray, FormArray,
FormControl, FormControl,
FormGroup, FormGroup,
@@ -14,7 +15,7 @@ import {
} from '@angular/forms' } from '@angular/forms'
import { NgbAccordionModule } from '@ng-bootstrap/ng-bootstrap' import { NgbAccordionModule } from '@ng-bootstrap/ng-bootstrap'
import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons' import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons'
import { first } from 'rxjs' import { Subscription, first, takeUntil } from 'rxjs'
import { Correspondent } from 'src/app/data/correspondent' import { Correspondent } from 'src/app/data/correspondent'
import { CustomField, CustomFieldDataType } from 'src/app/data/custom-field' import { CustomField, CustomFieldDataType } from 'src/app/data/custom-field'
import { DocumentType } from 'src/app/data/document-type' import { DocumentType } from 'src/app/data/document-type'
@@ -45,7 +46,12 @@ import { StoragePathService } from 'src/app/services/rest/storage-path.service'
import { UserService } from 'src/app/services/rest/user.service' import { UserService } from 'src/app/services/rest/user.service'
import { WorkflowService } from 'src/app/services/rest/workflow.service' import { WorkflowService } from 'src/app/services/rest/workflow.service'
import { SettingsService } from 'src/app/services/settings.service' import { SettingsService } from 'src/app/services/settings.service'
import { CustomFieldQueryExpression } from 'src/app/utils/custom-field-query-element'
import { ConfirmButtonComponent } from '../../confirm-button/confirm-button.component' import { ConfirmButtonComponent } from '../../confirm-button/confirm-button.component'
import {
CustomFieldQueriesModel,
CustomFieldsQueryDropdownComponent,
} from '../../custom-fields-query-dropdown/custom-fields-query-dropdown.component'
import { CheckComponent } from '../../input/check/check.component' import { CheckComponent } from '../../input/check/check.component'
import { CustomFieldsValuesComponent } from '../../input/custom-fields-values/custom-fields-values.component' import { CustomFieldsValuesComponent } from '../../input/custom-fields-values/custom-fields-values.component'
import { EntriesComponent } from '../../input/entries/entries.component' import { EntriesComponent } from '../../input/entries/entries.component'
@@ -135,10 +141,235 @@ export const WORKFLOW_ACTION_OPTIONS = [
}, },
] ]
export enum TriggerFilterType {
TagsAny = 'tags_any',
TagsAll = 'tags_all',
TagsNone = 'tags_none',
CorrespondentIs = 'correspondent_is',
CorrespondentNot = 'correspondent_not',
DocumentTypeIs = 'document_type_is',
DocumentTypeNot = 'document_type_not',
StoragePathIs = 'storage_path_is',
StoragePathNot = 'storage_path_not',
CustomFieldQuery = 'custom_field_query',
}
interface TriggerFilterDefinition {
id: TriggerFilterType
name: string
inputType: 'tags' | 'select' | 'customFieldQuery'
allowMultipleEntries: boolean
allowMultipleValues: boolean
selectItems?: 'correspondents' | 'documentTypes' | 'storagePaths'
disabled?: boolean
}
type TriggerFilterOption = TriggerFilterDefinition & {
disabled?: boolean
}
type TriggerFilterAggregate = {
filter_has_tags: number[]
filter_has_all_tags: number[]
filter_has_not_tags: number[]
filter_has_not_correspondents: number[]
filter_has_not_document_types: number[]
filter_has_not_storage_paths: number[]
filter_has_correspondent: number | null
filter_has_document_type: number | null
filter_has_storage_path: number | null
filter_custom_field_query: string | null
}
interface FilterHandler {
apply: (aggregate: TriggerFilterAggregate, values: any) => void
extract: (trigger: WorkflowTrigger) => any
hasValue: (value: any) => boolean
}
const CUSTOM_FIELD_QUERY_MODEL_KEY = Symbol('customFieldQueryModel')
const CUSTOM_FIELD_QUERY_SUBSCRIPTION_KEY = Symbol(
'customFieldQuerySubscription'
)
type CustomFieldFilterGroup = FormGroup & {
[CUSTOM_FIELD_QUERY_MODEL_KEY]?: CustomFieldQueriesModel
[CUSTOM_FIELD_QUERY_SUBSCRIPTION_KEY]?: Subscription
}
const TRIGGER_FILTER_DEFINITIONS: TriggerFilterDefinition[] = [
{
id: TriggerFilterType.TagsAny,
name: $localize`Has any of these tags`,
inputType: 'tags',
allowMultipleEntries: false,
allowMultipleValues: true,
},
{
id: TriggerFilterType.TagsAll,
name: $localize`Has all of these tags`,
inputType: 'tags',
allowMultipleEntries: false,
allowMultipleValues: true,
},
{
id: TriggerFilterType.TagsNone,
name: $localize`Does not have these tags`,
inputType: 'tags',
allowMultipleEntries: false,
allowMultipleValues: true,
},
{
id: TriggerFilterType.CorrespondentIs,
name: $localize`Has correspondent`,
inputType: 'select',
allowMultipleEntries: false,
allowMultipleValues: false,
selectItems: 'correspondents',
},
{
id: TriggerFilterType.CorrespondentNot,
name: $localize`Does not have correspondents`,
inputType: 'select',
allowMultipleEntries: false,
allowMultipleValues: true,
selectItems: 'correspondents',
},
{
id: TriggerFilterType.DocumentTypeIs,
name: $localize`Has document type`,
inputType: 'select',
allowMultipleEntries: false,
allowMultipleValues: false,
selectItems: 'documentTypes',
},
{
id: TriggerFilterType.DocumentTypeNot,
name: $localize`Does not have document types`,
inputType: 'select',
allowMultipleEntries: false,
allowMultipleValues: true,
selectItems: 'documentTypes',
},
{
id: TriggerFilterType.StoragePathIs,
name: $localize`Has storage path`,
inputType: 'select',
allowMultipleEntries: false,
allowMultipleValues: false,
selectItems: 'storagePaths',
},
{
id: TriggerFilterType.StoragePathNot,
name: $localize`Does not have storage paths`,
inputType: 'select',
allowMultipleEntries: false,
allowMultipleValues: true,
selectItems: 'storagePaths',
},
{
id: TriggerFilterType.CustomFieldQuery,
name: $localize`Matches custom field query`,
inputType: 'customFieldQuery',
allowMultipleEntries: false,
allowMultipleValues: false,
},
]
const TRIGGER_MATCHING_ALGORITHMS = MATCHING_ALGORITHMS.filter( const TRIGGER_MATCHING_ALGORITHMS = MATCHING_ALGORITHMS.filter(
(a) => a.id !== MATCH_AUTO (a) => a.id !== MATCH_AUTO
) )
const FILTER_HANDLERS: Record<TriggerFilterType, FilterHandler> = {
[TriggerFilterType.TagsAny]: {
apply: (aggregate, values) => {
aggregate.filter_has_tags = Array.isArray(values) ? [...values] : [values]
},
extract: (trigger) => trigger.filter_has_tags,
hasValue: (value) => Array.isArray(value) && value.length > 0,
},
[TriggerFilterType.TagsAll]: {
apply: (aggregate, values) => {
aggregate.filter_has_all_tags = Array.isArray(values)
? [...values]
: [values]
},
extract: (trigger) => trigger.filter_has_all_tags,
hasValue: (value) => Array.isArray(value) && value.length > 0,
},
[TriggerFilterType.TagsNone]: {
apply: (aggregate, values) => {
aggregate.filter_has_not_tags = Array.isArray(values)
? [...values]
: [values]
},
extract: (trigger) => trigger.filter_has_not_tags,
hasValue: (value) => Array.isArray(value) && value.length > 0,
},
[TriggerFilterType.CorrespondentIs]: {
apply: (aggregate, values) => {
aggregate.filter_has_correspondent = Array.isArray(values)
? (values[0] ?? null)
: values
},
extract: (trigger) => trigger.filter_has_correspondent,
hasValue: (value) => value !== null && value !== undefined,
},
[TriggerFilterType.CorrespondentNot]: {
apply: (aggregate, values) => {
aggregate.filter_has_not_correspondents = Array.isArray(values)
? [...values]
: [values]
},
extract: (trigger) => trigger.filter_has_not_correspondents,
hasValue: (value) => Array.isArray(value) && value.length > 0,
},
[TriggerFilterType.DocumentTypeIs]: {
apply: (aggregate, values) => {
aggregate.filter_has_document_type = Array.isArray(values)
? (values[0] ?? null)
: values
},
extract: (trigger) => trigger.filter_has_document_type,
hasValue: (value) => value !== null && value !== undefined,
},
[TriggerFilterType.DocumentTypeNot]: {
apply: (aggregate, values) => {
aggregate.filter_has_not_document_types = Array.isArray(values)
? [...values]
: [values]
},
extract: (trigger) => trigger.filter_has_not_document_types,
hasValue: (value) => Array.isArray(value) && value.length > 0,
},
[TriggerFilterType.StoragePathIs]: {
apply: (aggregate, values) => {
aggregate.filter_has_storage_path = Array.isArray(values)
? (values[0] ?? null)
: values
},
extract: (trigger) => trigger.filter_has_storage_path,
hasValue: (value) => value !== null && value !== undefined,
},
[TriggerFilterType.StoragePathNot]: {
apply: (aggregate, values) => {
aggregate.filter_has_not_storage_paths = Array.isArray(values)
? [...values]
: [values]
},
extract: (trigger) => trigger.filter_has_not_storage_paths,
hasValue: (value) => Array.isArray(value) && value.length > 0,
},
[TriggerFilterType.CustomFieldQuery]: {
apply: (aggregate, values) => {
aggregate.filter_custom_field_query = values as string
},
extract: (trigger) => trigger.filter_custom_field_query,
hasValue: (value) =>
typeof value === 'string' && value !== null && value.trim().length > 0,
},
}
@Component({ @Component({
selector: 'pngx-workflow-edit-dialog', selector: 'pngx-workflow-edit-dialog',
templateUrl: './workflow-edit-dialog.component.html', templateUrl: './workflow-edit-dialog.component.html',
@@ -153,6 +384,7 @@ const TRIGGER_MATCHING_ALGORITHMS = MATCHING_ALGORITHMS.filter(
TextAreaComponent, TextAreaComponent,
TagsComponent, TagsComponent,
CustomFieldsValuesComponent, CustomFieldsValuesComponent,
CustomFieldsQueryDropdownComponent,
PermissionsGroupComponent, PermissionsGroupComponent,
PermissionsUserComponent, PermissionsUserComponent,
ConfirmButtonComponent, ConfirmButtonComponent,
@@ -170,6 +402,8 @@ export class WorkflowEditDialogComponent
{ {
public WorkflowTriggerType = WorkflowTriggerType public WorkflowTriggerType = WorkflowTriggerType
public WorkflowActionType = WorkflowActionType public WorkflowActionType = WorkflowActionType
public TriggerFilterType = TriggerFilterType
public filterDefinitions = TRIGGER_FILTER_DEFINITIONS
private correspondentService: CorrespondentService private correspondentService: CorrespondentService
private documentTypeService: DocumentTypeService private documentTypeService: DocumentTypeService
@@ -189,6 +423,11 @@ export class WorkflowEditDialogComponent
private allowedActionTypes = [] private allowedActionTypes = []
private readonly triggerFilterOptionsMap = new WeakMap<
FormArray,
TriggerFilterOption[]
>()
constructor() { constructor() {
super() super()
this.service = inject(WorkflowService) this.service = inject(WorkflowService)
@@ -390,6 +629,416 @@ export class WorkflowEditDialogComponent
return this.objectForm.get('actions') as FormArray return this.objectForm.get('actions') as FormArray
} }
protected override getFormValues(): any {
const formValues = super.getFormValues()
if (formValues?.triggers?.length) {
formValues.triggers = formValues.triggers.map(
(trigger: any, index: number) => {
const triggerFormGroup = this.triggerFields.at(index) as FormGroup
const filters = this.getFiltersFormArray(triggerFormGroup)
const aggregate: TriggerFilterAggregate = {
filter_has_tags: [],
filter_has_all_tags: [],
filter_has_not_tags: [],
filter_has_not_correspondents: [],
filter_has_not_document_types: [],
filter_has_not_storage_paths: [],
filter_has_correspondent: null,
filter_has_document_type: null,
filter_has_storage_path: null,
filter_custom_field_query: null,
}
for (const control of filters.controls) {
const type = control.get('type').value as TriggerFilterType
const values = control.get('values').value
if (values === null || values === undefined) {
continue
}
if (Array.isArray(values) && values.length === 0) {
continue
}
const handler = FILTER_HANDLERS[type]
handler?.apply(aggregate, values)
}
trigger.filter_has_tags = aggregate.filter_has_tags
trigger.filter_has_all_tags = aggregate.filter_has_all_tags
trigger.filter_has_not_tags = aggregate.filter_has_not_tags
trigger.filter_has_not_correspondents =
aggregate.filter_has_not_correspondents
trigger.filter_has_not_document_types =
aggregate.filter_has_not_document_types
trigger.filter_has_not_storage_paths =
aggregate.filter_has_not_storage_paths
trigger.filter_has_correspondent =
aggregate.filter_has_correspondent ?? null
trigger.filter_has_document_type =
aggregate.filter_has_document_type ?? null
trigger.filter_has_storage_path =
aggregate.filter_has_storage_path ?? null
trigger.filter_custom_field_query =
aggregate.filter_custom_field_query ?? null
delete trigger.filters
return trigger
}
)
}
return formValues
}
public matchingPatternRequired(formGroup: FormGroup): boolean {
return formGroup.get('matching_algorithm').value !== MATCH_NONE
}
private createFilterFormGroup(
type: TriggerFilterType,
initialValue?: any
): FormGroup {
const group = new FormGroup({
type: new FormControl(type),
values: new FormControl(this.normalizeFilterValue(type, initialValue)),
})
group.get('type').valueChanges.subscribe((newType: TriggerFilterType) => {
if (newType === TriggerFilterType.CustomFieldQuery) {
this.ensureCustomFieldQueryModel(group)
} else {
this.clearCustomFieldQueryModel(group)
group.get('values').setValue(this.getDefaultFilterValue(newType), {
emitEvent: false,
})
}
})
if (type === TriggerFilterType.CustomFieldQuery) {
this.ensureCustomFieldQueryModel(group, initialValue)
}
return group
}
private buildFiltersFormArray(trigger: WorkflowTrigger): FormArray {
const filters = new FormArray([])
for (const definition of this.filterDefinitions) {
const handler = FILTER_HANDLERS[definition.id]
if (!handler) {
continue
}
const value = handler.extract(trigger)
if (!handler.hasValue(value)) {
continue
}
filters.push(this.createFilterFormGroup(definition.id, value))
}
return filters
}
getFiltersFormArray(formGroup: FormGroup): FormArray {
return formGroup.get('filters') as FormArray
}
getFilterTypeOptions(formGroup: FormGroup, filterIndex: number) {
const filters = this.getFiltersFormArray(formGroup)
const options = this.getFilterTypeOptionsForArray(filters)
const currentType = filters.at(filterIndex).get('type')
.value as TriggerFilterType
const usedTypes = new Set(
filters.controls.map(
(control) => control.get('type').value as TriggerFilterType
)
)
for (const option of options) {
if (option.allowMultipleEntries) {
option.disabled = false
continue
}
option.disabled = usedTypes.has(option.id) && option.id !== currentType
}
return options
}
canAddFilter(formGroup: FormGroup): boolean {
const filters = this.getFiltersFormArray(formGroup)
const usedTypes = new Set(
filters.controls.map(
(control) => control.get('type').value as TriggerFilterType
)
)
return this.filterDefinitions.some((definition) => {
if (definition.allowMultipleEntries) {
return true
}
return !usedTypes.has(definition.id)
})
}
addFilter(triggerFormGroup: FormGroup): FormGroup | null {
const triggerIndex = this.triggerFields.controls.indexOf(triggerFormGroup)
if (triggerIndex === -1) {
return null
}
const filters = this.getFiltersFormArray(triggerFormGroup)
const availableDefinition = this.filterDefinitions.find((definition) => {
if (definition.allowMultipleEntries) {
return true
}
return !filters.controls.some(
(control) => control.get('type').value === definition.id
)
})
if (!availableDefinition) {
return null
}
filters.push(this.createFilterFormGroup(availableDefinition.id))
triggerFormGroup.markAsDirty()
triggerFormGroup.markAsTouched()
return filters.at(-1) as FormGroup
}
removeFilter(triggerFormGroup: FormGroup, filterIndex: number) {
const triggerIndex = this.triggerFields.controls.indexOf(triggerFormGroup)
if (triggerIndex === -1) {
return
}
const filters = this.getFiltersFormArray(triggerFormGroup)
const filterGroup = filters.at(filterIndex) as FormGroup
if (filterGroup?.get('type').value === TriggerFilterType.CustomFieldQuery) {
this.clearCustomFieldQueryModel(filterGroup)
}
filters.removeAt(filterIndex)
triggerFormGroup.markAsDirty()
triggerFormGroup.markAsTouched()
}
getFilterDefinition(
type: TriggerFilterType
): TriggerFilterDefinition | undefined {
return this.filterDefinitions.find((definition) => definition.id === type)
}
getFilterName(type: TriggerFilterType): string {
return this.getFilterDefinition(type)?.name ?? ''
}
isTagsFilter(type: TriggerFilterType): boolean {
return this.getFilterDefinition(type)?.inputType === 'tags'
}
isCustomFieldQueryFilter(type: TriggerFilterType): boolean {
return this.getFilterDefinition(type)?.inputType === 'customFieldQuery'
}
isMultiValueFilter(type: TriggerFilterType): boolean {
switch (type) {
case TriggerFilterType.TagsAny:
case TriggerFilterType.TagsAll:
case TriggerFilterType.TagsNone:
case TriggerFilterType.CorrespondentNot:
case TriggerFilterType.DocumentTypeNot:
case TriggerFilterType.StoragePathNot:
return true
default:
return false
}
}
isSelectMultiple(type: TriggerFilterType): boolean {
return !this.isTagsFilter(type) && this.isMultiValueFilter(type)
}
getFilterSelectItems(type: TriggerFilterType) {
const definition = this.getFilterDefinition(type)
if (!definition || definition.inputType !== 'select') {
return []
}
switch (definition.selectItems) {
case 'correspondents':
return this.correspondents
case 'documentTypes':
return this.documentTypes
case 'storagePaths':
return this.storagePaths
default:
return []
}
}
getCustomFieldQueryModel(control: AbstractControl): CustomFieldQueriesModel {
return this.ensureCustomFieldQueryModel(control as FormGroup)
}
onCustomFieldQuerySelectionChange(
control: AbstractControl,
model: CustomFieldQueriesModel
) {
this.onCustomFieldQueryModelChanged(control as FormGroup, model)
}
isCustomFieldQueryValid(control: AbstractControl): boolean {
const model = this.getStoredCustomFieldQueryModel(control as FormGroup)
if (!model) {
return true
}
return model.isEmpty() || model.isValid()
}
private getFilterTypeOptionsForArray(
filters: FormArray
): TriggerFilterOption[] {
let cached = this.triggerFilterOptionsMap.get(filters)
if (!cached) {
cached = this.filterDefinitions.map((definition) => ({
...definition,
disabled: false,
}))
this.triggerFilterOptionsMap.set(filters, cached)
}
return cached
}
private ensureCustomFieldQueryModel(
filterGroup: FormGroup,
initialValue?: any
): CustomFieldQueriesModel {
const existingModel = this.getStoredCustomFieldQueryModel(filterGroup)
if (existingModel) {
return existingModel
}
const model = new CustomFieldQueriesModel()
this.setCustomFieldQueryModel(filterGroup, model)
const rawValue =
typeof initialValue === 'string'
? initialValue
: (filterGroup.get('values').value as string)
if (rawValue) {
try {
const parsed = JSON.parse(rawValue)
const expression = new CustomFieldQueryExpression(parsed)
model.queries = [expression]
} catch {
model.clear(false)
model.addInitialAtom()
}
}
const subscription = model.changed
.pipe(takeUntil(this.unsubscribeNotifier))
.subscribe(() => {
this.onCustomFieldQueryModelChanged(filterGroup, model)
})
filterGroup[CUSTOM_FIELD_QUERY_SUBSCRIPTION_KEY]?.unsubscribe()
filterGroup[CUSTOM_FIELD_QUERY_SUBSCRIPTION_KEY] = subscription
this.onCustomFieldQueryModelChanged(filterGroup, model)
return model
}
private clearCustomFieldQueryModel(filterGroup: FormGroup) {
const group = filterGroup as CustomFieldFilterGroup
group[CUSTOM_FIELD_QUERY_SUBSCRIPTION_KEY]?.unsubscribe()
delete group[CUSTOM_FIELD_QUERY_SUBSCRIPTION_KEY]
delete group[CUSTOM_FIELD_QUERY_MODEL_KEY]
}
private getStoredCustomFieldQueryModel(
filterGroup: FormGroup
): CustomFieldQueriesModel | null {
return (
(filterGroup as CustomFieldFilterGroup)[CUSTOM_FIELD_QUERY_MODEL_KEY] ??
null
)
}
private setCustomFieldQueryModel(
filterGroup: FormGroup,
model: CustomFieldQueriesModel
) {
const group = filterGroup as CustomFieldFilterGroup
group[CUSTOM_FIELD_QUERY_MODEL_KEY] = model
}
private onCustomFieldQueryModelChanged(
filterGroup: FormGroup,
model: CustomFieldQueriesModel
) {
const control = filterGroup.get('values')
if (!control) {
return
}
if (!model.isValid()) {
control.setValue(null, { emitEvent: false })
return
}
if (model.isEmpty()) {
control.setValue(null, { emitEvent: false })
return
}
const serialized = JSON.stringify(model.queries[0].serialize())
control.setValue(serialized, { emitEvent: false })
}
private getDefaultFilterValue(type: TriggerFilterType) {
if (type === TriggerFilterType.CustomFieldQuery) {
return null
}
return this.isMultiValueFilter(type) ? [] : null
}
private normalizeFilterValue(type: TriggerFilterType, value?: any) {
if (value === undefined || value === null) {
return this.getDefaultFilterValue(type)
}
if (type === TriggerFilterType.CustomFieldQuery) {
if (typeof value === 'string') {
return value
}
return value ? JSON.stringify(value) : null
}
if (this.isMultiValueFilter(type)) {
return Array.isArray(value) ? [...value] : [value]
}
if (Array.isArray(value)) {
return value.length > 0 ? value[0] : null
}
return value
}
private createTriggerField( private createTriggerField(
trigger: WorkflowTrigger, trigger: WorkflowTrigger,
emitEvent: boolean = false emitEvent: boolean = false
@@ -405,16 +1054,7 @@ export class WorkflowEditDialogComponent
matching_algorithm: new FormControl(trigger.matching_algorithm), matching_algorithm: new FormControl(trigger.matching_algorithm),
match: new FormControl(trigger.match), match: new FormControl(trigger.match),
is_insensitive: new FormControl(trigger.is_insensitive), is_insensitive: new FormControl(trigger.is_insensitive),
filter_has_tags: new FormControl(trigger.filter_has_tags), filters: this.buildFiltersFormArray(trigger),
filter_has_correspondent: new FormControl(
trigger.filter_has_correspondent
),
filter_has_document_type: new FormControl(
trigger.filter_has_document_type
),
filter_has_storage_path: new FormControl(
trigger.filter_has_storage_path
),
schedule_offset_days: new FormControl(trigger.schedule_offset_days), schedule_offset_days: new FormControl(trigger.schedule_offset_days),
schedule_is_recurring: new FormControl(trigger.schedule_is_recurring), schedule_is_recurring: new FormControl(trigger.schedule_is_recurring),
schedule_recurring_interval_days: new FormControl( schedule_recurring_interval_days: new FormControl(
@@ -537,6 +1177,12 @@ export class WorkflowEditDialogComponent
filter_path: null, filter_path: null,
filter_mailrule: null, filter_mailrule: null,
filter_has_tags: [], filter_has_tags: [],
filter_has_all_tags: [],
filter_has_not_tags: [],
filter_has_not_correspondents: [],
filter_has_not_document_types: [],
filter_has_not_storage_paths: [],
filter_custom_field_query: null,
filter_has_correspondent: null, filter_has_correspondent: null,
filter_has_document_type: null, filter_has_document_type: null,
filter_has_storage_path: null, filter_has_storage_path: null,

View File

@@ -1,66 +1,68 @@
<div class="mb-3 paperless-input-select" [class.disabled]="disabled"> <div class="mb-3 paperless-input-select" [class.disabled]="disabled">
<div class="row"> <div class="row">
<div class="d-flex align-items-center position-relative hidden-button-container" [class.col-md-3]="horizontal"> @if (title || removable) {
@if (title) { <div class="d-flex align-items-center position-relative hidden-button-container" [class.col-md-3]="horizontal">
<label class="form-label" [class.mb-md-0]="horizontal" [for]="inputId">{{title}}</label> @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)"> @if (removable) {
<i-bs name="x"></i-bs>&nbsp;<ng-container i18n>Remove</ng-container> <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 [class.input-group]="allowCreateNew || showFilter" [class.is-invalid]="error">
<ng-select name="inputId" [(ngModel)]="value"
[disabled]="disabled"
[style.color]="textColor"
[style.background]="backgroundColor"
[class.private]="isPrivate"
[clearable]="allowNull"
[items]="items"
[addTag]="allowCreateNew && addItemRef"
addTagText="Add item"
i18n-addTagText="Used for both types, correspondents, storage paths"
[placeholder]="placeholder"
[notFoundText]="notFoundText"
[multiple]="multiple"
[bindLabel]="bindLabel"
bindValue="id"
(change)="onChange(value)"
(search)="onSearch($event)"
(focus)="clearLastSearchTerm()"
(clear)="clearLastSearchTerm()"
(blur)="onBlur()">
<ng-template ng-option-tmp let-item="item">
<span [title]="item[bindLabel]">{{item[bindLabel]}}</span>
</ng-template>
</ng-select>
@if (allowCreateNew && !hideAddButton) {
<button class="btn btn-outline-secondary" type="button" (click)="addItem()" [disabled]="disabled">
<i-bs width="1.2em" height="1.2em" name="plus"></i-bs>
</button>
}
@if (showFilter) {
<button class="btn btn-outline-secondary" type="button" (click)="onFilterDocuments()" [disabled]="isPrivate || this.value === null" title="{{ filterButtonTitle }}">
<i-bs width="1.2em" height="1.2em" name="filter"></i-bs>
</button> </button>
} }
</div> </div>
<div [class.col-md-9]="horizontal"> <div class="invalid-feedback">
<div [class.input-group]="allowCreateNew || showFilter" [class.is-invalid]="error"> {{error}}
<ng-select name="inputId" [(ngModel)]="value"
[disabled]="disabled"
[style.color]="textColor"
[style.background]="backgroundColor"
[class.private]="isPrivate"
[clearable]="allowNull"
[items]="items"
[addTag]="allowCreateNew && addItemRef"
addTagText="Add item"
i18n-addTagText="Used for both types, correspondents, storage paths"
[placeholder]="placeholder"
[notFoundText]="notFoundText"
[multiple]="multiple"
[bindLabel]="bindLabel"
bindValue="id"
(change)="onChange(value)"
(search)="onSearch($event)"
(focus)="clearLastSearchTerm()"
(clear)="clearLastSearchTerm()"
(blur)="onBlur()">
<ng-template ng-option-tmp let-item="item">
<span [title]="item[bindLabel]">{{item[bindLabel]}}</span>
</ng-template>
</ng-select>
@if (allowCreateNew && !hideAddButton) {
<button class="btn btn-outline-secondary" type="button" (click)="addItem()" [disabled]="disabled">
<i-bs width="1.2em" height="1.2em" name="plus"></i-bs>
</button>
}
@if (showFilter) {
<button class="btn btn-outline-secondary" type="button" (click)="onFilterDocuments()" [disabled]="isPrivate || this.value === null" title="{{ filterButtonTitle }}">
<i-bs width="1.2em" height="1.2em" name="filter"></i-bs>
</button>
}
</div>
<div class="invalid-feedback">
{{error}}
</div>
@if (hint) {
<small class="form-text text-muted">{{hint}}</small>
}
@if (getSuggestions().length > 0) {
<small>
<span i18n>Suggestions:</span>&nbsp;
@for (s of getSuggestions(); track s) {
<a (click)="value = s.id; onChange(value)" [routerLink]="[]">{{s.name}}</a>&nbsp;
}
</small>
}
</div> </div>
@if (hint) {
<small class="form-text text-muted">{{hint}}</small>
}
@if (getSuggestions().length > 0) {
<small>
<span i18n>Suggestions:</span>&nbsp;
@for (s of getSuggestions(); track s) {
<a (click)="value = s.id; onChange(value)" [routerLink]="[]">{{s.name}}</a>&nbsp;
}
</small>
}
</div> </div>
</div> </div>
</div>

View File

@@ -1,8 +1,10 @@
<div class="mb-3 paperless-input-select paperless-input-tags" [class.disabled]="disabled" [class.pb-3]="getSuggestions().length > 0"> <div class="mb-3 paperless-input-select paperless-input-tags" [class.disabled]="disabled" [class.pb-3]="getSuggestions().length > 0">
<div class="row"> <div class="row">
<div class="d-flex align-items-center" [class.col-md-3]="horizontal"> @if (title) {
<label class="form-label" [class.mb-md-0]="horizontal" for="tags">{{title}}</label> <div class="d-flex align-items-center" [class.col-md-3]="horizontal">
</div> <label class="form-label" [class.mb-md-0]="horizontal" for="tags">{{title}}</label>
</div>
}
<div class="position-relative" [class.col-md-9]="horizontal"> <div class="position-relative" [class.col-md-9]="horizontal">
<div class="input-group flex-nowrap"> <div class="input-group flex-nowrap">
<ng-select #tagSelect name="tags" [items]="tags" bindLabel="name" bindValue="id" [(ngModel)]="value" <ng-select #tagSelect name="tags" [items]="tags" bindLabel="name" bindValue="id" [(ngModel)]="value"

View File

@@ -40,6 +40,18 @@ export interface WorkflowTrigger extends ObjectWithId {
filter_has_tags?: number[] // Tag.id[] filter_has_tags?: number[] // Tag.id[]
filter_has_all_tags?: number[] // Tag.id[]
filter_has_not_tags?: number[] // Tag.id[]
filter_has_not_correspondents?: number[] // Correspondent.id[]
filter_has_not_document_types?: number[] // DocumentType.id[]
filter_has_not_storage_paths?: number[] // StoragePath.id[]
filter_custom_field_query?: string
filter_has_correspondent?: number // Correspondent.id filter_has_correspondent?: number // Correspondent.id
filter_has_document_type?: number // DocumentType.id filter_has_document_type?: number // DocumentType.id

View File

@@ -6,8 +6,11 @@ from fnmatch import fnmatch
from fnmatch import translate as fnmatch_translate from fnmatch import translate as fnmatch_translate
from typing import TYPE_CHECKING from typing import TYPE_CHECKING
from rest_framework import serializers
from documents.data_models import ConsumableDocument from documents.data_models import ConsumableDocument
from documents.data_models import DocumentSource from documents.data_models import DocumentSource
from documents.filters import CustomFieldQueryParser
from documents.models import Correspondent from documents.models import Correspondent
from documents.models import Document from documents.models import Document
from documents.models import DocumentType from documents.models import DocumentType
@@ -342,67 +345,147 @@ def consumable_document_matches_workflow(
def existing_document_matches_workflow( def existing_document_matches_workflow(
document: Document, document: Document,
trigger: WorkflowTrigger, trigger: WorkflowTrigger,
) -> tuple[bool, str]: ) -> tuple[bool, str | None]:
""" """
Returns True if the Document matches all filters from the workflow trigger, Returns True if the Document matches all filters from the workflow trigger,
False otherwise. Includes a reason if doesn't match False otherwise. Includes a reason if doesn't match
""" """
trigger_matched = True # Check content matching algorithm
reason = ""
if trigger.matching_algorithm > MatchingModel.MATCH_NONE and not matches( if trigger.matching_algorithm > MatchingModel.MATCH_NONE and not matches(
trigger, trigger,
document, document,
): ):
reason = ( return (
False,
f"Document content matching settings for algorithm '{trigger.matching_algorithm}' did not match", f"Document content matching settings for algorithm '{trigger.matching_algorithm}' did not match",
) )
trigger_matched = False
# Document tags vs trigger has_tags # Check if any tag filters exist to determine if we need to load document tags
if ( trigger_has_tags_qs = trigger.filter_has_tags.all()
trigger.filter_has_tags.all().count() > 0 trigger_has_all_tags_qs = trigger.filter_has_all_tags.all()
and document.tags.filter( trigger_has_not_tags_qs = trigger.filter_has_not_tags.all()
id__in=trigger.filter_has_tags.all().values_list("id"),
).count() has_tags_filter = trigger_has_tags_qs.exists()
== 0 has_all_tags_filter = trigger_has_all_tags_qs.exists()
): has_not_tags_filter = trigger_has_not_tags_qs.exists()
reason = (
f"Document tags {document.tags.all()} do not include" # Load document tags once if any tag filters exist
f" {trigger.filter_has_tags.all()}", document_tag_ids = None
) if has_tags_filter or has_all_tags_filter or has_not_tags_filter:
trigger_matched = False document_tag_ids = set(document.tags.values_list("id", flat=True))
# Document tags vs trigger has_tags (any of)
if has_tags_filter:
trigger_has_tag_ids = set(trigger_has_tags_qs.values_list("id", flat=True))
if not (document_tag_ids & trigger_has_tag_ids):
# For error message, load the actual tag objects
return (
False,
f"Document tags {list(document.tags.all())} do not include {list(trigger_has_tags_qs)}",
)
# Document tags vs trigger has_all_tags (all of)
if has_all_tags_filter:
required_tag_ids = set(trigger_has_all_tags_qs.values_list("id", flat=True))
if not required_tag_ids.issubset(document_tag_ids):
return (
False,
f"Document tags {list(document.tags.all())} do not contain all of {list(trigger_has_all_tags_qs)}",
)
# Document tags vs trigger has_not_tags (none of)
if has_not_tags_filter:
excluded_tag_ids = set(trigger_has_not_tags_qs.values_list("id", flat=True))
if document_tag_ids & excluded_tag_ids:
return (
False,
f"Document tags {list(document.tags.all())} include excluded tags {list(trigger_has_not_tags_qs)}",
)
# Document correspondent vs trigger has_correspondent # Document correspondent vs trigger has_correspondent
if ( if (
trigger.filter_has_correspondent is not None trigger.filter_has_correspondent_id is not None
and document.correspondent != trigger.filter_has_correspondent and document.correspondent_id != trigger.filter_has_correspondent_id
): ):
reason = ( return (
False,
f"Document correspondent {document.correspondent} does not match {trigger.filter_has_correspondent}", f"Document correspondent {document.correspondent} does not match {trigger.filter_has_correspondent}",
) )
trigger_matched = False
if (
document.correspondent_id
and trigger.filter_has_not_correspondents.filter(
id=document.correspondent_id,
).exists()
):
return (
False,
f"Document correspondent {document.correspondent} is excluded by {list(trigger.filter_has_not_correspondents.all())}",
)
# Document document_type vs trigger has_document_type # Document document_type vs trigger has_document_type
if ( if (
trigger.filter_has_document_type is not None trigger.filter_has_document_type_id is not None
and document.document_type != trigger.filter_has_document_type and document.document_type_id != trigger.filter_has_document_type_id
): ):
reason = ( return (
False,
f"Document doc type {document.document_type} does not match {trigger.filter_has_document_type}", f"Document doc type {document.document_type} does not match {trigger.filter_has_document_type}",
) )
trigger_matched = False
if (
document.document_type_id
and trigger.filter_has_not_document_types.filter(
id=document.document_type_id,
).exists()
):
return (
False,
f"Document doc type {document.document_type} is excluded by {list(trigger.filter_has_not_document_types.all())}",
)
# Document storage_path vs trigger has_storage_path # Document storage_path vs trigger has_storage_path
if ( if (
trigger.filter_has_storage_path is not None trigger.filter_has_storage_path_id is not None
and document.storage_path != trigger.filter_has_storage_path and document.storage_path_id != trigger.filter_has_storage_path_id
): ):
reason = ( return (
False,
f"Document storage path {document.storage_path} does not match {trigger.filter_has_storage_path}", f"Document storage path {document.storage_path} does not match {trigger.filter_has_storage_path}",
) )
trigger_matched = False
if (
document.storage_path_id
and trigger.filter_has_not_storage_paths.filter(
id=document.storage_path_id,
).exists()
):
return (
False,
f"Document storage path {document.storage_path} is excluded by {list(trigger.filter_has_not_storage_paths.all())}",
)
# Custom field query check
if trigger.filter_custom_field_query:
parser = CustomFieldQueryParser("filter_custom_field_query")
try:
custom_field_q, annotations = parser.parse(
trigger.filter_custom_field_query,
)
except serializers.ValidationError:
return (False, "Invalid custom field query configuration")
qs = (
Document.objects.filter(id=document.id)
.annotate(**annotations)
.filter(custom_field_q)
)
if not qs.exists():
return (
False,
"Document custom fields do not match the configured custom field query",
)
# Document original_filename vs trigger filename # Document original_filename vs trigger filename
if ( if (
@@ -414,13 +497,12 @@ def existing_document_matches_workflow(
trigger.filter_filename.lower(), trigger.filter_filename.lower(),
) )
): ):
reason = ( return (
f"Document filename {document.original_filename} does not match" False,
f" {trigger.filter_filename.lower()}", f"Document filename {document.original_filename} does not match {trigger.filter_filename.lower()}",
) )
trigger_matched = False
return (trigger_matched, reason) return (True, None)
def prefilter_documents_by_workflowtrigger( def prefilter_documents_by_workflowtrigger(
@@ -433,31 +515,66 @@ def prefilter_documents_by_workflowtrigger(
document_matches_workflow in run_workflows document_matches_workflow in run_workflows
""" """
if trigger.filter_has_tags.all().count() > 0: # Filter for documents that have AT LEAST ONE of the specified tags.
documents = documents.filter( if trigger.filter_has_tags.exists():
tags__in=trigger.filter_has_tags.all(), documents = documents.filter(tags__in=trigger.filter_has_tags.all()).distinct()
).distinct()
# Filter for documents that have ALL of the specified tags.
if trigger.filter_has_all_tags.exists():
for tag in trigger.filter_has_all_tags.all():
documents = documents.filter(tags=tag)
# Multiple JOINs can create duplicate results.
documents = documents.distinct()
# Exclude documents that have ANY of the specified tags.
if trigger.filter_has_not_tags.exists():
documents = documents.exclude(tags__in=trigger.filter_has_not_tags.all())
# Correspondent, DocumentType, etc. filtering
if trigger.filter_has_correspondent is not None: if trigger.filter_has_correspondent is not None:
documents = documents.filter( documents = documents.filter(
correspondent=trigger.filter_has_correspondent, correspondent=trigger.filter_has_correspondent,
) )
if trigger.filter_has_not_correspondents.exists():
documents = documents.exclude(
correspondent__in=trigger.filter_has_not_correspondents.all(),
)
if trigger.filter_has_document_type is not None: if trigger.filter_has_document_type is not None:
documents = documents.filter( documents = documents.filter(
document_type=trigger.filter_has_document_type, document_type=trigger.filter_has_document_type,
) )
if trigger.filter_has_not_document_types.exists():
documents = documents.exclude(
document_type__in=trigger.filter_has_not_document_types.all(),
)
if trigger.filter_has_storage_path is not None: if trigger.filter_has_storage_path is not None:
documents = documents.filter( documents = documents.filter(
storage_path=trigger.filter_has_storage_path, storage_path=trigger.filter_has_storage_path,
) )
if trigger.filter_has_not_storage_paths.exists():
documents = documents.exclude(
storage_path__in=trigger.filter_has_not_storage_paths.all(),
)
if trigger.filter_filename is not None and len(trigger.filter_filename) > 0: # Custom Field & Filename Filtering
# the true fnmatch will actually run later so we just want a loose filter here
if trigger.filter_custom_field_query:
parser = CustomFieldQueryParser("filter_custom_field_query")
try:
custom_field_q, annotations = parser.parse(
trigger.filter_custom_field_query,
)
except serializers.ValidationError:
return documents.none()
documents = documents.annotate(**annotations).filter(custom_field_q)
if trigger.filter_filename:
regex = fnmatch_translate(trigger.filter_filename).lstrip("^").rstrip("$") regex = fnmatch_translate(trigger.filter_filename).lstrip("^").rstrip("$")
regex = f"(?i){regex}" documents = documents.filter(original_filename__iregex=regex)
documents = documents.filter(original_filename__regex=regex)
return documents return documents
@@ -472,13 +589,34 @@ def document_matches_workflow(
settings from the workflow trigger, False otherwise settings from the workflow trigger, False otherwise
""" """
triggers_queryset = (
workflow.triggers.filter(
type=trigger_type,
)
.select_related(
"filter_mailrule",
"filter_has_document_type",
"filter_has_correspondent",
"filter_has_storage_path",
"schedule_date_custom_field",
)
.prefetch_related(
"filter_has_tags",
"filter_has_all_tags",
"filter_has_not_tags",
"filter_has_not_document_types",
"filter_has_not_correspondents",
"filter_has_not_storage_paths",
)
)
trigger_matched = True trigger_matched = True
if workflow.triggers.filter(type=trigger_type).count() == 0: if not triggers_queryset.exists():
trigger_matched = False trigger_matched = False
logger.info(f"Document did not match {workflow}") logger.info(f"Document did not match {workflow}")
logger.debug(f"No matching triggers with type {trigger_type} found") logger.debug(f"No matching triggers with type {trigger_type} found")
else: else:
for trigger in workflow.triggers.filter(type=trigger_type): for trigger in triggers_queryset:
if trigger_type == WorkflowTrigger.WorkflowTriggerType.CONSUMPTION: if trigger_type == WorkflowTrigger.WorkflowTriggerType.CONSUMPTION:
trigger_matched, reason = consumable_document_matches_workflow( trigger_matched, reason = consumable_document_matches_workflow(
document, document,

View File

@@ -0,0 +1,73 @@
# Generated by Django 5.2.6 on 2025-10-07 18:52
from django.db import migrations
from django.db import models
class Migration(migrations.Migration):
dependencies = [
("documents", "1071_tag_tn_ancestors_count_tag_tn_ancestors_pks_and_more"),
]
operations = [
migrations.AddField(
model_name="workflowtrigger",
name="filter_custom_field_query",
field=models.TextField(
blank=True,
help_text="JSON-encoded custom field query expression.",
null=True,
verbose_name="filter custom field query",
),
),
migrations.AddField(
model_name="workflowtrigger",
name="filter_has_all_tags",
field=models.ManyToManyField(
blank=True,
related_name="workflowtriggers_has_all",
to="documents.tag",
verbose_name="has all of these tag(s)",
),
),
migrations.AddField(
model_name="workflowtrigger",
name="filter_has_not_correspondents",
field=models.ManyToManyField(
blank=True,
related_name="workflowtriggers_has_not_correspondent",
to="documents.correspondent",
verbose_name="does not have these correspondent(s)",
),
),
migrations.AddField(
model_name="workflowtrigger",
name="filter_has_not_document_types",
field=models.ManyToManyField(
blank=True,
related_name="workflowtriggers_has_not_document_type",
to="documents.documenttype",
verbose_name="does not have these document type(s)",
),
),
migrations.AddField(
model_name="workflowtrigger",
name="filter_has_not_storage_paths",
field=models.ManyToManyField(
blank=True,
related_name="workflowtriggers_has_not_storage_path",
to="documents.storagepath",
verbose_name="does not have these storage path(s)",
),
),
migrations.AddField(
model_name="workflowtrigger",
name="filter_has_not_tags",
field=models.ManyToManyField(
blank=True,
related_name="workflowtriggers_has_not",
to="documents.tag",
verbose_name="does not have these tag(s)",
),
),
]

View File

@@ -1065,6 +1065,20 @@ class WorkflowTrigger(models.Model):
verbose_name=_("has these tag(s)"), verbose_name=_("has these tag(s)"),
) )
filter_has_all_tags = models.ManyToManyField(
Tag,
blank=True,
related_name="workflowtriggers_has_all",
verbose_name=_("has all of these tag(s)"),
)
filter_has_not_tags = models.ManyToManyField(
Tag,
blank=True,
related_name="workflowtriggers_has_not",
verbose_name=_("does not have these tag(s)"),
)
filter_has_document_type = models.ForeignKey( filter_has_document_type = models.ForeignKey(
DocumentType, DocumentType,
null=True, null=True,
@@ -1073,6 +1087,13 @@ class WorkflowTrigger(models.Model):
verbose_name=_("has this document type"), verbose_name=_("has this document type"),
) )
filter_has_not_document_types = models.ManyToManyField(
DocumentType,
blank=True,
related_name="workflowtriggers_has_not_document_type",
verbose_name=_("does not have these document type(s)"),
)
filter_has_correspondent = models.ForeignKey( filter_has_correspondent = models.ForeignKey(
Correspondent, Correspondent,
null=True, null=True,
@@ -1081,6 +1102,13 @@ class WorkflowTrigger(models.Model):
verbose_name=_("has this correspondent"), verbose_name=_("has this correspondent"),
) )
filter_has_not_correspondents = models.ManyToManyField(
Correspondent,
blank=True,
related_name="workflowtriggers_has_not_correspondent",
verbose_name=_("does not have these correspondent(s)"),
)
filter_has_storage_path = models.ForeignKey( filter_has_storage_path = models.ForeignKey(
StoragePath, StoragePath,
null=True, null=True,
@@ -1089,6 +1117,20 @@ class WorkflowTrigger(models.Model):
verbose_name=_("has this storage path"), verbose_name=_("has this storage path"),
) )
filter_has_not_storage_paths = models.ManyToManyField(
StoragePath,
blank=True,
related_name="workflowtriggers_has_not_storage_path",
verbose_name=_("does not have these storage path(s)"),
)
filter_custom_field_query = models.TextField(
_("filter custom field query"),
null=True,
blank=True,
help_text=_("JSON-encoded custom field query expression."),
)
schedule_offset_days = models.IntegerField( schedule_offset_days = models.IntegerField(
_("schedule offset days"), _("schedule offset days"),
default=0, default=0,

View File

@@ -43,6 +43,7 @@ if settings.AUDIT_LOG_ENABLED:
from documents import bulk_edit from documents import bulk_edit
from documents.data_models import DocumentSource from documents.data_models import DocumentSource
from documents.filters import CustomFieldQueryParser
from documents.models import Correspondent from documents.models import Correspondent
from documents.models import CustomField from documents.models import CustomField
from documents.models import CustomFieldInstance from documents.models import CustomFieldInstance
@@ -2194,6 +2195,12 @@ class WorkflowTriggerSerializer(serializers.ModelSerializer):
"match", "match",
"is_insensitive", "is_insensitive",
"filter_has_tags", "filter_has_tags",
"filter_has_all_tags",
"filter_has_not_tags",
"filter_custom_field_query",
"filter_has_not_correspondents",
"filter_has_not_document_types",
"filter_has_not_storage_paths",
"filter_has_correspondent", "filter_has_correspondent",
"filter_has_document_type", "filter_has_document_type",
"filter_has_storage_path", "filter_has_storage_path",
@@ -2219,6 +2226,20 @@ class WorkflowTriggerSerializer(serializers.ModelSerializer):
): ):
attrs["filter_path"] = None attrs["filter_path"] = None
if (
"filter_custom_field_query" in attrs
and attrs["filter_custom_field_query"] is not None
and len(attrs["filter_custom_field_query"]) == 0
):
attrs["filter_custom_field_query"] = None
if (
"filter_custom_field_query" in attrs
and attrs["filter_custom_field_query"] is not None
):
parser = CustomFieldQueryParser("filter_custom_field_query")
parser.parse(attrs["filter_custom_field_query"])
trigger_type = attrs.get("type", getattr(self.instance, "type", None)) trigger_type = attrs.get("type", getattr(self.instance, "type", None))
if ( if (
trigger_type == WorkflowTrigger.WorkflowTriggerType.CONSUMPTION trigger_type == WorkflowTrigger.WorkflowTriggerType.CONSUMPTION
@@ -2414,6 +2435,20 @@ class WorkflowSerializer(serializers.ModelSerializer):
if triggers is not None and triggers is not serializers.empty: if triggers is not None and triggers is not serializers.empty:
for trigger in triggers: for trigger in triggers:
filter_has_tags = trigger.pop("filter_has_tags", None) filter_has_tags = trigger.pop("filter_has_tags", None)
filter_has_all_tags = trigger.pop("filter_has_all_tags", None)
filter_has_not_tags = trigger.pop("filter_has_not_tags", None)
filter_has_not_correspondents = trigger.pop(
"filter_has_not_correspondents",
None,
)
filter_has_not_document_types = trigger.pop(
"filter_has_not_document_types",
None,
)
filter_has_not_storage_paths = trigger.pop(
"filter_has_not_storage_paths",
None,
)
# Convert sources to strings to handle django-multiselectfield v1.0 changes # Convert sources to strings to handle django-multiselectfield v1.0 changes
WorkflowTriggerSerializer.normalize_workflow_trigger_sources(trigger) WorkflowTriggerSerializer.normalize_workflow_trigger_sources(trigger)
trigger_instance, _ = WorkflowTrigger.objects.update_or_create( trigger_instance, _ = WorkflowTrigger.objects.update_or_create(
@@ -2422,6 +2457,22 @@ class WorkflowSerializer(serializers.ModelSerializer):
) )
if filter_has_tags is not None: if filter_has_tags is not None:
trigger_instance.filter_has_tags.set(filter_has_tags) trigger_instance.filter_has_tags.set(filter_has_tags)
if filter_has_all_tags is not None:
trigger_instance.filter_has_all_tags.set(filter_has_all_tags)
if filter_has_not_tags is not None:
trigger_instance.filter_has_not_tags.set(filter_has_not_tags)
if filter_has_not_correspondents is not None:
trigger_instance.filter_has_not_correspondents.set(
filter_has_not_correspondents,
)
if filter_has_not_document_types is not None:
trigger_instance.filter_has_not_document_types.set(
filter_has_not_document_types,
)
if filter_has_not_storage_paths is not None:
trigger_instance.filter_has_not_storage_paths.set(
filter_has_not_storage_paths,
)
set_triggers.append(trigger_instance) set_triggers.append(trigger_instance)
if actions is not None and actions is not serializers.empty: if actions is not None and actions is not serializers.empty:

View File

@@ -184,6 +184,17 @@ class TestApiWorkflows(DirectoriesMixin, APITestCase):
"filter_filename": "*", "filter_filename": "*",
"filter_path": "*/samples/*", "filter_path": "*/samples/*",
"filter_has_tags": [self.t1.id], "filter_has_tags": [self.t1.id],
"filter_has_all_tags": [self.t2.id],
"filter_has_not_tags": [self.t3.id],
"filter_has_not_correspondents": [self.c2.id],
"filter_has_not_document_types": [self.dt2.id],
"filter_has_not_storage_paths": [self.sp2.id],
"filter_custom_field_query": json.dumps(
[
"AND",
[[self.cf1.id, "exact", "value"]],
],
),
"filter_has_document_type": self.dt.id, "filter_has_document_type": self.dt.id,
"filter_has_correspondent": self.c.id, "filter_has_correspondent": self.c.id,
"filter_has_storage_path": self.sp.id, "filter_has_storage_path": self.sp.id,
@@ -223,6 +234,36 @@ class TestApiWorkflows(DirectoriesMixin, APITestCase):
) )
self.assertEqual(response.status_code, status.HTTP_201_CREATED) self.assertEqual(response.status_code, status.HTTP_201_CREATED)
self.assertEqual(Workflow.objects.count(), 2) self.assertEqual(Workflow.objects.count(), 2)
workflow = Workflow.objects.get(name="Workflow 2")
trigger = workflow.triggers.first()
self.assertSetEqual(
set(trigger.filter_has_tags.values_list("id", flat=True)),
{self.t1.id},
)
self.assertSetEqual(
set(trigger.filter_has_all_tags.values_list("id", flat=True)),
{self.t2.id},
)
self.assertSetEqual(
set(trigger.filter_has_not_tags.values_list("id", flat=True)),
{self.t3.id},
)
self.assertSetEqual(
set(trigger.filter_has_not_correspondents.values_list("id", flat=True)),
{self.c2.id},
)
self.assertSetEqual(
set(trigger.filter_has_not_document_types.values_list("id", flat=True)),
{self.dt2.id},
)
self.assertSetEqual(
set(trigger.filter_has_not_storage_paths.values_list("id", flat=True)),
{self.sp2.id},
)
self.assertEqual(
trigger.filter_custom_field_query,
json.dumps(["AND", [[self.cf1.id, "exact", "value"]]]),
)
def test_api_create_invalid_workflow_trigger(self): def test_api_create_invalid_workflow_trigger(self):
""" """
@@ -376,6 +417,14 @@ class TestApiWorkflows(DirectoriesMixin, APITestCase):
{ {
"type": WorkflowTrigger.WorkflowTriggerType.DOCUMENT_ADDED, "type": WorkflowTrigger.WorkflowTriggerType.DOCUMENT_ADDED,
"filter_has_tags": [self.t1.id], "filter_has_tags": [self.t1.id],
"filter_has_all_tags": [self.t2.id],
"filter_has_not_tags": [self.t3.id],
"filter_has_not_correspondents": [self.c2.id],
"filter_has_not_document_types": [self.dt2.id],
"filter_has_not_storage_paths": [self.sp2.id],
"filter_custom_field_query": json.dumps(
["AND", [[self.cf1.id, "exact", "value"]]],
),
"filter_has_correspondent": self.c.id, "filter_has_correspondent": self.c.id,
"filter_has_document_type": self.dt.id, "filter_has_document_type": self.dt.id,
}, },
@@ -393,6 +442,30 @@ class TestApiWorkflows(DirectoriesMixin, APITestCase):
workflow = Workflow.objects.get(id=response.data["id"]) workflow = Workflow.objects.get(id=response.data["id"])
self.assertEqual(workflow.name, "Workflow Updated") self.assertEqual(workflow.name, "Workflow Updated")
self.assertEqual(workflow.triggers.first().filter_has_tags.first(), self.t1) self.assertEqual(workflow.triggers.first().filter_has_tags.first(), self.t1)
self.assertEqual(
workflow.triggers.first().filter_has_all_tags.first(),
self.t2,
)
self.assertEqual(
workflow.triggers.first().filter_has_not_tags.first(),
self.t3,
)
self.assertEqual(
workflow.triggers.first().filter_has_not_correspondents.first(),
self.c2,
)
self.assertEqual(
workflow.triggers.first().filter_has_not_document_types.first(),
self.dt2,
)
self.assertEqual(
workflow.triggers.first().filter_has_not_storage_paths.first(),
self.sp2,
)
self.assertEqual(
workflow.triggers.first().filter_custom_field_query,
json.dumps(["AND", [[self.cf1.id, "exact", "value"]]]),
)
self.assertEqual(workflow.actions.first().assign_title, "Action New Title") self.assertEqual(workflow.actions.first().assign_title, "Action New Title")
def test_api_update_workflow_no_trigger_actions(self): def test_api_update_workflow_no_trigger_actions(self):

View File

@@ -1,4 +1,5 @@
import datetime import datetime
import json
import shutil import shutil
import socket import socket
from datetime import timedelta from datetime import timedelta
@@ -31,6 +32,7 @@ from documents import tasks
from documents.data_models import ConsumableDocument from documents.data_models import ConsumableDocument
from documents.data_models import DocumentSource from documents.data_models import DocumentSource
from documents.matching import document_matches_workflow from documents.matching import document_matches_workflow
from documents.matching import existing_document_matches_workflow
from documents.matching import prefilter_documents_by_workflowtrigger from documents.matching import prefilter_documents_by_workflowtrigger
from documents.models import Correspondent from documents.models import Correspondent
from documents.models import CustomField from documents.models import CustomField
@@ -46,6 +48,7 @@ from documents.models import WorkflowActionEmail
from documents.models import WorkflowActionWebhook from documents.models import WorkflowActionWebhook
from documents.models import WorkflowRun from documents.models import WorkflowRun
from documents.models import WorkflowTrigger from documents.models import WorkflowTrigger
from documents.serialisers import WorkflowTriggerSerializer
from documents.signals import document_consumption_finished from documents.signals import document_consumption_finished
from documents.tests.utils import DirectoriesMixin from documents.tests.utils import DirectoriesMixin
from documents.tests.utils import DummyProgressManager from documents.tests.utils import DummyProgressManager
@@ -1080,9 +1083,409 @@ class TestWorkflows(
) )
expected_str = f"Document did not match {w}" expected_str = f"Document did not match {w}"
self.assertIn(expected_str, cm.output[0]) self.assertIn(expected_str, cm.output[0])
expected_str = f"Document tags {doc.tags.all()} do not include {trigger.filter_has_tags.all()}" expected_str = f"Document tags {list(doc.tags.all())} do not include {list(trigger.filter_has_tags.all())}"
self.assertIn(expected_str, cm.output[1]) self.assertIn(expected_str, cm.output[1])
def test_document_added_no_match_all_tags(self):
trigger = WorkflowTrigger.objects.create(
type=WorkflowTrigger.WorkflowTriggerType.DOCUMENT_ADDED,
)
trigger.filter_has_all_tags.set([self.t1, self.t2])
action = WorkflowAction.objects.create(
assign_title="Doc assign owner",
assign_owner=self.user2,
)
w = Workflow.objects.create(
name="Workflow 1",
order=0,
)
w.triggers.add(trigger)
w.actions.add(action)
w.save()
doc = Document.objects.create(
title="sample test",
correspondent=self.c,
original_filename="sample.pdf",
)
doc.tags.set([self.t1])
doc.save()
with self.assertLogs("paperless.matching", level="DEBUG") as cm:
document_consumption_finished.send(
sender=self.__class__,
document=doc,
)
expected_str = f"Document did not match {w}"
self.assertIn(expected_str, cm.output[0])
expected_str = (
f"Document tags {list(doc.tags.all())} do not contain all of"
f" {list(trigger.filter_has_all_tags.all())}"
)
self.assertIn(expected_str, cm.output[1])
def test_document_added_excluded_tags(self):
trigger = WorkflowTrigger.objects.create(
type=WorkflowTrigger.WorkflowTriggerType.DOCUMENT_ADDED,
)
trigger.filter_has_not_tags.set([self.t3])
action = WorkflowAction.objects.create(
assign_title="Doc assign owner",
assign_owner=self.user2,
)
w = Workflow.objects.create(
name="Workflow 1",
order=0,
)
w.triggers.add(trigger)
w.actions.add(action)
w.save()
doc = Document.objects.create(
title="sample test",
correspondent=self.c,
original_filename="sample.pdf",
)
doc.tags.set([self.t3])
doc.save()
with self.assertLogs("paperless.matching", level="DEBUG") as cm:
document_consumption_finished.send(
sender=self.__class__,
document=doc,
)
expected_str = f"Document did not match {w}"
self.assertIn(expected_str, cm.output[0])
expected_str = (
f"Document tags {list(doc.tags.all())} include excluded tags"
f" {list(trigger.filter_has_not_tags.all())}"
)
self.assertIn(expected_str, cm.output[1])
def test_document_added_excluded_correspondent(self):
trigger = WorkflowTrigger.objects.create(
type=WorkflowTrigger.WorkflowTriggerType.DOCUMENT_ADDED,
)
trigger.filter_has_not_correspondents.set([self.c])
action = WorkflowAction.objects.create(
assign_title="Doc assign owner",
assign_owner=self.user2,
)
w = Workflow.objects.create(
name="Workflow 1",
order=0,
)
w.triggers.add(trigger)
w.actions.add(action)
w.save()
doc = Document.objects.create(
title="sample test",
correspondent=self.c,
original_filename="sample.pdf",
)
with self.assertLogs("paperless.matching", level="DEBUG") as cm:
document_consumption_finished.send(
sender=self.__class__,
document=doc,
)
expected_str = f"Document did not match {w}"
self.assertIn(expected_str, cm.output[0])
expected_str = (
f"Document correspondent {doc.correspondent} is excluded by"
f" {list(trigger.filter_has_not_correspondents.all())}"
)
self.assertIn(expected_str, cm.output[1])
def test_document_added_excluded_document_types(self):
trigger = WorkflowTrigger.objects.create(
type=WorkflowTrigger.WorkflowTriggerType.DOCUMENT_ADDED,
)
trigger.filter_has_not_document_types.set([self.dt])
action = WorkflowAction.objects.create(
assign_title="Doc assign owner",
assign_owner=self.user2,
)
w = Workflow.objects.create(
name="Workflow 1",
order=0,
)
w.triggers.add(trigger)
w.actions.add(action)
w.save()
doc = Document.objects.create(
title="sample test",
document_type=self.dt,
original_filename="sample.pdf",
)
with self.assertLogs("paperless.matching", level="DEBUG") as cm:
document_consumption_finished.send(
sender=self.__class__,
document=doc,
)
expected_str = f"Document did not match {w}"
self.assertIn(expected_str, cm.output[0])
expected_str = (
f"Document doc type {doc.document_type} is excluded by"
f" {list(trigger.filter_has_not_document_types.all())}"
)
self.assertIn(expected_str, cm.output[1])
def test_document_added_excluded_storage_paths(self):
trigger = WorkflowTrigger.objects.create(
type=WorkflowTrigger.WorkflowTriggerType.DOCUMENT_ADDED,
)
trigger.filter_has_not_storage_paths.set([self.sp])
action = WorkflowAction.objects.create(
assign_title="Doc assign owner",
assign_owner=self.user2,
)
w = Workflow.objects.create(
name="Workflow 1",
order=0,
)
w.triggers.add(trigger)
w.actions.add(action)
w.save()
doc = Document.objects.create(
title="sample test",
storage_path=self.sp,
original_filename="sample.pdf",
)
with self.assertLogs("paperless.matching", level="DEBUG") as cm:
document_consumption_finished.send(
sender=self.__class__,
document=doc,
)
expected_str = f"Document did not match {w}"
self.assertIn(expected_str, cm.output[0])
expected_str = (
f"Document storage path {doc.storage_path} is excluded by"
f" {list(trigger.filter_has_not_storage_paths.all())}"
)
self.assertIn(expected_str, cm.output[1])
def test_document_added_custom_field_query_no_match(self):
trigger = WorkflowTrigger.objects.create(
type=WorkflowTrigger.WorkflowTriggerType.DOCUMENT_ADDED,
filter_custom_field_query=json.dumps(
[
"AND",
[[self.cf1.id, "exact", "expected"]],
],
),
)
action = WorkflowAction.objects.create(
assign_title="Doc assign owner",
assign_owner=self.user2,
)
workflow = Workflow.objects.create(name="Workflow 1", order=0)
workflow.triggers.add(trigger)
workflow.actions.add(action)
workflow.save()
doc = Document.objects.create(
title="sample test",
correspondent=self.c,
original_filename="sample.pdf",
)
CustomFieldInstance.objects.create(
document=doc,
field=self.cf1,
value_text="other",
)
with self.assertLogs("paperless.matching", level="DEBUG") as cm:
document_consumption_finished.send(
sender=self.__class__,
document=doc,
)
expected_str = f"Document did not match {workflow}"
self.assertIn(expected_str, cm.output[0])
self.assertIn(
"Document custom fields do not match the configured custom field query",
cm.output[1],
)
def test_document_added_custom_field_query_match(self):
trigger = WorkflowTrigger.objects.create(
type=WorkflowTrigger.WorkflowTriggerType.DOCUMENT_ADDED,
filter_custom_field_query=json.dumps(
[
"AND",
[[self.cf1.id, "exact", "expected"]],
],
),
)
doc = Document.objects.create(
title="sample test",
correspondent=self.c,
original_filename="sample.pdf",
)
CustomFieldInstance.objects.create(
document=doc,
field=self.cf1,
value_text="expected",
)
matched, reason = existing_document_matches_workflow(doc, trigger)
self.assertTrue(matched)
self.assertIsNone(reason)
def test_prefilter_documents_custom_field_query(self):
trigger = WorkflowTrigger.objects.create(
type=WorkflowTrigger.WorkflowTriggerType.DOCUMENT_ADDED,
filter_custom_field_query=json.dumps(
[
"AND",
[[self.cf1.id, "exact", "match"]],
],
),
)
doc1 = Document.objects.create(
title="doc 1",
correspondent=self.c,
original_filename="doc1.pdf",
checksum="checksum1",
)
CustomFieldInstance.objects.create(
document=doc1,
field=self.cf1,
value_text="match",
)
doc2 = Document.objects.create(
title="doc 2",
correspondent=self.c,
original_filename="doc2.pdf",
checksum="checksum2",
)
CustomFieldInstance.objects.create(
document=doc2,
field=self.cf1,
value_text="different",
)
filtered = prefilter_documents_by_workflowtrigger(
Document.objects.all(),
trigger,
)
self.assertIn(doc1, filtered)
self.assertNotIn(doc2, filtered)
def test_consumption_trigger_requires_filter_configuration(self):
serializer = WorkflowTriggerSerializer(
data={
"type": WorkflowTrigger.WorkflowTriggerType.CONSUMPTION,
},
)
self.assertFalse(serializer.is_valid())
errors = serializer.errors.get("non_field_errors", [])
self.assertIn(
"File name, path or mail rule filter are required",
[str(error) for error in errors],
)
def test_workflow_trigger_serializer_clears_empty_custom_field_query(self):
serializer = WorkflowTriggerSerializer(
data={
"type": WorkflowTrigger.WorkflowTriggerType.DOCUMENT_ADDED,
"filter_custom_field_query": "",
},
)
self.assertTrue(serializer.is_valid(), serializer.errors)
self.assertIsNone(serializer.validated_data.get("filter_custom_field_query"))
def test_existing_document_invalid_custom_field_query_configuration(self):
trigger = WorkflowTrigger.objects.create(
type=WorkflowTrigger.WorkflowTriggerType.DOCUMENT_ADDED,
filter_custom_field_query="{ not json",
)
document = Document.objects.create(
title="doc invalid query",
original_filename="invalid.pdf",
checksum="checksum-invalid-query",
)
matched, reason = existing_document_matches_workflow(document, trigger)
self.assertFalse(matched)
self.assertEqual(reason, "Invalid custom field query configuration")
def test_prefilter_documents_returns_none_for_invalid_custom_field_query(self):
trigger = WorkflowTrigger.objects.create(
type=WorkflowTrigger.WorkflowTriggerType.DOCUMENT_ADDED,
filter_custom_field_query="{ not json",
)
Document.objects.create(
title="doc",
original_filename="doc.pdf",
checksum="checksum-prefilter-invalid",
)
filtered = prefilter_documents_by_workflowtrigger(
Document.objects.all(),
trigger,
)
self.assertEqual(list(filtered), [])
def test_prefilter_documents_applies_all_filters(self):
other_document_type = DocumentType.objects.create(name="Other Type")
other_storage_path = StoragePath.objects.create(
name="Blocked path",
path="/blocked/",
)
trigger = WorkflowTrigger.objects.create(
type=WorkflowTrigger.WorkflowTriggerType.DOCUMENT_ADDED,
filter_has_correspondent=self.c,
filter_has_document_type=self.dt,
filter_has_storage_path=self.sp,
)
trigger.filter_has_tags.set([self.t1])
trigger.filter_has_all_tags.set([self.t1, self.t2])
trigger.filter_has_not_tags.set([self.t3])
trigger.filter_has_not_correspondents.set([self.c2])
trigger.filter_has_not_document_types.set([other_document_type])
trigger.filter_has_not_storage_paths.set([other_storage_path])
allowed_document = Document.objects.create(
title="allowed",
correspondent=self.c,
document_type=self.dt,
storage_path=self.sp,
original_filename="allow.pdf",
checksum="checksum-prefilter-allowed",
)
allowed_document.tags.set([self.t1, self.t2])
blocked_document = Document.objects.create(
title="blocked",
correspondent=self.c2,
document_type=other_document_type,
storage_path=other_storage_path,
original_filename="block.pdf",
checksum="checksum-prefilter-blocked",
)
blocked_document.tags.set([self.t1, self.t3])
filtered = prefilter_documents_by_workflowtrigger(
Document.objects.all(),
trigger,
)
self.assertIn(allowed_document, filtered)
self.assertNotIn(blocked_document, filtered)
def test_document_added_no_match_doctype(self): def test_document_added_no_match_doctype(self):
trigger = WorkflowTrigger.objects.create( trigger = WorkflowTrigger.objects.create(
type=WorkflowTrigger.WorkflowTriggerType.DOCUMENT_ADDED, type=WorkflowTrigger.WorkflowTriggerType.DOCUMENT_ADDED,