mirror of
				https://github.com/paperless-ngx/paperless-ngx.git
				synced 2025-10-26 03:36:08 -05:00 
			
		
		
		
	Compare commits
	
		
			1 Commits
		
	
	
		
			7f74348164
			...
			feature-tr
		
	
	| Author | SHA1 | Date | |
|---|---|---|---|
|   | 46345376b0 | 
							
								
								
									
										18
									
								
								Dockerfile
									
									
									
									
									
								
							
							
						
						
									
										18
									
								
								Dockerfile
									
									
									
									
									
								
							| @@ -5,7 +5,7 @@ | ||||
| # Purpose: Compiles the frontend | ||||
| # Notes: | ||||
| #  - Does PNPM stuff with Typescript and such | ||||
| FROM --platform=$BUILDPLATFORM docker.io/node:20-bookworm-slim AS compile-frontend | ||||
| FROM --platform=$BUILDPLATFORM docker.io/node:20-trixie-slim AS compile-frontend | ||||
|  | ||||
| COPY ./src-ui /src/src-ui | ||||
|  | ||||
| @@ -170,20 +170,8 @@ RUN set -eux \ | ||||
|     && apt-get update \ | ||||
|     && apt-get install --yes --quiet --no-install-recommends ${RUNTIME_PACKAGES} \ | ||||
|     && echo "Installing pre-built updates" \ | ||||
|       && curl --fail --silent --no-progress-meter --show-error --location --remote-name-all --parallel --parallel-max 4 \ | ||||
|         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 \ | ||||
|       && curl --fail --silent --no-progress-meter --show-error --location --remote-name-all \ | ||||
|         https://github.com/paperless-ngx/builder/releases/download/jbig2enc-v${JBIG2ENC_VERSION}/jbig2enc_${JBIG2ENC_VERSION}-1_${TARGETARCH}.deb \ | ||||
|       && echo "Installing jbig2enc" \ | ||||
|         && dpkg --install ./jbig2enc_${JBIG2ENC_VERSION}-1_${TARGETARCH}.deb \ | ||||
|       && echo "Configuring imagemagick" \ | ||||
|   | ||||
							
								
								
									
										319
									
								
								dev.txt
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										319
									
								
								dev.txt
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,319 @@ | ||||
| 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 | ||||
| @@ -462,24 +462,15 @@ flowchart TD | ||||
| Workflows allow you to filter by: | ||||
|  | ||||
| -   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 | ||||
|     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. | ||||
| -   Content matching (`Added`, `Updated` and `Scheduled` triggers only). Filter document content using the matching settings. | ||||
|  | ||||
| There are also 'advanced' filters available for `Added`, `Updated` and `Scheduled` triggers: | ||||
|  | ||||
| -   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). | ||||
| -   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 | ||||
| -   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 | ||||
|  | ||||
| ### Workflow Actions | ||||
|  | ||||
|   | ||||
| @@ -1,36 +1,28 @@ | ||||
| @if (useDropdown) { | ||||
|   <div class="btn-group w-100" role="group" ngbDropdown #dropdown="ngbDropdown" (openChange)="onOpenChange($event)" [popperOptions]="popperOptions"> | ||||
|     <button class="btn btn-sm btn-outline-primary" id="dropdown_toggle" ngbDropdownToggle [disabled]="disabled"> | ||||
|       <i-bs name="{{icon}}"></i-bs> | ||||
|       <div class="d-none d-sm-inline"> {{title}}</div> | ||||
|       @if (isActive) { | ||||
|         <pngx-clearable-badge [selected]="isActive" (cleared)="reset()"></pngx-clearable-badge> | ||||
| <div class="btn-group w-100" role="group" ngbDropdown #dropdown="ngbDropdown" (openChange)="onOpenChange($event)" [popperOptions]="popperOptions"> | ||||
|   <button class="btn btn-sm btn-outline-primary" id="dropdown_toggle" ngbDropdownToggle [disabled]="disabled"> | ||||
|     <i-bs name="{{icon}}"></i-bs> | ||||
|     <div class="d-none d-sm-inline"> {{title}}</div> | ||||
|     @if (isActive) { | ||||
|       <pngx-clearable-badge [selected]="isActive" (cleared)="reset()"></pngx-clearable-badge> | ||||
|     } | ||||
|   </button> | ||||
|   <div class="px-3 shadow" ngbDropdownMenu attr.aria-labelledby="dropdown_{{name}}"> | ||||
|     <div class="list-group list-group-flush"> | ||||
|       @for (element of selectionModel.queries; track element.id; let i = $index) { | ||||
|         <div class="list-group-item px-0 d-flex flex-nowrap"> | ||||
|           @switch (element.type) { | ||||
|             @case (CustomFieldQueryComponentType.Atom) { | ||||
|               <ng-container *ngTemplateOutlet="queryAtom; context: { atom: element }"></ng-container> | ||||
|             } | ||||
|             @case (CustomFieldQueryComponentType.Expression) { | ||||
|               <ng-container *ngTemplateOutlet="queryExpression; context: { expression: element }"></ng-container> | ||||
|             } | ||||
|           } | ||||
|         </div> | ||||
|       } | ||||
|     </button> | ||||
|     <div class="px-3 shadow" ngbDropdownMenu attr.aria-labelledby="dropdown_{{name}}"> | ||||
|       <ng-container *ngTemplateOutlet="list; context: { queries: selectionModel.queries }"></ng-container> | ||||
|     </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> | ||||
| </div> | ||||
|  | ||||
| <ng-template #comparisonValueTemplate let-atom="atom"> | ||||
|   @if (getCustomFieldByID(atom.field)?.data_type === CustomFieldDataType.Date) { | ||||
|   | ||||
| @@ -120,12 +120,6 @@ export class CustomFieldQueriesModel { | ||||
|     }) | ||||
|   } | ||||
|  | ||||
|   addInitialAtom() { | ||||
|     this.addAtom( | ||||
|       new CustomFieldQueryAtom([null, CustomFieldQueryOperator.Exists, 'true']) | ||||
|     ) | ||||
|   } | ||||
|  | ||||
|   private findElement( | ||||
|     queryElement: CustomFieldQueryElement, | ||||
|     elements: any[] | ||||
| @@ -212,9 +206,6 @@ export class CustomFieldsQueryDropdownComponent extends LoadingComponentWithPerm | ||||
|   @Input() | ||||
|   applyOnClose = false | ||||
|  | ||||
|   @Input() | ||||
|   useDropdown: boolean = true | ||||
|  | ||||
|   get name(): string { | ||||
|     return this.title ? this.title.replace(/\s/g, '_').toLowerCase() : null | ||||
|   } | ||||
| @@ -267,7 +258,13 @@ export class CustomFieldsQueryDropdownComponent extends LoadingComponentWithPerm | ||||
|   public onOpenChange(open: boolean) { | ||||
|     if (open) { | ||||
|       if (this.selectionModel.queries.length === 0) { | ||||
|         this.selectionModel.addInitialAtom() | ||||
|         this.selectionModel.addAtom( | ||||
|           new CustomFieldQueryAtom([ | ||||
|             null, | ||||
|             CustomFieldQueryOperator.Exists, | ||||
|             'true', | ||||
|           ]) | ||||
|         ) | ||||
|       } | ||||
|       if ( | ||||
|         this.selectionModel.queries.length === 1 && | ||||
|   | ||||
| @@ -156,97 +156,31 @@ | ||||
|     <p class="small" i18n>Trigger for documents that match <em>all</em> filters specified below.</p> | ||||
|     <div class="row"> | ||||
|       <div class="col"> | ||||
|         <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> | ||||
|         <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> | ||||
|         @if (formGroup.get('type').value === WorkflowTriggerType.Consumption) { | ||||
|           <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" 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" 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> | ||||
|           <pngx-input-select i18n-title title="Filter sources" [items]="sourceOptions" [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-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> | ||||
|         } | ||||
|         @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" horizontal="true" [items]="getMatchingAlgorithms()" formControlName="matching_algorithm"></pngx-input-select> | ||||
|           @if (matchingPatternRequired(formGroup)) { | ||||
|             <pngx-input-text i18n-title title="Content matching pattern" horizontal="true" formControlName="match" [error]="error?.match"></pngx-input-text> | ||||
|           <pngx-input-select i18n-title title="Content matching algorithm" [items]="getMatchingAlgorithms()" formControlName="matching_algorithm"></pngx-input-select> | ||||
|           @if (patternRequired) { | ||||
|             <pngx-input-text i18n-title title="Content matching pattern" formControlName="match" [error]="error?.match"></pngx-input-text> | ||||
|           } | ||||
|           @if (matchingPatternRequired(formGroup)) { | ||||
|             <pngx-input-check i18n-title title="Case insensitive" horizontal="true" formControlName="is_insensitive"></pngx-input-check> | ||||
|           @if (patternRequired) { | ||||
|             <pngx-input-check i18n-title title="Case insensitive" 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="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> <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> | ||||
|       @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> | ||||
|   </div> | ||||
| </ng-template> | ||||
|  | ||||
|   | ||||
| @@ -7,7 +7,3 @@ | ||||
| .accordion-button { | ||||
|     font-size: 1rem; | ||||
| } | ||||
|  | ||||
| :host ::ng-deep .filters .paperless-input-select.mb-3 { | ||||
|     margin-bottom: 0 !important; | ||||
| } | ||||
|   | ||||
| @@ -11,14 +11,8 @@ import { | ||||
| import { NgbActiveModal, NgbModule } from '@ng-bootstrap/ng-bootstrap' | ||||
| import { NgSelectModule } from '@ng-select/ng-select' | ||||
| 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 { CustomFieldQueryLogicalOperator } from 'src/app/data/custom-field-query' | ||||
| import { | ||||
|   MATCHING_ALGORITHMS, | ||||
|   MATCH_AUTO, | ||||
|   MATCH_NONE, | ||||
| } from 'src/app/data/matching-model' | ||||
| import { MATCHING_ALGORITHMS, MATCH_AUTO } from 'src/app/data/matching-model' | ||||
| import { Workflow } from 'src/app/data/workflow' | ||||
| import { | ||||
|   WorkflowAction, | ||||
| @@ -37,7 +31,6 @@ import { DocumentTypeService } from 'src/app/services/rest/document-type.service | ||||
| import { MailRuleService } from 'src/app/services/rest/mail-rule.service' | ||||
| import { StoragePathService } from 'src/app/services/rest/storage-path.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 { NumberComponent } from '../../input/number/number.component' | ||||
| import { PermissionsGroupComponent } from '../../input/permissions/permissions-group/permissions-group.component' | ||||
| @@ -50,7 +43,6 @@ import { EditDialogMode } from '../edit-dialog.component' | ||||
| import { | ||||
|   DOCUMENT_SOURCE_OPTIONS, | ||||
|   SCHEDULE_DATE_FIELD_OPTIONS, | ||||
|   TriggerFilterType, | ||||
|   WORKFLOW_ACTION_OPTIONS, | ||||
|   WORKFLOW_TYPE_OPTIONS, | ||||
|   WorkflowEditDialogComponent, | ||||
| @@ -383,562 +375,6 @@ describe('WorkflowEditDialogComponent', () => { | ||||
|     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', () => { | ||||
|     const formGroup = new FormGroup({ | ||||
|       assign_custom_fields: new FormControl([1, 2, 3]), | ||||
|   | ||||
| @@ -6,7 +6,6 @@ import { | ||||
| import { NgTemplateOutlet } from '@angular/common' | ||||
| import { Component, OnInit, inject } from '@angular/core' | ||||
| import { | ||||
|   AbstractControl, | ||||
|   FormArray, | ||||
|   FormControl, | ||||
|   FormGroup, | ||||
| @@ -15,7 +14,7 @@ import { | ||||
| } from '@angular/forms' | ||||
| import { NgbAccordionModule } from '@ng-bootstrap/ng-bootstrap' | ||||
| import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons' | ||||
| import { Subscription, first, takeUntil } from 'rxjs' | ||||
| import { first } from 'rxjs' | ||||
| import { Correspondent } from 'src/app/data/correspondent' | ||||
| import { CustomField, CustomFieldDataType } from 'src/app/data/custom-field' | ||||
| import { DocumentType } from 'src/app/data/document-type' | ||||
| @@ -46,12 +45,7 @@ import { StoragePathService } from 'src/app/services/rest/storage-path.service' | ||||
| import { UserService } from 'src/app/services/rest/user.service' | ||||
| import { WorkflowService } from 'src/app/services/rest/workflow.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 { | ||||
|   CustomFieldQueriesModel, | ||||
|   CustomFieldsQueryDropdownComponent, | ||||
| } from '../../custom-fields-query-dropdown/custom-fields-query-dropdown.component' | ||||
| import { CheckComponent } from '../../input/check/check.component' | ||||
| import { CustomFieldsValuesComponent } from '../../input/custom-fields-values/custom-fields-values.component' | ||||
| import { EntriesComponent } from '../../input/entries/entries.component' | ||||
| @@ -141,235 +135,10 @@ 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( | ||||
|   (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({ | ||||
|   selector: 'pngx-workflow-edit-dialog', | ||||
|   templateUrl: './workflow-edit-dialog.component.html', | ||||
| @@ -384,7 +153,6 @@ const FILTER_HANDLERS: Record<TriggerFilterType, FilterHandler> = { | ||||
|     TextAreaComponent, | ||||
|     TagsComponent, | ||||
|     CustomFieldsValuesComponent, | ||||
|     CustomFieldsQueryDropdownComponent, | ||||
|     PermissionsGroupComponent, | ||||
|     PermissionsUserComponent, | ||||
|     ConfirmButtonComponent, | ||||
| @@ -402,8 +170,6 @@ export class WorkflowEditDialogComponent | ||||
| { | ||||
|   public WorkflowTriggerType = WorkflowTriggerType | ||||
|   public WorkflowActionType = WorkflowActionType | ||||
|   public TriggerFilterType = TriggerFilterType | ||||
|   public filterDefinitions = TRIGGER_FILTER_DEFINITIONS | ||||
|  | ||||
|   private correspondentService: CorrespondentService | ||||
|   private documentTypeService: DocumentTypeService | ||||
| @@ -423,11 +189,6 @@ export class WorkflowEditDialogComponent | ||||
|  | ||||
|   private allowedActionTypes = [] | ||||
|  | ||||
|   private readonly triggerFilterOptionsMap = new WeakMap< | ||||
|     FormArray, | ||||
|     TriggerFilterOption[] | ||||
|   >() | ||||
|  | ||||
|   constructor() { | ||||
|     super() | ||||
|     this.service = inject(WorkflowService) | ||||
| @@ -629,416 +390,6 @@ export class WorkflowEditDialogComponent | ||||
|     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( | ||||
|     trigger: WorkflowTrigger, | ||||
|     emitEvent: boolean = false | ||||
| @@ -1054,7 +405,16 @@ export class WorkflowEditDialogComponent | ||||
|         matching_algorithm: new FormControl(trigger.matching_algorithm), | ||||
|         match: new FormControl(trigger.match), | ||||
|         is_insensitive: new FormControl(trigger.is_insensitive), | ||||
|         filters: this.buildFiltersFormArray(trigger), | ||||
|         filter_has_tags: new FormControl(trigger.filter_has_tags), | ||||
|         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_is_recurring: new FormControl(trigger.schedule_is_recurring), | ||||
|         schedule_recurring_interval_days: new FormControl( | ||||
| @@ -1177,12 +537,6 @@ export class WorkflowEditDialogComponent | ||||
|       filter_path: null, | ||||
|       filter_mailrule: null, | ||||
|       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_document_type: null, | ||||
|       filter_has_storage_path: null, | ||||
|   | ||||
| @@ -1,68 +1,66 @@ | ||||
| <div class="mb-3 paperless-input-select" [class.disabled]="disabled"> | ||||
|   <div class="row"> | ||||
|     @if (title || removable) { | ||||
|       <div class="d-flex align-items-center position-relative hidden-button-container" [class.col-md-3]="horizontal"> | ||||
|         @if (title) { | ||||
|           <label class="form-label" [class.mb-md-0]="horizontal" [for]="inputId">{{title}}</label> | ||||
|     <div class="d-flex align-items-center position-relative hidden-button-container" [class.col-md-3]="horizontal"> | ||||
|       @if (title) { | ||||
|         <label class="form-label" [class.mb-md-0]="horizontal" [for]="inputId">{{title}}</label> | ||||
|       } | ||||
|       @if (removable) { | ||||
|         <button type="button" class="btn btn-sm btn-danger position-absolute left-0" (click)="removed.emit(this)"> | ||||
|           <i-bs  name="x"></i-bs> <ng-container i18n>Remove</ng-container> | ||||
|           </button> | ||||
|         } | ||||
|         @if (removable) { | ||||
|           <button type="button" class="btn btn-sm btn-danger position-absolute left-0" (click)="removed.emit(this)"> | ||||
|             <i-bs  name="x"></i-bs> <ng-container i18n>Remove</ng-container> | ||||
|       </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> | ||||
|         } | ||||
|       </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> | ||||
|         } | ||||
|       </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>  | ||||
|           @for (s of getSuggestions(); track s) { | ||||
|             <a (click)="value = s.id; onChange(value)" [routerLink]="[]">{{s.name}}</a>  | ||||
|           } | ||||
|         </small> | ||||
|       } | ||||
|           @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>  | ||||
|             @for (s of getSuggestions(); track s) { | ||||
|               <a (click)="value = s.id; onChange(value)" [routerLink]="[]">{{s.name}}</a>  | ||||
|             } | ||||
|           </small> | ||||
|         } | ||||
|       </div> | ||||
|     </div> | ||||
|   </div> | ||||
| </div> | ||||
|   | ||||
| @@ -1,10 +1,8 @@ | ||||
| <div class="mb-3 paperless-input-select paperless-input-tags" [class.disabled]="disabled" [class.pb-3]="getSuggestions().length > 0"> | ||||
|   <div class="row"> | ||||
|     @if (title) { | ||||
|       <div class="d-flex align-items-center" [class.col-md-3]="horizontal"> | ||||
|         <label class="form-label" [class.mb-md-0]="horizontal" for="tags">{{title}}</label> | ||||
|       </div> | ||||
|     } | ||||
|     <div class="d-flex align-items-center" [class.col-md-3]="horizontal"> | ||||
|       <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="input-group flex-nowrap"> | ||||
|         <ng-select #tagSelect name="tags" [items]="tags" bindLabel="name" bindValue="id" [(ngModel)]="value" | ||||
|   | ||||
| @@ -40,18 +40,6 @@ export interface WorkflowTrigger extends ObjectWithId { | ||||
|  | ||||
|   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_document_type?: number // DocumentType.id | ||||
|   | ||||
| @@ -6,11 +6,8 @@ from fnmatch import fnmatch | ||||
| from fnmatch import translate as fnmatch_translate | ||||
| from typing import TYPE_CHECKING | ||||
|  | ||||
| from rest_framework import serializers | ||||
|  | ||||
| from documents.data_models import ConsumableDocument | ||||
| from documents.data_models import DocumentSource | ||||
| from documents.filters import CustomFieldQueryParser | ||||
| from documents.models import Correspondent | ||||
| from documents.models import Document | ||||
| from documents.models import DocumentType | ||||
| @@ -345,147 +342,67 @@ def consumable_document_matches_workflow( | ||||
| def existing_document_matches_workflow( | ||||
|     document: Document, | ||||
|     trigger: WorkflowTrigger, | ||||
| ) -> tuple[bool, str | None]: | ||||
| ) -> tuple[bool, str]: | ||||
|     """ | ||||
|     Returns True if the Document matches all filters from the workflow trigger, | ||||
|     False otherwise. Includes a reason if doesn't match | ||||
|     """ | ||||
|  | ||||
|     # Check content matching algorithm | ||||
|     trigger_matched = True | ||||
|     reason = "" | ||||
|  | ||||
|     if trigger.matching_algorithm > MatchingModel.MATCH_NONE and not matches( | ||||
|         trigger, | ||||
|         document, | ||||
|     ): | ||||
|         return ( | ||||
|             False, | ||||
|         reason = ( | ||||
|             f"Document content matching settings for algorithm '{trigger.matching_algorithm}' did not match", | ||||
|         ) | ||||
|         trigger_matched = False | ||||
|  | ||||
|     # Check if any tag filters exist to determine if we need to load document tags | ||||
|     trigger_has_tags_qs = trigger.filter_has_tags.all() | ||||
|     trigger_has_all_tags_qs = trigger.filter_has_all_tags.all() | ||||
|     trigger_has_not_tags_qs = trigger.filter_has_not_tags.all() | ||||
|  | ||||
|     has_tags_filter = trigger_has_tags_qs.exists() | ||||
|     has_all_tags_filter = trigger_has_all_tags_qs.exists() | ||||
|     has_not_tags_filter = trigger_has_not_tags_qs.exists() | ||||
|  | ||||
|     # Load document tags once if any tag filters exist | ||||
|     document_tag_ids = None | ||||
|     if has_tags_filter or has_all_tags_filter or has_not_tags_filter: | ||||
|         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 tags vs trigger has_tags | ||||
|     if ( | ||||
|         trigger.filter_has_tags.all().count() > 0 | ||||
|         and document.tags.filter( | ||||
|             id__in=trigger.filter_has_tags.all().values_list("id"), | ||||
|         ).count() | ||||
|         == 0 | ||||
|     ): | ||||
|         reason = ( | ||||
|             f"Document tags {document.tags.all()} do not include" | ||||
|             f" {trigger.filter_has_tags.all()}", | ||||
|         ) | ||||
|         trigger_matched = False | ||||
|  | ||||
|     # Document correspondent vs trigger has_correspondent | ||||
|     if ( | ||||
|         trigger.filter_has_correspondent_id is not None | ||||
|         and document.correspondent_id != trigger.filter_has_correspondent_id | ||||
|         trigger.filter_has_correspondent is not None | ||||
|         and document.correspondent != trigger.filter_has_correspondent | ||||
|     ): | ||||
|         return ( | ||||
|             False, | ||||
|         reason = ( | ||||
|             f"Document correspondent {document.correspondent} does not match {trigger.filter_has_correspondent}", | ||||
|         ) | ||||
|  | ||||
|     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())}", | ||||
|         ) | ||||
|         trigger_matched = False | ||||
|  | ||||
|     # Document document_type vs trigger has_document_type | ||||
|     if ( | ||||
|         trigger.filter_has_document_type_id is not None | ||||
|         and document.document_type_id != trigger.filter_has_document_type_id | ||||
|         trigger.filter_has_document_type is not None | ||||
|         and document.document_type != trigger.filter_has_document_type | ||||
|     ): | ||||
|         return ( | ||||
|             False, | ||||
|         reason = ( | ||||
|             f"Document doc type {document.document_type} does not match {trigger.filter_has_document_type}", | ||||
|         ) | ||||
|  | ||||
|     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())}", | ||||
|         ) | ||||
|         trigger_matched = False | ||||
|  | ||||
|     # Document storage_path vs trigger has_storage_path | ||||
|     if ( | ||||
|         trigger.filter_has_storage_path_id is not None | ||||
|         and document.storage_path_id != trigger.filter_has_storage_path_id | ||||
|         trigger.filter_has_storage_path is not None | ||||
|         and document.storage_path != trigger.filter_has_storage_path | ||||
|     ): | ||||
|         return ( | ||||
|             False, | ||||
|         reason = ( | ||||
|             f"Document storage path {document.storage_path} does not match {trigger.filter_has_storage_path}", | ||||
|         ) | ||||
|  | ||||
|     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", | ||||
|             ) | ||||
|         trigger_matched = False | ||||
|  | ||||
|     # Document original_filename vs trigger filename | ||||
|     if ( | ||||
| @@ -497,12 +414,13 @@ def existing_document_matches_workflow( | ||||
|             trigger.filter_filename.lower(), | ||||
|         ) | ||||
|     ): | ||||
|         return ( | ||||
|             False, | ||||
|             f"Document filename {document.original_filename} does not match {trigger.filter_filename.lower()}", | ||||
|         reason = ( | ||||
|             f"Document filename {document.original_filename} does not match" | ||||
|             f" {trigger.filter_filename.lower()}", | ||||
|         ) | ||||
|         trigger_matched = False | ||||
|  | ||||
|     return (True, None) | ||||
|     return (trigger_matched, reason) | ||||
|  | ||||
|  | ||||
| def prefilter_documents_by_workflowtrigger( | ||||
| @@ -515,66 +433,31 @@ def prefilter_documents_by_workflowtrigger( | ||||
|     document_matches_workflow in run_workflows | ||||
|     """ | ||||
|  | ||||
|     # Filter for documents that have AT LEAST ONE of the specified tags. | ||||
|     if trigger.filter_has_tags.exists(): | ||||
|         documents = documents.filter(tags__in=trigger.filter_has_tags.all()).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_tags.all().count() > 0: | ||||
|         documents = documents.filter( | ||||
|             tags__in=trigger.filter_has_tags.all(), | ||||
|         ).distinct() | ||||
|  | ||||
|     if trigger.filter_has_correspondent is not None: | ||||
|         documents = documents.filter( | ||||
|             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: | ||||
|         documents = documents.filter( | ||||
|             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: | ||||
|         documents = documents.filter( | ||||
|             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(), | ||||
|         ) | ||||
|  | ||||
|     # Custom Field & Filename Filtering | ||||
|  | ||||
|     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: | ||||
|     if trigger.filter_filename is not None and len(trigger.filter_filename) > 0: | ||||
|         # the true fnmatch will actually run later so we just want a loose filter here | ||||
|         regex = fnmatch_translate(trigger.filter_filename).lstrip("^").rstrip("$") | ||||
|         documents = documents.filter(original_filename__iregex=regex) | ||||
|         regex = f"(?i){regex}" | ||||
|         documents = documents.filter(original_filename__regex=regex) | ||||
|  | ||||
|     return documents | ||||
|  | ||||
| @@ -589,34 +472,13 @@ def document_matches_workflow( | ||||
|     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 | ||||
|     if not triggers_queryset.exists(): | ||||
|     if workflow.triggers.filter(type=trigger_type).count() == 0: | ||||
|         trigger_matched = False | ||||
|         logger.info(f"Document did not match {workflow}") | ||||
|         logger.debug(f"No matching triggers with type {trigger_type} found") | ||||
|     else: | ||||
|         for trigger in triggers_queryset: | ||||
|         for trigger in workflow.triggers.filter(type=trigger_type): | ||||
|             if trigger_type == WorkflowTrigger.WorkflowTriggerType.CONSUMPTION: | ||||
|                 trigger_matched, reason = consumable_document_matches_workflow( | ||||
|                     document, | ||||
|   | ||||
| @@ -1,73 +0,0 @@ | ||||
| # 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)", | ||||
|             ), | ||||
|         ), | ||||
|     ] | ||||
| @@ -1065,20 +1065,6 @@ class WorkflowTrigger(models.Model): | ||||
|         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( | ||||
|         DocumentType, | ||||
|         null=True, | ||||
| @@ -1087,13 +1073,6 @@ class WorkflowTrigger(models.Model): | ||||
|         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( | ||||
|         Correspondent, | ||||
|         null=True, | ||||
| @@ -1102,13 +1081,6 @@ class WorkflowTrigger(models.Model): | ||||
|         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( | ||||
|         StoragePath, | ||||
|         null=True, | ||||
| @@ -1117,20 +1089,6 @@ class WorkflowTrigger(models.Model): | ||||
|         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"), | ||||
|         default=0, | ||||
|   | ||||
| @@ -43,7 +43,6 @@ if settings.AUDIT_LOG_ENABLED: | ||||
|  | ||||
| from documents import bulk_edit | ||||
| from documents.data_models import DocumentSource | ||||
| from documents.filters import CustomFieldQueryParser | ||||
| from documents.models import Correspondent | ||||
| from documents.models import CustomField | ||||
| from documents.models import CustomFieldInstance | ||||
| @@ -2195,12 +2194,6 @@ class WorkflowTriggerSerializer(serializers.ModelSerializer): | ||||
|             "match", | ||||
|             "is_insensitive", | ||||
|             "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_document_type", | ||||
|             "filter_has_storage_path", | ||||
| @@ -2226,20 +2219,6 @@ class WorkflowTriggerSerializer(serializers.ModelSerializer): | ||||
|         ): | ||||
|             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)) | ||||
|         if ( | ||||
|             trigger_type == WorkflowTrigger.WorkflowTriggerType.CONSUMPTION | ||||
| @@ -2435,20 +2414,6 @@ class WorkflowSerializer(serializers.ModelSerializer): | ||||
|         if triggers is not None and triggers is not serializers.empty: | ||||
|             for trigger in triggers: | ||||
|                 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 | ||||
|                 WorkflowTriggerSerializer.normalize_workflow_trigger_sources(trigger) | ||||
|                 trigger_instance, _ = WorkflowTrigger.objects.update_or_create( | ||||
| @@ -2457,22 +2422,6 @@ class WorkflowSerializer(serializers.ModelSerializer): | ||||
|                 ) | ||||
|                 if filter_has_tags is not None: | ||||
|                     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) | ||||
|  | ||||
|         if actions is not None and actions is not serializers.empty: | ||||
|   | ||||
| @@ -184,17 +184,6 @@ class TestApiWorkflows(DirectoriesMixin, APITestCase): | ||||
|                             "filter_filename": "*", | ||||
|                             "filter_path": "*/samples/*", | ||||
|                             "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_correspondent": self.c.id, | ||||
|                             "filter_has_storage_path": self.sp.id, | ||||
| @@ -234,36 +223,6 @@ class TestApiWorkflows(DirectoriesMixin, APITestCase): | ||||
|         ) | ||||
|         self.assertEqual(response.status_code, status.HTTP_201_CREATED) | ||||
|         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): | ||||
|         """ | ||||
| @@ -417,14 +376,6 @@ class TestApiWorkflows(DirectoriesMixin, APITestCase): | ||||
|                         { | ||||
|                             "type": WorkflowTrigger.WorkflowTriggerType.DOCUMENT_ADDED, | ||||
|                             "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_document_type": self.dt.id, | ||||
|                         }, | ||||
| @@ -442,30 +393,6 @@ class TestApiWorkflows(DirectoriesMixin, APITestCase): | ||||
|         workflow = Workflow.objects.get(id=response.data["id"]) | ||||
|         self.assertEqual(workflow.name, "Workflow Updated") | ||||
|         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") | ||||
|  | ||||
|     def test_api_update_workflow_no_trigger_actions(self): | ||||
|   | ||||
| @@ -1,5 +1,4 @@ | ||||
| import datetime | ||||
| import json | ||||
| import shutil | ||||
| import socket | ||||
| from datetime import timedelta | ||||
| @@ -32,7 +31,6 @@ from documents import tasks | ||||
| from documents.data_models import ConsumableDocument | ||||
| from documents.data_models import DocumentSource | ||||
| 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.models import Correspondent | ||||
| from documents.models import CustomField | ||||
| @@ -48,7 +46,6 @@ from documents.models import WorkflowActionEmail | ||||
| from documents.models import WorkflowActionWebhook | ||||
| from documents.models import WorkflowRun | ||||
| from documents.models import WorkflowTrigger | ||||
| from documents.serialisers import WorkflowTriggerSerializer | ||||
| from documents.signals import document_consumption_finished | ||||
| from documents.tests.utils import DirectoriesMixin | ||||
| from documents.tests.utils import DummyProgressManager | ||||
| @@ -1083,409 +1080,9 @@ class TestWorkflows( | ||||
|             ) | ||||
|             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 include {list(trigger.filter_has_tags.all())}" | ||||
|             expected_str = f"Document tags {doc.tags.all()} do not include {trigger.filter_has_tags.all()}" | ||||
|             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): | ||||
|         trigger = WorkflowTrigger.objects.create( | ||||
|             type=WorkflowTrigger.WorkflowTriggerType.DOCUMENT_ADDED, | ||||
|   | ||||
		Reference in New Issue
	
	Block a user