Merge remote-tracking branch 'origin/dev' into feature-multiple-barcode-scanners

This commit is contained in:
Trenton H 2023-03-29 09:40:10 -07:00
commit 66929a9088
104 changed files with 2615 additions and 1098 deletions

View File

@ -16,7 +16,7 @@ on:
env: env:
# This is the version of pipenv all the steps will use # This is the version of pipenv all the steps will use
# If changing this, change Dockerfile # If changing this, change Dockerfile
DEFAULT_PIP_ENV_VERSION: "2022.11.30" DEFAULT_PIP_ENV_VERSION: "2023.3.20"
# This is the default version of Python to use in most steps # This is the default version of Python to use in most steps
# If changing this, change Dockerfile # If changing this, change Dockerfile
DEFAULT_PYTHON_VERSION: "3.9" DEFAULT_PYTHON_VERSION: "3.9"

View File

@ -29,7 +29,7 @@ COPY Pipfile* ./
RUN set -eux \ RUN set -eux \
&& echo "Installing pipenv" \ && echo "Installing pipenv" \
&& python3 -m pip install --no-cache-dir --upgrade pipenv==2022.11.30 \ && python3 -m pip install --no-cache-dir --upgrade pipenv==2023.3.20 \
&& echo "Generating requirement.txt" \ && echo "Generating requirement.txt" \
&& pipenv requirements > requirements.txt && pipenv requirements > requirements.txt
@ -46,15 +46,6 @@ LABEL org.opencontainers.image.url="https://github.com/paperless-ngx/paperless-n
LABEL org.opencontainers.image.licenses="GPL-3.0-only" LABEL org.opencontainers.image.licenses="GPL-3.0-only"
ARG DEBIAN_FRONTEND=noninteractive ARG DEBIAN_FRONTEND=noninteractive
# Buildx provided, must be defined to use though
ARG TARGETARCH
ARG TARGETVARIANT
# Workflow provided
ARG JBIG2ENC_VERSION
ARG QPDF_VERSION
ARG PIKEPDF_VERSION
ARG PSYCOPG2_VERSION
# #
# Begin installation and configuration # Begin installation and configuration
@ -175,12 +166,22 @@ RUN set -eux \
&& chmod +x install_management_commands.sh \ && chmod +x install_management_commands.sh \
&& ./install_management_commands.sh && ./install_management_commands.sh
# Buildx provided, must be defined to use though
ARG TARGETARCH
ARG TARGETVARIANT
# Workflow provided, defaults set for manual building
ARG JBIG2ENC_VERSION=0.29
ARG QPDF_VERSION=11.3.0
ARG PIKEPDF_VERSION=7.1.1
ARG PSYCOPG2_VERSION=2.9.5
# Install the built packages from the installer library images # Install the built packages from the installer library images
# These change sometimes # These change sometimes
RUN set -eux \ RUN set -eux \
&& echo "Getting binaries" \ && echo "Getting binaries" \
&& mkdir paperless-ngx \ && mkdir paperless-ngx \
&& curl --fail --silent --show-error --output paperless-ngx.tar.gz --location https://github.com/paperless-ngx/paperless-ngx/archive/40895f1cdb7702d8cd4b9c3b1d1d7a886018ccd2.tar.gz \ && curl --fail --silent --show-error --output paperless-ngx.tar.gz --location https://github.com/paperless-ngx/paperless-ngx/archive/ba28a1e16c27d121b644b4f6bdb78855a2850561.tar.gz \
&& tar -xf paperless-ngx.tar.gz --directory paperless-ngx --strip-components=1 \ && tar -xf paperless-ngx.tar.gz --directory paperless-ngx --strip-components=1 \
&& cd paperless-ngx \ && cd paperless-ngx \
# Setting a specific revision ensures we know what this installed # Setting a specific revision ensures we know what this installed

View File

@ -46,6 +46,7 @@ tika = "*"
# TODO: This will sadly also install daphne+dependencies, # TODO: This will sadly also install daphne+dependencies,
# which an ASGI server we don't need. Adds about 15MB image size. # which an ASGI server we don't need. Adds about 15MB image size.
channels = "~=3.0" channels = "~=3.0"
channels-redis = "*"
uvicorn = {extras = ["standard"], version = "*"} uvicorn = {extras = ["standard"], version = "*"}
concurrent-log-handler = "*" concurrent-log-handler = "*"
"pdfminer.six" = "*" "pdfminer.six" = "*"
@ -57,15 +58,12 @@ nltk = "*"
pdf2image = "*" pdf2image = "*"
flower = "*" flower = "*"
bleach = "*" bleach = "*"
zxing-cpp = {version = "*", platform_machine = "== 'x86_64'"}
# #
# Packages locked due to issues (try to check if these are fixed in a release every so often) # Packages locked due to issues (try to check if these are fixed in a release every so often)
# #
# Pin this until piwheels is building 1.9 (see https://www.piwheels.org/project/scipy/) # Pin this until piwheels is building 1.9 (see https://www.piwheels.org/project/scipy/)
scipy = "==1.8.1" scipy = "==1.8.1"
# Locked version until https://github.com/django/channels_redis/issues/332
# is resolved
channels-redis = "==3.4.1"
zxing-cpp = {version = "*", platform_machine = "== 'x86_64'"}
[dev-packages] [dev-packages]
coveralls = "*" coveralls = "*"

555
Pipfile.lock generated
View File

@ -1,7 +1,7 @@
{ {
"_meta": { "_meta": {
"hash": { "hash": {
"sha256": "d813537b3e32ac288b7a89f85041b1b52b4bf69b349dd0df4a1283dc17ce2275" "sha256": "8395f25f876a71a7307a55dd542e69a4cdcb3be3be38c4e89ed06ce3d52a5345"
}, },
"pipfile-spec": 6, "pipfile-spec": 6,
"requires": {}, "requires": {},
@ -19,13 +19,6 @@
] ]
}, },
"default": { "default": {
"aioredis": {
"hashes": [
"sha256:15f8af30b044c771aee6787e5ec24694c048184c7b9e54c3b60c750a4b93273a",
"sha256:b61808d7e97b7cd5a92ed574937a079c9387fdadd22bfbfa7ad2fd319ecc26e3"
],
"version": "==1.3.1"
},
"amqp": { "amqp": {
"hashes": [ "hashes": [
"sha256:2c1b13fecc0893e946c65cbd5f36427861cffa4ea2201d8f6fca22e2a373b5e2", "sha256:2c1b13fecc0893e946c65cbd5f36427861cffa4ea2201d8f6fca22e2a373b5e2",
@ -55,7 +48,7 @@
"sha256:2163e1640ddb52b7a8c80d0a67a08587e5d245cc9c553a74a847056bc2976b15", "sha256:2163e1640ddb52b7a8c80d0a67a08587e5d245cc9c553a74a847056bc2976b15",
"sha256:8ca1e4fcf50d07413d66d1a5e416e42cfdf5851c981d679a09851a6853383b3c" "sha256:8ca1e4fcf50d07413d66d1a5e416e42cfdf5851c981d679a09851a6853383b3c"
], ],
"markers": "python_version >= '3.6'", "markers": "python_full_version <= '3.11.2'",
"version": "==4.0.2" "version": "==4.0.2"
}, },
"attrs": { "attrs": {
@ -302,11 +295,11 @@
}, },
"channels-redis": { "channels-redis": {
"hashes": [ "hashes": [
"sha256:78e4a2f2b2a744fe5a87848ec36b5ee49f522c6808cefe6c583663d0d531faa8", "sha256:122414f29f525f7b9e0c9d59cdcfc4dc1b0eecba16fbb6a1c23f1d9b58f49dcb",
"sha256:ba7e2ad170f273c372812dd32aaac102d68d4e508172abb1cfda3160b7333890" "sha256:81b59d68f53313e1aa891f23591841b684abb936b42e4d1a966d9e4dc63a95ec"
], ],
"index": "pypi", "index": "pypi",
"version": "==3.4.1" "version": "==4.0.0"
}, },
"charset-normalizer": { "charset-normalizer": {
"hashes": [ "hashes": [
@ -481,11 +474,11 @@
}, },
"dateparser": { "dateparser": {
"hashes": [ "hashes": [
"sha256:fbed8b738a24c9cd7f47c4f2089527926566fe539e1a06125eddba75917b1eef", "sha256:070b29b5bbf4b1ec2cd51c96ea040dc68a614de703910a91ad1abba18f9f379f",
"sha256:ff047d9cffad4d3113ead8ec0faf8a7fc43bab7d853ac8715e071312b53c465a" "sha256:86b8b7517efcc558f085a142cdb7620f0921543fcabdb538c8a4c4001d8178e3"
], ],
"index": "pypi", "index": "pypi",
"version": "==1.1.7" "version": "==1.1.8"
}, },
"deprecation": { "deprecation": {
"hashes": [ "hashes": [
@ -576,11 +569,11 @@
}, },
"filelock": { "filelock": {
"hashes": [ "hashes": [
"sha256:4427cdda14a1c68e264845142842d6de2d0fa2c15ba31571a3d9c9a1ec9d191c", "sha256:75997740323c5f12e18f10b494bc11c03e42843129f980f17c04352cc7b09d40",
"sha256:e393782f76abea324dee598d2ea145b857a20df0e0ee4f80fcf35e72a341d2c7" "sha256:eb8f0f2d37ed68223ea63e3bddf2fac99667e4362c88b3f762e434d160190d18"
], ],
"index": "pypi", "index": "pypi",
"version": "==3.9.1" "version": "==3.10.2"
}, },
"flower": { "flower": {
"hashes": [ "hashes": [
@ -1053,11 +1046,11 @@
}, },
"ocrmypdf": { "ocrmypdf": {
"hashes": [ "hashes": [
"sha256:8fab75052bf77c3488acd9c3054423d9f1f7650e302960a1fa2e991f36c2a66a", "sha256:779b6f77ece5836b4ac703ba02a4bb0ccb758dbb9b4dad1feab3fccd4dba33cf",
"sha256:db03cdd1a5d277fa038b0420ba05fcf7b1f92729ba85431344844ebf01035160" "sha256:c731bd3b6bfd67dc495edc97946f159ba99631854bf7671c2d35c36f30b3ffa8"
], ],
"index": "pypi", "index": "pypi",
"version": "==14.0.3" "version": "==14.0.4"
}, },
"packaging": { "packaging": {
"hashes": [ "hashes": [
@ -1515,105 +1508,77 @@
"hiredis" "hiredis"
], ],
"hashes": [ "hashes": [
"sha256:1eec3741cda408d3a5f84b78d089c8b8d895f21b3b050988351e925faf202864", "sha256:56732e156fe31801c4f43396bd3ca0c2a7f6f83d7936798531b9848d103381aa",
"sha256:5deb072d26e67d2be1712603bfb7947ec3431fb0eec9c578994052e33035af6d" "sha256:7df17a0a2b72a4c8895b462dd07616c51b1dcb48fdd7ecb7b6f4bf39ecb2e94e"
], ],
"index": "pypi", "index": "pypi",
"version": "==4.5.1" "version": "==4.5.3"
}, },
"regex": { "regex": {
"hashes": [ "hashes": [
"sha256:052b670fafbe30966bbe5d025e90b2a491f85dfe5b2583a163b5e60a85a321ad", "sha256:0a2a851d0548a4e298d88e3ceeb4bad4aab751cf1883edf6150f25718ce0207a",
"sha256:0653d012b3bf45f194e5e6a41df9258811ac8fc395579fa82958a8b76286bea4", "sha256:148ad520f41021b97870e9c80420e6cdaadcc5e4306e613aed84cd5d53f8a7ca",
"sha256:0a069c8483466806ab94ea9068c34b200b8bfc66b6762f45a831c4baaa9e8cdd", "sha256:159c7b83488a056365119ada0bceddc06a455d3db7a7aa3cf07f13b2878b885f",
"sha256:0cf0da36a212978be2c2e2e2d04bdff46f850108fccc1851332bcae51c8907cc", "sha256:1937946dd03818845bd9c1713dfd3173a7b9a324e6593a235fc8c51c9cd460eb",
"sha256:131d4be09bea7ce2577f9623e415cab287a3c8e0624f778c1d955ec7c281bd4d", "sha256:20ce96da2093e72e151d6af8217a629aeb5f48f1ac543c2fffd1d87c57699d7e",
"sha256:144486e029793a733e43b2e37df16a16df4ceb62102636ff3db6033994711066", "sha256:24242e5f26823e95edd64969bd206d4752c1a56a744d8cbcf58461f9788bc0c7",
"sha256:1ddf14031a3882f684b8642cb74eea3af93a2be68893901b2b387c5fd92a03ec", "sha256:2e2e6baf4a1108f84966f44870b26766d8f6d104c9959aae329078327c677122",
"sha256:1eba476b1b242620c266edf6325b443a2e22b633217a9835a52d8da2b5c051f9", "sha256:328a70e578f37f59eb54e8450b5042190bbadf2ef7f5c0b60829574b62955ed7",
"sha256:20f61c9944f0be2dc2b75689ba409938c14876c19d02f7585af4460b6a21403e", "sha256:3371975b165c1e859e1990e5069e8606f00b25aed961cfd25b7bac626b1eb5a9",
"sha256:22960019a842777a9fa5134c2364efaed5fbf9610ddc5c904bd3a400973b0eb8", "sha256:33bab9c9af936123b70b9874ce83f2bcd54be76b97637b33d31560fba8ad5d78",
"sha256:22e7ebc231d28393dfdc19b185d97e14a0f178bedd78e85aad660e93b646604e", "sha256:33c887b658afb144cdc8ce9156a0e1098453060c18b8bd5177f831ad58e0d60d",
"sha256:23cbb932cc53a86ebde0fb72e7e645f9a5eec1a5af7aa9ce333e46286caef783", "sha256:3582db55372eaee9e998d378109c4b9b15beb2c84624c767efe351363fada9c4",
"sha256:29c04741b9ae13d1e94cf93fca257730b97ce6ea64cfe1eba11cf9ac4e85afb6", "sha256:3b4da28d89527572f0d4a24814e353e1228a7aeda965e5d9265c1435a154b17a",
"sha256:2bde29cc44fa81c0a0c8686992c3080b37c488df167a371500b2a43ce9f026d1", "sha256:3c4fa90fd91cc2957e66195ce374331bebbc816964864f64b42bd14bda773b53",
"sha256:2cdc55ca07b4e70dda898d2ab7150ecf17c990076d3acd7a5f3b25cb23a69f1c", "sha256:3e66cfc915f5f7e2c8a0af8a27f87aa857f440de7521fd7f2682e23f082142a1",
"sha256:370f6e97d02bf2dd20d7468ce4f38e173a124e769762d00beadec3bc2f4b3bc4", "sha256:3f6f29cb134d782685f8eda01d72073c483c7f87b318b5101c7001faef7850f5",
"sha256:395161bbdbd04a8333b9ff9763a05e9ceb4fe210e3c7690f5e68cedd3d65d8e1", "sha256:43469c22fcf705a7cb59c7e01d6d96975bdbc54c1138900f04d11496489a0054",
"sha256:44136355e2f5e06bf6b23d337a75386371ba742ffa771440b85bed367c1318d1", "sha256:4ad467524cb6879ce42107cf02a49cdb4a06f07fe0e5f1160d7db865a8d25d4b",
"sha256:44a6c2f6374e0033873e9ed577a54a3602b4f609867794c1a3ebba65e4c93ee7", "sha256:4c9c3db90acd17e4231344a23616f33fd79837809584ce30e2450ca312fa47aa",
"sha256:4919899577ba37f505aaebdf6e7dc812d55e8f097331312db7f1aab18767cce8", "sha256:533ba64d67d882286557106a1c5f12b4c2825f11b47a7c209a8c22922ca882be",
"sha256:4b4b1fe58cd102d75ef0552cf17242705ce0759f9695334a56644ad2d83903fe", "sha256:548257463696daf919d2fdfc53ee4b98e29e3ffc5afddd713d83aa849d1fa178",
"sha256:4bdd56ee719a8f751cf5a593476a441c4e56c9b64dc1f0f30902858c4ef8771d", "sha256:55f907c4d18a5a40da0ceb339a0beda77c9df47c934adad987793632fb4318c3",
"sha256:4bf41b8b0a80708f7e0384519795e80dcb44d7199a35d52c15cc674d10b3081b", "sha256:5826e7fb443acb49f64f9648a2852efc8d9af2f4c67f6c3dca69dccd9e8e1d15",
"sha256:4cac3405d8dda8bc6ed499557625585544dd5cbf32072dcc72b5a176cb1271c8", "sha256:59a15c2803c20702d7f2077807d9a2b7d9a168034b87fd3f0d8361de60019a1e",
"sha256:4fe7fda2fe7c8890d454f2cbc91d6c01baf206fbc96d89a80241a02985118c0c", "sha256:59b3aab231c27cd754d6452c43b12498d34e7ab87d69a502bd0220f4b1c090c4",
"sha256:50921c140561d3db2ab9f5b11c5184846cde686bb5a9dc64cae442926e86f3af", "sha256:5da83c964aecb6c3f2a6c9a03f3d0fa579e1ad208e2c264ba826cecd19da11fa",
"sha256:5217c25229b6a85049416a5c1e6451e9060a1edcf988641e309dbe3ab26d3e49", "sha256:60b545806a433cc752b9fa936f1c0a63bf96a3872965b958b35bd0d5d788d411",
"sha256:5352bea8a8f84b89d45ccc503f390a6be77917932b1c98c4cdc3565137acc714", "sha256:60fcef5c3144d861b623456d87ca7fff7af59a4a918e1364cdd0687b48285285",
"sha256:542e3e306d1669b25936b64917285cdffcd4f5c6f0247636fec037187bd93542", "sha256:617d101b95151d827d5366e9c4225a68c64d56065e41ab9c7ef51bb87f347a8a",
"sha256:543883e3496c8b6d58bd036c99486c3c8387c2fc01f7a342b760c1ea3158a318", "sha256:68e9add923bda8357e6fe65a568766feae369063cb7210297067675cce65272f",
"sha256:586b36ebda81e6c1a9c5a5d0bfdc236399ba6595e1397842fd4a45648c30f35e", "sha256:7798b3d662f70cea425637c54da30ef1894d426cab24ee7ffaaccb24a8b17bb8",
"sha256:597f899f4ed42a38df7b0e46714880fb4e19a25c2f66e5c908805466721760f5", "sha256:80a288b21b17e39fb3630cf1d14fd704499bb11d9c8fc110662a0c57758d3d3e",
"sha256:5a260758454580f11dd8743fa98319bb046037dfab4f7828008909d0aa5292bc", "sha256:81291006a934052161eae8340e7731ea6b8595b0c27dd4927c4e8a489e1760e2",
"sha256:5aefb84a301327ad115e9d346c8e2760009131d9d4b4c6b213648d02e2abe144", "sha256:8527ea0978ed6dc58ccb3935bd2883537b455c97ec44b5d8084677dfa817f96b",
"sha256:5e6a5567078b3eaed93558842346c9d678e116ab0135e22eb72db8325e90b453", "sha256:87016850c13082747bd120558e6750746177bd492b103b2fca761c8a1c43fba9",
"sha256:5ff525698de226c0ca743bfa71fc6b378cda2ddcf0d22d7c37b1cc925c9650a5", "sha256:88552925fd22320600c59ee80342d6eb06bfa9503c3a402d7327983f5fa999d9",
"sha256:61edbca89aa3f5ef7ecac8c23d975fe7261c12665f1d90a6b1af527bba86ce61", "sha256:8d7477ebaf5d3621c763702e1ec0daeede8863fb22459c5e26ddfd17e9b1999c",
"sha256:659175b2144d199560d99a8d13b2228b85e6019b6e09e556209dfb8c37b78a11", "sha256:97326d62255203c6026896d4b1ad6b5a0141ba097cae00ed3a508fe454e96baf",
"sha256:6a9a19bea8495bb419dc5d38c4519567781cd8d571c72efc6aa959473d10221a", "sha256:a4c7b8c5a3a186b49415af3be18e4b8f93b33d6853216c0a1d7401736b703bce",
"sha256:6b30bddd61d2a3261f025ad0f9ee2586988c6a00c780a2fb0a92cea2aa702c54", "sha256:aff7c778d9229d66f716ad98a701fa91cf97935ae4a32a145ae9e61619906aaa",
"sha256:6ffd55b5aedc6f25fd8d9f905c9376ca44fcf768673ffb9d160dd6f409bfda73", "sha256:b280cb303fed94199f0b976595af71ebdcd388fb5e377a8198790f1016a23476",
"sha256:702d8fc6f25bbf412ee706bd73019da5e44a8400861dfff7ff31eb5b4a1276dc", "sha256:b59233cb8df6b60fff5f3056f6f342a8f5f04107a11936bf49ebff87dd4ace34",
"sha256:74bcab50a13960f2a610cdcd066e25f1fd59e23b69637c92ad470784a51b1347", "sha256:bdab2c90665b88faf5cc5e11bf835d548f4b8d8060c89fc70782b6020850aa1c",
"sha256:75f591b2055523fc02a4bbe598aa867df9e953255f0b7f7715d2a36a9c30065c", "sha256:c00c357a4914f58398503c7f716cf1646b1e36b8176efa35255f5ebfacedfa46",
"sha256:763b64853b0a8f4f9cfb41a76a4a85a9bcda7fdda5cb057016e7706fde928e66", "sha256:c95a977cfdccb8ddef95ddd77cf586fe9dc327c7c93cf712983cece70cdaa1be",
"sha256:76c598ca73ec73a2f568e2a72ba46c3b6c8690ad9a07092b18e48ceb936e9f0c", "sha256:cdd3d2df486c9a8c6d08f78bdfa8ea7cf6191e037fde38c2cf6f5f0559e9d353",
"sha256:78d680ef3e4d405f36f0d6d1ea54e740366f061645930072d39bca16a10d8c93", "sha256:d15a0cc48f7a3055e89df1bd6623a907c407d1f58f67ff47064e598d4a550de4",
"sha256:7b280948d00bd3973c1998f92e22aa3ecb76682e3a4255f33e1020bd32adf443", "sha256:d40cecf4bcb2cb37c59e3c79e5bbc45d47e3f3e07edf24e35fc5775db2570058",
"sha256:7db345956ecce0c99b97b042b4ca7326feeec6b75facd8390af73b18e2650ffc", "sha256:d4d3571c8eb21f0fbe9f0b21b49092c24d442f9a295f079949df3551b2886f29",
"sha256:7dbdce0c534bbf52274b94768b3498abdf675a691fec5f751b6057b3030f34c1", "sha256:d94a0d25e517c76c9ce9e2e2635d9d1a644b894f466a66a10061f4e599cdc019",
"sha256:7ef6b5942e6bfc5706301a18a62300c60db9af7f6368042227ccb7eeb22d0892", "sha256:dcc5b0d6a94637c071a427dc4469efd0ae4fda8ff384790bc8b5baaf9308dc3e",
"sha256:7f5a3ffc731494f1a57bd91c47dc483a1e10048131ffb52d901bfe2beb6102e8", "sha256:e00b046000b313ffaa2f6e8d7290b33b08d2005150eff4c8cf3ad74d011888d1",
"sha256:8a45b6514861916c429e6059a55cf7db74670eaed2052a648e3e4d04f070e001", "sha256:e1b56dac5e86ab52e0443d63b02796357202a8f8c5966b69f8d4c03a94778e98",
"sha256:8ad241da7fac963d7573cc67a064c57c58766b62a9a20c452ca1f21050868dfa", "sha256:e30d9a6fd7a7a6a4da6f80d167ce8eda4a993ff24282cbc73f34186c46a498db",
"sha256:8b0886885f7323beea6f552c28bff62cbe0983b9fbb94126531693ea6c5ebb90", "sha256:f1977c1fe28173f2349d42c59f80f10a97ce34f2bedb7b7f55e2e8a8de9b7dfb",
"sha256:8ca88da1bd78990b536c4a7765f719803eb4f8f9971cc22d6ca965c10a7f2c4c", "sha256:f2bc8a9076ea7add860d57dbee0554a212962ecf2a900344f2fc7c56a02463b0",
"sha256:8e0caeff18b96ea90fc0eb6e3bdb2b10ab5b01a95128dfeccb64a7238decf5f0", "sha256:f311ca33fcb9f8fb060c1fa76238d8d029f33b71a2021bafa5d423cc25965b54",
"sha256:957403a978e10fb3ca42572a23e6f7badff39aa1ce2f4ade68ee452dc6807692", "sha256:f579a202b90c1110d0894a86b32a89bf550fdb34bdd3f9f550115706be462e19",
"sha256:9af69f6746120998cd9c355e9c3c6aec7dff70d47247188feb4f829502be8ab4", "sha256:fa41a427d4f03ec6d6da2fd8a230f4f388f336cd7ca46b46c4d2a1bca3ead85a",
"sha256:9c94f7cc91ab16b36ba5ce476f1904c91d6c92441f01cd61a8e2729442d6fcf5", "sha256:fd47362e03acc780aad5a5bc4624d495594261b55a1f79a5b775b6be865a5911"
"sha256:a37d51fa9a00d265cf73f3de3930fa9c41548177ba4f0faf76e61d512c774690",
"sha256:a3a98921da9a1bf8457aeee6a551948a83601689e5ecdd736894ea9bbec77e83",
"sha256:a3c1ebd4ed8e76e886507c9eddb1a891673686c813adf889b864a17fafcf6d66",
"sha256:a5f9505efd574d1e5b4a76ac9dd92a12acb2b309551e9aa874c13c11caefbe4f",
"sha256:a8ff454ef0bb061e37df03557afda9d785c905dab15584860f982e88be73015f",
"sha256:a9d0b68ac1743964755ae2d89772c7e6fb0118acd4d0b7464eaf3921c6b49dd4",
"sha256:aa62a07ac93b7cb6b7d0389d8ef57ffc321d78f60c037b19dfa78d6b17c928ee",
"sha256:ac741bf78b9bb432e2d314439275235f41656e189856b11fb4e774d9f7246d81",
"sha256:ae1e96785696b543394a4e3f15f3f225d44f3c55dafe3f206493031419fedf95",
"sha256:b683e5fd7f74fb66e89a1ed16076dbab3f8e9f34c18b1979ded614fe10cdc4d9",
"sha256:b7a8b43ee64ca8f4befa2bea4083f7c52c92864d8518244bfa6e88c751fa8fff",
"sha256:b8e38472739028e5f2c3a4aded0ab7eadc447f0d84f310c7a8bb697ec417229e",
"sha256:bfff48c7bd23c6e2aec6454aaf6edc44444b229e94743b34bdcdda2e35126cf5",
"sha256:c14b63c9d7bab795d17392c7c1f9aaabbffd4cf4387725a0ac69109fb3b550c6",
"sha256:c27cc1e4b197092e50ddbf0118c788d9977f3f8f35bfbbd3e76c1846a3443df7",
"sha256:c28d3309ebd6d6b2cf82969b5179bed5fefe6142c70f354ece94324fa11bf6a1",
"sha256:c670f4773f2f6f1957ff8a3962c7dd12e4be54d05839b216cb7fd70b5a1df394",
"sha256:ce6910b56b700bea7be82c54ddf2e0ed792a577dfaa4a76b9af07d550af435c6",
"sha256:d0213671691e341f6849bf33cd9fad21f7b1cb88b89e024f33370733fec58742",
"sha256:d03fe67b2325cb3f09be029fd5da8df9e6974f0cde2c2ac6a79d2634e791dd57",
"sha256:d0e5af9a9effb88535a472e19169e09ce750c3d442fb222254a276d77808620b",
"sha256:d243b36fbf3d73c25e48014961e83c19c9cc92530516ce3c43050ea6276a2ab7",
"sha256:d26166acf62f731f50bdd885b04b38828436d74e8e362bfcb8df221d868b5d9b",
"sha256:d403d781b0e06d2922435ce3b8d2376579f0c217ae491e273bab8d092727d244",
"sha256:d8716f82502997b3d0895d1c64c3b834181b1eaca28f3f6336a71777e437c2af",
"sha256:e4f781ffedd17b0b834c8731b75cce2639d5a8afe961c1e58ee7f1f20b3af185",
"sha256:e613a98ead2005c4ce037c7b061f2409a1a4e45099edb0ef3200ee26ed2a69a8",
"sha256:ef4163770525257876f10e8ece1cf25b71468316f61451ded1a6f44273eedeb5"
], ],
"markers": "python_version >= '3.6'", "markers": "python_version >= '3.8'",
"version": "==2022.10.31" "version": "==2023.3.22"
}, },
"reportlab": { "reportlab": {
"hashes": [ "hashes": [
@ -1925,11 +1890,11 @@
}, },
"tzlocal": { "tzlocal": {
"hashes": [ "hashes": [
"sha256:89885494684c929d9191c57aa27502afc87a579be5cdd3225c77c463ea043745", "sha256:3f21d09e1b2aa9f2dacca12da240ca37de3ba5237a93addfd6d593afe9073355",
"sha256:ee5842fa3a795f023514ac2d801c4a81d1743bbe642e3940143326b3a00addd7" "sha256:b44c4388f3d34f25862cfbb387578a4d70fec417649da694a132f628a23367e2"
], ],
"markers": "python_version >= '3.6'", "markers": "python_version >= '3.7'",
"version": "==4.2" "version": "==4.3"
}, },
"urllib3": { "urllib3": {
"hashes": [ "hashes": [
@ -1944,11 +1909,11 @@
"standard" "standard"
], ],
"hashes": [ "hashes": [
"sha256:8635a388062222082f4b06225b867b74a7e4ef942124453d4d1d1a5cb3750932", "sha256:0fac9cb342ba099e0d582966005f3fdba5b0290579fed4a6266dc702ca7bb032",
"sha256:e69e955cb621ae7b75f5590a814a4fcbfb14cb8f44a36dfe3c5c75ab8aee3ad5" "sha256:e47cac98a6da10cd41e6fd036d472c6f58ede6c5dbee3dbee3ef7a100ed97742"
], ],
"index": "pypi", "index": "pypi",
"version": "==0.21.0" "version": "==0.21.1"
}, },
"uvloop": { "uvloop": {
"hashes": [ "hashes": [
@ -2165,45 +2130,39 @@
}, },
"zope.interface": { "zope.interface": {
"hashes": [ "hashes": [
"sha256:008b0b65c05993bb08912f644d140530e775cf1c62a072bf9340c2249e613c32", "sha256:042f2381118b093714081fd82c98e3b189b68db38ee7d35b63c327c470ef8373",
"sha256:0217a9615531c83aeedb12e126611b1b1a3175013bbafe57c702ce40000eb9a0", "sha256:0ec9653825f837fbddc4e4b603d90269b501486c11800d7c761eee7ce46d1bbb",
"sha256:0fb497c6b088818e3395e302e426850f8236d8d9f4ef5b2836feae812a8f699c", "sha256:12175ca6b4db7621aedd7c30aa7cfa0a2d65ea3a0105393e05482d7a2d367446",
"sha256:17ebf6e0b1d07ed009738016abf0d0a0f80388e009d0ac6e0ead26fc162b3b9c", "sha256:1592f68ae11e557b9ff2bc96ac8fc30b187e77c45a3c9cd876e3368c53dc5ba8",
"sha256:311196634bb9333aa06f00fc94f59d3a9fddd2305c2c425d86e406ddc6f2260d", "sha256:23ac41d52fd15dd8be77e3257bc51bbb82469cf7f5e9a30b75e903e21439d16c",
"sha256:3218ab1a7748327e08ef83cca63eea7cf20ea7e2ebcb2522072896e5e2fceedf", "sha256:424d23b97fa1542d7be882eae0c0fc3d6827784105264a8169a26ce16db260d8",
"sha256:404d1e284eda9e233c90128697c71acffd55e183d70628aa0bbb0e7a3084ed8b", "sha256:4407b1435572e3e1610797c9203ad2753666c62883b921318c5403fb7139dec2",
"sha256:4087e253bd3bbbc3e615ecd0b6dd03c4e6a1e46d152d3be6d2ad08fbad742dcc", "sha256:48f4d38cf4b462e75fac78b6f11ad47b06b1c568eb59896db5b6ec1094eb467f",
"sha256:40f4065745e2c2fa0dff0e7ccd7c166a8ac9748974f960cd39f63d2c19f9231f", "sha256:4c3d7dfd897a588ec27e391edbe3dd320a03684457470415870254e714126b1f",
"sha256:5334e2ef60d3d9439c08baedaf8b84dc9bb9522d0dacbc10572ef5609ef8db6d", "sha256:5171eb073474a5038321409a630904fd61f12dd1856dd7e9d19cd6fe092cbbc5",
"sha256:604cdba8f1983d0ab78edc29aa71c8df0ada06fb147cea436dc37093a0100a4e", "sha256:5a158846d0fca0a908c1afb281ddba88744d403f2550dc34405c3691769cdd85",
"sha256:6373d7eb813a143cb7795d3e42bd8ed857c82a90571567e681e1b3841a390d16", "sha256:6ee934f023f875ec2cfd2b05a937bd817efcc6c4c3f55c5778cbf78e58362ddc",
"sha256:655796a906fa3ca67273011c9805c1e1baa047781fca80feeb710328cdbed87f", "sha256:790c1d9d8f9c92819c31ea660cd43c3d5451df1df61e2e814a6f99cebb292788",
"sha256:65c3c06afee96c654e590e046c4a24559e65b0a87dbff256cd4bd6f77e1a33f9", "sha256:809fe3bf1a91393abc7e92d607976bbb8586512913a79f2bf7d7ec15bd8ea518",
"sha256:696f3d5493eae7359887da55c2afa05acc3db5fc625c49529e84bd9992313296", "sha256:87b690bbee9876163210fd3f500ee59f5803e4a6607d1b1238833b8885ebd410",
"sha256:6e972493cdfe4ad0411fd9abfab7d4d800a7317a93928217f1a5de2bb0f0d87a", "sha256:89086c9d3490a0f265a3c4b794037a84541ff5ffa28bb9c24cc9f66566968464",
"sha256:7579960be23d1fddecb53898035a0d112ac858c3554018ce615cefc03024e46d", "sha256:99856d6c98a326abbcc2363827e16bd6044f70f2ef42f453c0bd5440c4ce24e5",
"sha256:765d703096ca47aa5d93044bf701b00bbce4d903a95b41fff7c3796e747b1f1d", "sha256:aab584725afd10c710b8f1e6e208dbee2d0ad009f57d674cb9d1b3964037275d",
"sha256:7e66f60b0067a10dd289b29dceabd3d0e6d68be1504fc9d0bc209cf07f56d189", "sha256:af169ba897692e9cd984a81cb0f02e46dacdc07d6cf9fd5c91e81f8efaf93d52",
"sha256:8a2ffadefd0e7206adc86e492ccc60395f7edb5680adedf17a7ee4205c530df4", "sha256:b39b8711578dcfd45fc0140993403b8a81e879ec25d53189f3faa1f006087dca",
"sha256:959697ef2757406bff71467a09d940ca364e724c534efbf3786e86eee8591452", "sha256:b3f543ae9d3408549a9900720f18c0194ac0fe810cecda2a584fd4dca2eb3bb8",
"sha256:9d783213fab61832dbb10d385a319cb0e45451088abd45f95b5bb88ed0acca1a", "sha256:d0583b75f2e70ec93f100931660328965bb9ff65ae54695fb3fa0a1255daa6f2",
"sha256:a16025df73d24795a0bde05504911d306307c24a64187752685ff6ea23897cb0", "sha256:dfbbbf0809a3606046a41f8561c3eada9db811be94138f42d9135a5c47e75f6f",
"sha256:a2ad597c8c9e038a5912ac3cf166f82926feff2f6e0dabdab956768de0a258f5", "sha256:e538f2d4a6ffb6edfb303ce70ae7e88629ac6e5581870e66c306d9ad7b564a58",
"sha256:bfee1f3ff62143819499e348f5b8a7f3aa0259f9aca5e0ddae7391d059dce671", "sha256:eba51599370c87088d8882ab74f637de0c4f04a6d08a312dce49368ba9ed5c2a",
"sha256:d169ccd0756c15bbb2f1acc012f5aab279dffc334d733ca0d9362c5beaebe88e", "sha256:ee4b43f35f5dc15e1fec55ccb53c130adb1d11e8ad8263d68b1284b66a04190d",
"sha256:d514c269d1f9f5cd05ddfed15298d6c418129f3f064765295659798349c43e6f", "sha256:f2363e5fd81afb650085c6686f2ee3706975c54f331b426800b53531191fdf28",
"sha256:d692374b578360d36568dd05efb8a5a67ab6d1878c29c582e37ddba80e66c396", "sha256:f299c020c6679cb389814a3b81200fe55d428012c5e76da7e722491f5d205990",
"sha256:dbaeb9cf0ea0b3bc4b36fae54a016933d64c6d52a94810a63c00f440ecb37dd7", "sha256:f72f23bab1848edb7472309e9898603141644faec9fd57a823ea6b4d1c4c8995",
"sha256:dc26c8d44472e035d59d6f1177eb712888447f5799743da9c398b0339ed90b1b", "sha256:fa90bac61c9dc3e1a563e5babb3fd2c0c1c80567e815442ddbe561eadc803b30"
"sha256:e1574980b48c8c74f83578d1e77e701f8439a5d93f36a5a0af31337467c08fcf",
"sha256:e74a578172525c20d7223eac5f8ad187f10940dac06e40113d62f14f3adb1e8f",
"sha256:e945de62917acbf853ab968d8916290548df18dd62c739d862f359ecd25842a6",
"sha256:f0980d44b8aded808bec5059018d64692f0127f10510eca71f2f0ace8fb11188",
"sha256:f98d4bd7bbb15ca701d19b93263cc5edfd480c3475d163f137385f49e5b3a3a7",
"sha256:fb68d212efd057596dee9e6582daded9f8ef776538afdf5feceb3059df2d2e7b"
], ],
"markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4'", "markers": "python_version >= '3.7'",
"version": "==5.5.2" "version": "==6.0"
}, },
"zstandard": { "zstandard": {
"hashes": [ "hashes": [
@ -2542,19 +2501,19 @@
}, },
"faker": { "faker": {
"hashes": [ "hashes": [
"sha256:51f37ff9df710159d6d736d0ba1c75e063430a8c806b91334d7794305b5a6114", "sha256:2deeee8fed3d1b8ae5f87d172d4569ddc859aab8693f7cd68eddc5d20400563a",
"sha256:5aaa16fa9cfde7d117eef70b6b293a705021e57158f3fa6b44ed1b70202d2065" "sha256:e7c058e1f360f245f265625b32d3189d7229398ad80a8b6bac459891745de052"
], ],
"markers": "python_version >= '3.7'", "markers": "python_version >= '3.7'",
"version": "==17.6.0" "version": "==18.3.0"
}, },
"filelock": { "filelock": {
"hashes": [ "hashes": [
"sha256:4427cdda14a1c68e264845142842d6de2d0fa2c15ba31571a3d9c9a1ec9d191c", "sha256:75997740323c5f12e18f10b494bc11c03e42843129f980f17c04352cc7b09d40",
"sha256:e393782f76abea324dee598d2ea145b857a20df0e0ee4f80fcf35e72a341d2c7" "sha256:eb8f0f2d37ed68223ea63e3bddf2fac99667e4362c88b3f762e434d160190d18"
], ],
"index": "pypi", "index": "pypi",
"version": "==3.9.1" "version": "==3.10.2"
}, },
"ghp-import": { "ghp-import": {
"hashes": [ "hashes": [
@ -2565,11 +2524,11 @@
}, },
"identify": { "identify": {
"hashes": [ "hashes": [
"sha256:5dfef8a745ca4f2c95f27e9db74cb4c8b6d9916383988e8791f3595868f78a33", "sha256:69edcaffa8e91ae0f77d397af60f148b6b45a8044b2cc6d99cafa5b04793ff00",
"sha256:c8b288552bc5f05a08aff09af2f58e6976bf8ac87beb38498a0e3d98ba64eb18" "sha256:7671a05ef9cfaf8ff63b15d45a91a1147a03aaccb2976d4e9bd047cbbc508471"
], ],
"markers": "python_version >= '3.7'", "markers": "python_version >= '3.7'",
"version": "==2.5.20" "version": "==2.5.21"
}, },
"idna": { "idna": {
"hashes": [ "hashes": [
@ -2589,11 +2548,11 @@
}, },
"importlib-metadata": { "importlib-metadata": {
"hashes": [ "hashes": [
"sha256:7efb448ec9a5e313a57655d35aa54cd3e01b7e1fbcf72dce1bf06119420f5bad", "sha256:43ce9281e097583d758c2c708c4376371261a02c34682491a8e98352365aad20",
"sha256:e354bedeb60efa6affdcc8ae121b73544a7aa74156d047311948f6d711cd378d" "sha256:ff80f3b5394912eb1b108fcfd444dc78b7f1f3e16b16188054bd01cb9cb86f09"
], ],
"markers": "python_version < '3.10'", "markers": "python_version < '3.10'",
"version": "==6.0.0" "version": "==6.1.0"
}, },
"iniconfig": { "iniconfig": {
"hashes": [ "hashes": [
@ -2767,11 +2726,11 @@
}, },
"pathspec": { "pathspec": {
"hashes": [ "hashes": [
"sha256:3a66eb970cbac598f9e5ccb5b2cf58930cd8e3ed86d393d541eaf2d8b1705229", "sha256:2798de800fa92780e33acca925945e9a19a133b715067cf165b8866c15a31687",
"sha256:64d338d4e0914e91c1792321e6907b5a593f1ab1851de7fc269557a21b30ebbc" "sha256:d8af70af76652554bd134c22b3e8a1cc46ed7d91edcdd721ef1a0c51a84a5293"
], ],
"markers": "python_version >= '3.7'", "markers": "python_version >= '3.7'",
"version": "==0.11.0" "version": "==0.11.1"
}, },
"pillow": { "pillow": {
"hashes": [ "hashes": [
@ -2874,11 +2833,11 @@
}, },
"pre-commit": { "pre-commit": {
"hashes": [ "hashes": [
"sha256:b80254e60668e1dd1f5c03a1c9e0413941d61f568a57d745add265945f65bfe8", "sha256:818f0d998059934d0f81bb3667e3ccdc32da6ed7ccaac33e43dc231561ddaaa9",
"sha256:d63e6537f9252d99f65755ae5b79c989b462d511ebbc481b561db6a297e1e865" "sha256:f712d3688102e13c8e66b7d7dbd8934a6dda157e58635d89f7d6fecdca39ce8a"
], ],
"index": "pypi", "index": "pypi",
"version": "==3.1.1" "version": "==3.2.0"
}, },
"pygments": { "pygments": {
"hashes": [ "hashes": [
@ -3038,97 +2997,69 @@
}, },
"regex": { "regex": {
"hashes": [ "hashes": [
"sha256:052b670fafbe30966bbe5d025e90b2a491f85dfe5b2583a163b5e60a85a321ad", "sha256:0a2a851d0548a4e298d88e3ceeb4bad4aab751cf1883edf6150f25718ce0207a",
"sha256:0653d012b3bf45f194e5e6a41df9258811ac8fc395579fa82958a8b76286bea4", "sha256:148ad520f41021b97870e9c80420e6cdaadcc5e4306e613aed84cd5d53f8a7ca",
"sha256:0a069c8483466806ab94ea9068c34b200b8bfc66b6762f45a831c4baaa9e8cdd", "sha256:159c7b83488a056365119ada0bceddc06a455d3db7a7aa3cf07f13b2878b885f",
"sha256:0cf0da36a212978be2c2e2e2d04bdff46f850108fccc1851332bcae51c8907cc", "sha256:1937946dd03818845bd9c1713dfd3173a7b9a324e6593a235fc8c51c9cd460eb",
"sha256:131d4be09bea7ce2577f9623e415cab287a3c8e0624f778c1d955ec7c281bd4d", "sha256:20ce96da2093e72e151d6af8217a629aeb5f48f1ac543c2fffd1d87c57699d7e",
"sha256:144486e029793a733e43b2e37df16a16df4ceb62102636ff3db6033994711066", "sha256:24242e5f26823e95edd64969bd206d4752c1a56a744d8cbcf58461f9788bc0c7",
"sha256:1ddf14031a3882f684b8642cb74eea3af93a2be68893901b2b387c5fd92a03ec", "sha256:2e2e6baf4a1108f84966f44870b26766d8f6d104c9959aae329078327c677122",
"sha256:1eba476b1b242620c266edf6325b443a2e22b633217a9835a52d8da2b5c051f9", "sha256:328a70e578f37f59eb54e8450b5042190bbadf2ef7f5c0b60829574b62955ed7",
"sha256:20f61c9944f0be2dc2b75689ba409938c14876c19d02f7585af4460b6a21403e", "sha256:3371975b165c1e859e1990e5069e8606f00b25aed961cfd25b7bac626b1eb5a9",
"sha256:22960019a842777a9fa5134c2364efaed5fbf9610ddc5c904bd3a400973b0eb8", "sha256:33bab9c9af936123b70b9874ce83f2bcd54be76b97637b33d31560fba8ad5d78",
"sha256:22e7ebc231d28393dfdc19b185d97e14a0f178bedd78e85aad660e93b646604e", "sha256:33c887b658afb144cdc8ce9156a0e1098453060c18b8bd5177f831ad58e0d60d",
"sha256:23cbb932cc53a86ebde0fb72e7e645f9a5eec1a5af7aa9ce333e46286caef783", "sha256:3582db55372eaee9e998d378109c4b9b15beb2c84624c767efe351363fada9c4",
"sha256:29c04741b9ae13d1e94cf93fca257730b97ce6ea64cfe1eba11cf9ac4e85afb6", "sha256:3b4da28d89527572f0d4a24814e353e1228a7aeda965e5d9265c1435a154b17a",
"sha256:2bde29cc44fa81c0a0c8686992c3080b37c488df167a371500b2a43ce9f026d1", "sha256:3c4fa90fd91cc2957e66195ce374331bebbc816964864f64b42bd14bda773b53",
"sha256:2cdc55ca07b4e70dda898d2ab7150ecf17c990076d3acd7a5f3b25cb23a69f1c", "sha256:3e66cfc915f5f7e2c8a0af8a27f87aa857f440de7521fd7f2682e23f082142a1",
"sha256:370f6e97d02bf2dd20d7468ce4f38e173a124e769762d00beadec3bc2f4b3bc4", "sha256:3f6f29cb134d782685f8eda01d72073c483c7f87b318b5101c7001faef7850f5",
"sha256:395161bbdbd04a8333b9ff9763a05e9ceb4fe210e3c7690f5e68cedd3d65d8e1", "sha256:43469c22fcf705a7cb59c7e01d6d96975bdbc54c1138900f04d11496489a0054",
"sha256:44136355e2f5e06bf6b23d337a75386371ba742ffa771440b85bed367c1318d1", "sha256:4ad467524cb6879ce42107cf02a49cdb4a06f07fe0e5f1160d7db865a8d25d4b",
"sha256:44a6c2f6374e0033873e9ed577a54a3602b4f609867794c1a3ebba65e4c93ee7", "sha256:4c9c3db90acd17e4231344a23616f33fd79837809584ce30e2450ca312fa47aa",
"sha256:4919899577ba37f505aaebdf6e7dc812d55e8f097331312db7f1aab18767cce8", "sha256:533ba64d67d882286557106a1c5f12b4c2825f11b47a7c209a8c22922ca882be",
"sha256:4b4b1fe58cd102d75ef0552cf17242705ce0759f9695334a56644ad2d83903fe", "sha256:548257463696daf919d2fdfc53ee4b98e29e3ffc5afddd713d83aa849d1fa178",
"sha256:4bdd56ee719a8f751cf5a593476a441c4e56c9b64dc1f0f30902858c4ef8771d", "sha256:55f907c4d18a5a40da0ceb339a0beda77c9df47c934adad987793632fb4318c3",
"sha256:4bf41b8b0a80708f7e0384519795e80dcb44d7199a35d52c15cc674d10b3081b", "sha256:5826e7fb443acb49f64f9648a2852efc8d9af2f4c67f6c3dca69dccd9e8e1d15",
"sha256:4cac3405d8dda8bc6ed499557625585544dd5cbf32072dcc72b5a176cb1271c8", "sha256:59a15c2803c20702d7f2077807d9a2b7d9a168034b87fd3f0d8361de60019a1e",
"sha256:4fe7fda2fe7c8890d454f2cbc91d6c01baf206fbc96d89a80241a02985118c0c", "sha256:59b3aab231c27cd754d6452c43b12498d34e7ab87d69a502bd0220f4b1c090c4",
"sha256:50921c140561d3db2ab9f5b11c5184846cde686bb5a9dc64cae442926e86f3af", "sha256:5da83c964aecb6c3f2a6c9a03f3d0fa579e1ad208e2c264ba826cecd19da11fa",
"sha256:5217c25229b6a85049416a5c1e6451e9060a1edcf988641e309dbe3ab26d3e49", "sha256:60b545806a433cc752b9fa936f1c0a63bf96a3872965b958b35bd0d5d788d411",
"sha256:5352bea8a8f84b89d45ccc503f390a6be77917932b1c98c4cdc3565137acc714", "sha256:60fcef5c3144d861b623456d87ca7fff7af59a4a918e1364cdd0687b48285285",
"sha256:542e3e306d1669b25936b64917285cdffcd4f5c6f0247636fec037187bd93542", "sha256:617d101b95151d827d5366e9c4225a68c64d56065e41ab9c7ef51bb87f347a8a",
"sha256:543883e3496c8b6d58bd036c99486c3c8387c2fc01f7a342b760c1ea3158a318", "sha256:68e9add923bda8357e6fe65a568766feae369063cb7210297067675cce65272f",
"sha256:586b36ebda81e6c1a9c5a5d0bfdc236399ba6595e1397842fd4a45648c30f35e", "sha256:7798b3d662f70cea425637c54da30ef1894d426cab24ee7ffaaccb24a8b17bb8",
"sha256:597f899f4ed42a38df7b0e46714880fb4e19a25c2f66e5c908805466721760f5", "sha256:80a288b21b17e39fb3630cf1d14fd704499bb11d9c8fc110662a0c57758d3d3e",
"sha256:5a260758454580f11dd8743fa98319bb046037dfab4f7828008909d0aa5292bc", "sha256:81291006a934052161eae8340e7731ea6b8595b0c27dd4927c4e8a489e1760e2",
"sha256:5aefb84a301327ad115e9d346c8e2760009131d9d4b4c6b213648d02e2abe144", "sha256:8527ea0978ed6dc58ccb3935bd2883537b455c97ec44b5d8084677dfa817f96b",
"sha256:5e6a5567078b3eaed93558842346c9d678e116ab0135e22eb72db8325e90b453", "sha256:87016850c13082747bd120558e6750746177bd492b103b2fca761c8a1c43fba9",
"sha256:5ff525698de226c0ca743bfa71fc6b378cda2ddcf0d22d7c37b1cc925c9650a5", "sha256:88552925fd22320600c59ee80342d6eb06bfa9503c3a402d7327983f5fa999d9",
"sha256:61edbca89aa3f5ef7ecac8c23d975fe7261c12665f1d90a6b1af527bba86ce61", "sha256:8d7477ebaf5d3621c763702e1ec0daeede8863fb22459c5e26ddfd17e9b1999c",
"sha256:659175b2144d199560d99a8d13b2228b85e6019b6e09e556209dfb8c37b78a11", "sha256:97326d62255203c6026896d4b1ad6b5a0141ba097cae00ed3a508fe454e96baf",
"sha256:6a9a19bea8495bb419dc5d38c4519567781cd8d571c72efc6aa959473d10221a", "sha256:a4c7b8c5a3a186b49415af3be18e4b8f93b33d6853216c0a1d7401736b703bce",
"sha256:6b30bddd61d2a3261f025ad0f9ee2586988c6a00c780a2fb0a92cea2aa702c54", "sha256:aff7c778d9229d66f716ad98a701fa91cf97935ae4a32a145ae9e61619906aaa",
"sha256:6ffd55b5aedc6f25fd8d9f905c9376ca44fcf768673ffb9d160dd6f409bfda73", "sha256:b280cb303fed94199f0b976595af71ebdcd388fb5e377a8198790f1016a23476",
"sha256:702d8fc6f25bbf412ee706bd73019da5e44a8400861dfff7ff31eb5b4a1276dc", "sha256:b59233cb8df6b60fff5f3056f6f342a8f5f04107a11936bf49ebff87dd4ace34",
"sha256:74bcab50a13960f2a610cdcd066e25f1fd59e23b69637c92ad470784a51b1347", "sha256:bdab2c90665b88faf5cc5e11bf835d548f4b8d8060c89fc70782b6020850aa1c",
"sha256:75f591b2055523fc02a4bbe598aa867df9e953255f0b7f7715d2a36a9c30065c", "sha256:c00c357a4914f58398503c7f716cf1646b1e36b8176efa35255f5ebfacedfa46",
"sha256:763b64853b0a8f4f9cfb41a76a4a85a9bcda7fdda5cb057016e7706fde928e66", "sha256:c95a977cfdccb8ddef95ddd77cf586fe9dc327c7c93cf712983cece70cdaa1be",
"sha256:76c598ca73ec73a2f568e2a72ba46c3b6c8690ad9a07092b18e48ceb936e9f0c", "sha256:cdd3d2df486c9a8c6d08f78bdfa8ea7cf6191e037fde38c2cf6f5f0559e9d353",
"sha256:78d680ef3e4d405f36f0d6d1ea54e740366f061645930072d39bca16a10d8c93", "sha256:d15a0cc48f7a3055e89df1bd6623a907c407d1f58f67ff47064e598d4a550de4",
"sha256:7b280948d00bd3973c1998f92e22aa3ecb76682e3a4255f33e1020bd32adf443", "sha256:d40cecf4bcb2cb37c59e3c79e5bbc45d47e3f3e07edf24e35fc5775db2570058",
"sha256:7db345956ecce0c99b97b042b4ca7326feeec6b75facd8390af73b18e2650ffc", "sha256:d4d3571c8eb21f0fbe9f0b21b49092c24d442f9a295f079949df3551b2886f29",
"sha256:7dbdce0c534bbf52274b94768b3498abdf675a691fec5f751b6057b3030f34c1", "sha256:d94a0d25e517c76c9ce9e2e2635d9d1a644b894f466a66a10061f4e599cdc019",
"sha256:7ef6b5942e6bfc5706301a18a62300c60db9af7f6368042227ccb7eeb22d0892", "sha256:dcc5b0d6a94637c071a427dc4469efd0ae4fda8ff384790bc8b5baaf9308dc3e",
"sha256:7f5a3ffc731494f1a57bd91c47dc483a1e10048131ffb52d901bfe2beb6102e8", "sha256:e00b046000b313ffaa2f6e8d7290b33b08d2005150eff4c8cf3ad74d011888d1",
"sha256:8a45b6514861916c429e6059a55cf7db74670eaed2052a648e3e4d04f070e001", "sha256:e1b56dac5e86ab52e0443d63b02796357202a8f8c5966b69f8d4c03a94778e98",
"sha256:8ad241da7fac963d7573cc67a064c57c58766b62a9a20c452ca1f21050868dfa", "sha256:e30d9a6fd7a7a6a4da6f80d167ce8eda4a993ff24282cbc73f34186c46a498db",
"sha256:8b0886885f7323beea6f552c28bff62cbe0983b9fbb94126531693ea6c5ebb90", "sha256:f1977c1fe28173f2349d42c59f80f10a97ce34f2bedb7b7f55e2e8a8de9b7dfb",
"sha256:8ca88da1bd78990b536c4a7765f719803eb4f8f9971cc22d6ca965c10a7f2c4c", "sha256:f2bc8a9076ea7add860d57dbee0554a212962ecf2a900344f2fc7c56a02463b0",
"sha256:8e0caeff18b96ea90fc0eb6e3bdb2b10ab5b01a95128dfeccb64a7238decf5f0", "sha256:f311ca33fcb9f8fb060c1fa76238d8d029f33b71a2021bafa5d423cc25965b54",
"sha256:957403a978e10fb3ca42572a23e6f7badff39aa1ce2f4ade68ee452dc6807692", "sha256:f579a202b90c1110d0894a86b32a89bf550fdb34bdd3f9f550115706be462e19",
"sha256:9af69f6746120998cd9c355e9c3c6aec7dff70d47247188feb4f829502be8ab4", "sha256:fa41a427d4f03ec6d6da2fd8a230f4f388f336cd7ca46b46c4d2a1bca3ead85a",
"sha256:9c94f7cc91ab16b36ba5ce476f1904c91d6c92441f01cd61a8e2729442d6fcf5", "sha256:fd47362e03acc780aad5a5bc4624d495594261b55a1f79a5b775b6be865a5911"
"sha256:a37d51fa9a00d265cf73f3de3930fa9c41548177ba4f0faf76e61d512c774690",
"sha256:a3a98921da9a1bf8457aeee6a551948a83601689e5ecdd736894ea9bbec77e83",
"sha256:a3c1ebd4ed8e76e886507c9eddb1a891673686c813adf889b864a17fafcf6d66",
"sha256:a5f9505efd574d1e5b4a76ac9dd92a12acb2b309551e9aa874c13c11caefbe4f",
"sha256:a8ff454ef0bb061e37df03557afda9d785c905dab15584860f982e88be73015f",
"sha256:a9d0b68ac1743964755ae2d89772c7e6fb0118acd4d0b7464eaf3921c6b49dd4",
"sha256:aa62a07ac93b7cb6b7d0389d8ef57ffc321d78f60c037b19dfa78d6b17c928ee",
"sha256:ac741bf78b9bb432e2d314439275235f41656e189856b11fb4e774d9f7246d81",
"sha256:ae1e96785696b543394a4e3f15f3f225d44f3c55dafe3f206493031419fedf95",
"sha256:b683e5fd7f74fb66e89a1ed16076dbab3f8e9f34c18b1979ded614fe10cdc4d9",
"sha256:b7a8b43ee64ca8f4befa2bea4083f7c52c92864d8518244bfa6e88c751fa8fff",
"sha256:b8e38472739028e5f2c3a4aded0ab7eadc447f0d84f310c7a8bb697ec417229e",
"sha256:bfff48c7bd23c6e2aec6454aaf6edc44444b229e94743b34bdcdda2e35126cf5",
"sha256:c14b63c9d7bab795d17392c7c1f9aaabbffd4cf4387725a0ac69109fb3b550c6",
"sha256:c27cc1e4b197092e50ddbf0118c788d9977f3f8f35bfbbd3e76c1846a3443df7",
"sha256:c28d3309ebd6d6b2cf82969b5179bed5fefe6142c70f354ece94324fa11bf6a1",
"sha256:c670f4773f2f6f1957ff8a3962c7dd12e4be54d05839b216cb7fd70b5a1df394",
"sha256:ce6910b56b700bea7be82c54ddf2e0ed792a577dfaa4a76b9af07d550af435c6",
"sha256:d0213671691e341f6849bf33cd9fad21f7b1cb88b89e024f33370733fec58742",
"sha256:d03fe67b2325cb3f09be029fd5da8df9e6974f0cde2c2ac6a79d2634e791dd57",
"sha256:d0e5af9a9effb88535a472e19169e09ce750c3d442fb222254a276d77808620b",
"sha256:d243b36fbf3d73c25e48014961e83c19c9cc92530516ce3c43050ea6276a2ab7",
"sha256:d26166acf62f731f50bdd885b04b38828436d74e8e362bfcb8df221d868b5d9b",
"sha256:d403d781b0e06d2922435ce3b8d2376579f0c217ae491e273bab8d092727d244",
"sha256:d8716f82502997b3d0895d1c64c3b834181b1eaca28f3f6336a71777e437c2af",
"sha256:e4f781ffedd17b0b834c8731b75cce2639d5a8afe961c1e58ee7f1f20b3af185",
"sha256:e613a98ead2005c4ce037c7b061f2409a1a4e45099edb0ef3200ee26ed2a69a8",
"sha256:ef4163770525257876f10e8ece1cf25b71468316f61451ded1a6f44273eedeb5"
], ],
"markers": "python_version >= '3.6'", "markers": "python_version >= '3.8'",
"version": "==2022.10.31" "version": "==2023.3.22"
}, },
"requests": { "requests": {
"hashes": [ "hashes": [
@ -3513,30 +3444,30 @@
"compatible-mypy" "compatible-mypy"
], ],
"hashes": [ "hashes": [
"sha256:0bbf9eb172c5b06eccff2d704c7c3906e4a2c6146df8c32ee9f3a51e29265581", "sha256:1bd96207576cd220221a0e615f0259f13d453d515a80f576c1246e0fb547f561",
"sha256:25010658acac0ce4a69211b55dd719fd16dbfe54fcfe5c878d0c8db07bdd5482" "sha256:c95f948e2bfc565f3147e969ff361ef033841a0b8a51cac974a6cc6d0486732c"
], ],
"index": "pypi", "index": "pypi",
"version": "==1.15.0" "version": "==1.16.0"
}, },
"django-stubs-ext": { "django-stubs-ext": {
"hashes": [ "hashes": [
"sha256:4fd8cdbc68d1a421f21bb7e0d9e76d50f6a4b504d350ba786405daf536e90c21", "sha256:9a9ba9e2808737949de96a0fce8b054f12d38e461011d77ebc074ffe8c43dfcb",
"sha256:d729fbc7fe8970a7e26b35956c35b48502516f011d523c0577bdfb02ed956284" "sha256:a454d349d19c26d6c50c4c6dbc1e8af4a9cda4ce1dc4104e3dd4c0330510cc56"
], ],
"markers": "python_version >= '3.7'", "markers": "python_version >= '3.7'",
"version": "==0.7.0" "version": "==0.8.0"
}, },
"djangorestframework-stubs": { "djangorestframework-stubs": {
"extras": [ "extras": [
"compatible-mypy" "compatible-mypy"
], ],
"hashes": [ "hashes": [
"sha256:89f6c2add193cb5ab61b9e47187b33a93cc099376a8df5e4d6c3fc8ecb992d3b", "sha256:433edd7f10786914138b300b9be5aba1ebc80c471b5156934664afd7e9df9fd6",
"sha256:9475e1374b057ffbdcaaa84a060fe5f01476d8b9014d82a83b4153f57fbcbc1f" "sha256:69e8a1ea7eb815cbe35155c27eee72522d7c8666d3cbdacb9997ab88c7b4202c"
], ],
"index": "pypi", "index": "pypi",
"version": "==1.9.1" "version": "==1.10.0"
}, },
"idna": { "idna": {
"hashes": [ "hashes": [
@ -3548,35 +3479,35 @@
}, },
"mypy": { "mypy": {
"hashes": [ "hashes": [
"sha256:0af4f0e20706aadf4e6f8f8dc5ab739089146b83fd53cb4a7e0e850ef3de0bb6", "sha256:0a28a76785bf57655a8ea5eb0540a15b0e781c807b5aa798bd463779988fa1d5",
"sha256:15b5a824b58c7c822c51bc66308e759243c32631896743f030daf449fe3677f3", "sha256:19ba15f9627a5723e522d007fe708007bae52b93faab00f95d72f03e1afa9598",
"sha256:17455cda53eeee0a4adb6371a21dd3dbf465897de82843751cf822605d152c8c", "sha256:21b437be1c02712a605591e1ed1d858aba681757a1e55fe678a15c2244cd68a5",
"sha256:2013226d17f20468f34feddd6aae4635a55f79626549099354ce641bc7d40262", "sha256:26cdd6a22b9b40b2fd71881a8a4f34b4d7914c679f154f43385ca878a8297389",
"sha256:24189f23dc66f83b839bd1cce2dfc356020dfc9a8bae03978477b15be61b062e", "sha256:2888ce4fe5aae5a673386fa232473014056967f3904f5abfcf6367b5af1f612a",
"sha256:27a0f74a298769d9fdc8498fcb4f2beb86f0564bcdb1a37b58cbbe78e55cf8c0", "sha256:2b0c373d071593deefbcdd87ec8db91ea13bd8f1328d44947e88beae21e8d5e9",
"sha256:28cea5a6392bb43d266782983b5a4216c25544cd7d80be681a155ddcdafd152d", "sha256:315ac73cc1cce4771c27d426b7ea558fb4e2836f89cb0296cbe056894e3a1f78",
"sha256:448de661536d270ce04f2d7dddaa49b2fdba6e3bd8a83212164d4174ff43aa65", "sha256:39c7119335be05630611ee798cc982623b9e8f0cff04a0b48dfc26100e0b97af",
"sha256:48525aec92b47baed9b3380371ab8ab6e63a5aab317347dfe9e55e02aaad22e8", "sha256:4b398d8b1f4fba0e3c6463e02f8ad3346f71956b92287af22c9b12c3ec965a9f",
"sha256:5bc8d6bd3b274dd3846597855d96d38d947aedba18776aa998a8d46fabdaed76", "sha256:4e4e8b362cdf99ba00c2b218036002bdcdf1e0de085cdb296a49df03fb31dfc4",
"sha256:5deb252fd42a77add936b463033a59b8e48eb2eaec2976d76b6878d031933fe4", "sha256:59bbd71e5c58eed2e992ce6523180e03c221dcd92b52f0e792f291d67b15a71c",
"sha256:5f546ac34093c6ce33f6278f7c88f0f147a4849386d3bf3ae193702f4fe31407", "sha256:5b5f81b40d94c785f288948c16e1f2da37203c6006546c5d947aab6f90aefef2",
"sha256:5fdd63e4f50e3538617887e9aee91855368d9fc1dea30da743837b0df7373bc4", "sha256:5cb14ff9919b7df3538590fc4d4c49a0f84392237cbf5f7a816b4161c061829e",
"sha256:65b122a993d9c81ea0bfde7689b3365318a88bde952e4dfa1b3a8b4ac05d168b", "sha256:61bf08362e93b6b12fad3eab68c4ea903a077b87c90ac06c11e3d7a09b56b9c1",
"sha256:71a808334d3f41ef011faa5a5cd8153606df5fc0b56de5b2e89566c8093a0c9a", "sha256:64cc3afb3e9e71a79d06e3ed24bb508a6d66f782aff7e56f628bf35ba2e0ba51",
"sha256:920169f0184215eef19294fa86ea49ffd4635dedfdea2b57e45cb4ee85d5ccaf", "sha256:69b35d1dcb5707382810765ed34da9db47e7f95b3528334a3c999b0c90fe523f",
"sha256:93a85495fb13dc484251b4c1fd7a5ac370cd0d812bbfc3b39c1bafefe95275d5", "sha256:9401e33814cec6aec8c03a9548e9385e0e228fc1b8b0a37b9ea21038e64cdd8a",
"sha256:a2948c40a7dd46c1c33765718936669dc1f628f134013b02ff5ac6c7ef6942bf", "sha256:a380c041db500e1410bb5b16b3c1c35e61e773a5c3517926b81dfdab7582be54",
"sha256:c6c2ccb7af7154673c591189c3687b013122c5a891bb5651eca3db8e6c6c55bd", "sha256:ae9ceae0f5b9059f33dbc62dea087e942c0ccab4b7a003719cb70f9b8abfa32f",
"sha256:c96b8a0c019fe29040d520d9257d8c8f122a7343a8307bf8d6d4a43f5c5bfcc8", "sha256:b7c7b708fe9a871a96626d61912e3f4ddd365bf7f39128362bc50cbd74a634d5",
"sha256:d42a98e76070a365a1d1c220fcac8aa4ada12ae0db679cb4d910fabefc88b994", "sha256:c1c10fa12df1232c936830839e2e935d090fc9ee315744ac33b8a32216b93707",
"sha256:dbeb24514c4acbc78d205f85dd0e800f34062efcc1f4a4857c57e4b4b8712bff", "sha256:ce61663faf7a8e5ec6f456857bfbcec2901fbdb3ad958b778403f63b9e606a1b",
"sha256:e60d0b09f62ae97a94605c3f73fd952395286cf3e3b9e7b97f60b01ddfbbda88", "sha256:d64c28e03ce40d5303450f547e07418c64c241669ab20610f273c9e6290b4b0b",
"sha256:e64f48c6176e243ad015e995de05af7f22bbe370dbb5b32bd6988438ec873919", "sha256:d809f88734f44a0d44959d795b1e6f64b2bbe0ea4d9cc4776aa588bb4229fc1c",
"sha256:e831662208055b006eef68392a768ff83596035ffd6d846786578ba1714ba8f6", "sha256:dbb19c9f662e41e474e0cff502b7064a7edc6764f5262b6cd91d698163196799",
"sha256:eda5c8b9949ed411ff752b9a01adda31afe7eae1e53e946dbdf9db23865e66c4" "sha256:ef6a01e563ec6a4940784c574d33f6ac1943864634517984471642908b30b6f7"
], ],
"index": "pypi", "index": "pypi",
"version": "==1.0.1" "version": "==1.1.1"
}, },
"mypy-extensions": { "mypy-extensions": {
"hashes": [ "hashes": [
@ -3727,26 +3658,26 @@
}, },
"types-redis": { "types-redis": {
"hashes": [ "hashes": [
"sha256:43d92b4d6315a45bb0e9a790683ba4448ada88cd1233f3f9886fa6f783f53956", "sha256:7c1d5fdb0a2d5fd92eac37ce382fdb47d99a69889e7d6c2bc4479148ac646c73",
"sha256:f516254bd593023110a38b77e80d5a76a7f033f1d94c53bee09a7d5d0433f34d" "sha256:f23415e448ca25ec5028c24fdf3717a13f0c905eb1933733e8a8a7d4952f6908"
], ],
"index": "pypi", "index": "pypi",
"version": "==4.5.1.5" "version": "==4.5.3.0"
}, },
"types-requests": { "types-requests": {
"hashes": [ "hashes": [
"sha256:a05e4c7bc967518fba5789c341ea8b0c942776ee474c7873129a61161978e586", "sha256:9d4002056df7ebc4ec1f28fd701fba82c5c22549c4477116cb2656aa30ace6db",
"sha256:fc8eaa09cc014699c6b63c60c2e3add0c8b09a410c818b5ac6e65f92a26dde09" "sha256:a86921028335fdcc3aaf676c9d3463f867db6af2303fc65aa309b13ae1e6dd53"
], ],
"version": "==2.28.11.15" "version": "==2.28.11.16"
}, },
"types-setuptools": { "types-setuptools": {
"hashes": [ "hashes": [
"sha256:70b5e6a379e9fccf6579871a93ca3301a46252e3ae66957ec64281a2b6a812d9", "sha256:3a708e66c7bdc620e4d0439f344c750c57a4340c895a4c3ed2d0fc4ae8eb9962",
"sha256:d669a80ee8e37eb1697dc31a23d41ea2c48a635464e2c7e6370dda811459b466" "sha256:dae5a4a659dbb6dba57773440f6e2dbdd8ef282dc136a174a8a59bd33d949945"
], ],
"index": "pypi", "index": "pypi",
"version": "==67.6.0.0" "version": "==67.6.0.5"
}, },
"types-tqdm": { "types-tqdm": {
"hashes": [ "hashes": [

View File

@ -80,7 +80,7 @@ django_checks() {
search_index() { search_index() {
local -r index_version=3 local -r index_version=4
local -r index_version_file=${DATA_DIR}/.index_version local -r index_version_file=${DATA_DIR}/.index_version
if [[ (! -f "${index_version_file}") || $(<"${index_version_file}") != "$index_version" ]]; then if [[ (! -f "${index_version_file}") || $(<"${index_version_file}") != "$index_version" ]]; then

View File

@ -28,7 +28,7 @@ stderr_logfile_maxbytes=0
[program:celery] [program:celery]
command = celery --app paperless worker --loglevel INFO command = celery --app paperless worker --loglevel INFO --without-mingle --without-gossip
user=paperless user=paperless
stopasgroup = true stopasgroup = true
stopwaitsecs = 60 stopwaitsecs = 60

View File

@ -475,12 +475,13 @@ mail_fetcher
The command takes no arguments and processes all your mail accounts and The command takes no arguments and processes all your mail accounts and
rules. rules.
!!! note !!! tip
As of October 2022 Microsoft no longer supports IMAP authentication To use OAuth access tokens for mail fetching,
for Exchange servers, thus Exchange is no longer supported until a select the box to indicate the password is actually
solution is implemented in the Python IMAP library used by Paperless. a token when creating or editing a mail account. The
See [learn.microsoft.com](https://learn.microsoft.com/en-us/exchange/clients-and-mobile-in-exchange-online/deprecation-of-basic-authentication-exchange-online) details for creating a token depend on your email
provider.
### Creating archived documents {#archiver} ### Creating archived documents {#archiver}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 436 KiB

After

Width:  |  Height:  |  Size: 890 KiB

View File

@ -86,6 +86,36 @@ changed here.
Default is `prefer`. Default is `prefer`.
`PAPERLESS_DBSSLROOTCERT=<ca-path>`
: SSL root certificate path
See [the official documentation about
sslmode](https://www.postgresql.org/docs/current/libpq-ssl.html).
Changes path of `root.crt`.
Defaults to unset, using the documented path in the home directory.
`PAPERLESS_DBSSLCERT=<client-cert-path>`
: SSL client certificate path
See [the official documentation about
sslmode](https://www.postgresql.org/docs/current/libpq-ssl.html).
Changes path of `postgresql.crt`.
Defaults to unset, using the documented path in the home directory.
`PAPERLESS_DBSSLKEY=<client-cert-key>`
: SSL client key path
See [the official documentation about
sslmode](https://www.postgresql.org/docs/current/libpq-ssl.html).
Changes path of `postgresql.key`.
Defaults to unset, using the documented path in the home directory.
`PAPERLESS_DB_TIMEOUT=<float>` `PAPERLESS_DB_TIMEOUT=<float>`
: Amount of time for a database connection to wait for the database to : Amount of time for a database connection to wait for the database to

View File

@ -332,3 +332,16 @@ change the port gunicorn listens on.
To fix this, set `PAPERLESS_PORT` again to your desired port, or the To fix this, set `PAPERLESS_PORT` again to your desired port, or the
default of 8000. default of 8000.
## Database Warns about unique constraint "documents_tag_name_uniq
You may see database log lines like:
```
ERROR: duplicate key value violates unique constraint "documents_tag_name_uniq"
DETAIL: Key (name)=(NameF) already exists.
STATEMENT: INSERT INTO "documents_tag" ("owner_id", "name", "match", "matching_algorithm", "is_insensitive", "color", "is_inbox_tag") VALUES (NULL, 'NameF', '', 1, true, '#a6cee3', false) RETURNING "documents_tag"."id"
```
This can happen during heavy consumption when using polling. Paperless will handle it correctly and the file
will still be consumed

View File

@ -17,28 +17,28 @@ describe('document-detail', () => {
req.reply({ result: 'OK' }) req.reply({ result: 'OK' })
}).as('saveDoc') }).as('saveDoc')
cy.fixture('documents/1/comments.json').then((commentsJson) => { cy.fixture('documents/1/notes.json').then((notesJson) => {
cy.intercept( cy.intercept(
'GET', 'GET',
'http://localhost:8000/api/documents/1/comments/', 'http://localhost:8000/api/documents/1/notes/',
(req) => { (req) => {
req.reply(commentsJson.filter((c) => c.id != 10)) // 3 req.reply(notesJson.filter((c) => c.id != 10)) // 3
} }
) )
cy.intercept( cy.intercept(
'DELETE', 'DELETE',
'http://localhost:8000/api/documents/1/comments/?id=9', 'http://localhost:8000/api/documents/1/notes/?id=9',
(req) => { (req) => {
req.reply(commentsJson.filter((c) => c.id != 9 && c.id != 10)) // 2 req.reply(notesJson.filter((c) => c.id != 9 && c.id != 10)) // 2
} }
) )
cy.intercept( cy.intercept(
'POST', 'POST',
'http://localhost:8000/api/documents/1/comments/', 'http://localhost:8000/api/documents/1/notes/',
(req) => { (req) => {
req.reply(commentsJson) // 4 req.reply(notesJson) // 4
} }
) )
}) })
@ -75,33 +75,40 @@ describe('document-detail', () => {
cy.get('pdf-viewer').should('be.visible') cy.get('pdf-viewer').should('be.visible')
}) })
it('should show a list of comments', () => { it('should show a list of notes', () => {
cy.wait(1000) cy.wait(1000).get('a').contains('Notes').click({ force: true }).wait(1000)
.get('a') cy.get('app-document-notes').find('.card').its('length').should('eq', 3)
.contains('Comments')
.click({ force: true })
.wait(1000)
cy.get('app-document-comments').find('.card').its('length').should('eq', 3)
}) })
it('should support comment deletion', () => { it('should support note deletion', () => {
cy.wait(1000).get('a').contains('Comments').click().wait(1000) cy.wait(1000).get('a').contains('Notes').click().wait(1000)
cy.get('app-document-comments') cy.get('app-document-notes')
.find('.card') .find('.card')
.first() .first()
.find('button') .find('button')
.click({ force: true }) .click({ force: true })
.wait(500) .wait(500)
cy.get('app-document-comments').find('.card').its('length').should('eq', 2) cy.get('app-document-notes').find('.card').its('length').should('eq', 2)
}) })
it('should support comment insertion', () => { it('should support note insertion', () => {
cy.wait(1000).get('a').contains('Comments').click().wait(1000) cy.wait(1000).get('a').contains('Notes').click().wait(1000)
cy.get('app-document-comments') cy.get('app-document-notes')
.find('form textarea') .find('form textarea')
.type('Testing new comment') .type('Testing new note')
.wait(500) .wait(500)
cy.get('app-document-comments').find('form button').click().wait(1500) cy.get('app-document-notes').find('form button').click().wait(1500)
cy.get('app-document-comments').find('.card').its('length').should('eq', 4) cy.get('app-document-notes').find('.card').its('length').should('eq', 4)
})
it('should support navigation to notes tab by url', () => {
cy.visit('/documents/1/notes')
cy.get('app-document-notes').should('exist')
})
it('should dynamically update note counts', () => {
cy.visit('/documents/1/notes')
cy.get('app-document-notes').within(() => cy.contains('Delete').click())
cy.get('ul.nav').find('li').contains('Notes').find('.badge').contains('2')
}) })
}) })

View File

@ -48,6 +48,26 @@ describe('documents-list', () => {
(d.tags as Array<number>).includes(tag_id) (d.tags as Array<number>).includes(tag_id)
) )
response.count = response.results.length response.count = response.results.length
} else if (req.query.hasOwnProperty('correspondent__id__in')) {
// filtering e.g. http://localhost:8000/api/documents/?page=1&page_size=50&ordering=-created&correspondent__id__in=9,14
const correspondent_ids = req.query['correspondent__id__in']
.toString()
.split(',')
.map((c) => +c)
response.results = (documentsJson.results as Array<any>).filter((d) =>
correspondent_ids.includes(d.correspondent)
)
response.count = response.results.length
} else if (req.query.hasOwnProperty('correspondent__id__none')) {
// filtering e.g. http://localhost:8000/api/documents/?page=1&page_size=50&ordering=-created&correspondent__id__none=9,14
const correspondent_ids = req.query['correspondent__id__none']
.toString()
.split(',')
.map((c) => +c)
response.results = (documentsJson.results as Array<any>).filter(
(d) => !correspondent_ids.includes(d.correspondent)
)
response.count = response.results.length
} }
req.reply(response) req.reply(response)
@ -112,6 +132,27 @@ describe('documents-list', () => {
cy.contains('One document') cy.contains('One document')
}) })
it('should filter including multiple correspondents', () => {
cy.get('app-filter-editor app-filterable-dropdown[title="Correspondent"]')
.click()
.within(() => {
cy.contains('button', 'ABC Test Correspondent').click()
cy.contains('button', 'Corresp 11').click()
})
cy.contains('3 documents')
})
it('should filter excluding multiple correspondents', () => {
cy.get('app-filter-editor app-filterable-dropdown[title="Correspondent"]')
.click()
.within(() => {
cy.contains('button', 'ABC Test Correspondent').click()
cy.contains('button', 'Corresp 11').click()
cy.contains('label', 'Exclude').click()
})
cy.contains('One document')
})
it('should apply tags', () => { it('should apply tags', () => {
cy.get('app-document-card-small:first-of-type').click() cy.get('app-document-card-small:first-of-type').click()
cy.get('app-bulk-editor app-filterable-dropdown[title="Tags"]').within( cy.get('app-bulk-editor app-filterable-dropdown[title="Tags"]').within(

View File

@ -232,6 +232,11 @@ describe('documents query params', () => {
it('should show a list of documents filtered by document type', () => { it('should show a list of documents filtered by document type', () => {
cy.visit('/documents?sort=created&reverse=true&document_type__id=1') cy.visit('/documents?sort=created&reverse=true&document_type__id=1')
cy.contains('2 documents')
})
it('should show a list of documents filtered by multiple correspondents', () => {
cy.visit('/documents?sort=created&reverse=true&document_type__id__in=1,2')
cy.contains('3 documents') cy.contains('3 documents')
}) })
@ -245,9 +250,14 @@ describe('documents query params', () => {
cy.contains('2 documents') cy.contains('2 documents')
}) })
it('should show a list of documents filtered by multiple correspondents', () => {
cy.visit('/documents?sort=created&reverse=true&correspondent__id__in=9,14')
cy.contains('3 documents')
})
it('should show a list of documents filtered by no correspondent', () => { it('should show a list of documents filtered by no correspondent', () => {
cy.visit('/documents?sort=created&reverse=true&correspondent__isnull=1') cy.visit('/documents?sort=created&reverse=true&correspondent__isnull=1')
cy.contains('2 documents') cy.contains('One document')
}) })
it('should show a list of documents filtered by storage path', () => { it('should show a list of documents filtered by storage path', () => {

View File

@ -1 +1,257 @@
{"count":27,"next":"http://localhost:8000/api/correspondents/?page=2","previous":null,"results":[{"id":9,"slug":"abc-test-correspondent","name":"ABC Test Correspondent","match":"","matching_algorithm":1,"is_insensitive":true,"document_count":0,"last_correspondence":null},{"id":13,"slug":"corresp-10","name":"Corresp 10","match":"","matching_algorithm":1,"is_insensitive":true,"document_count":0,"last_correspondence":null},{"id":14,"slug":"corresp-11","name":"Corresp 11","match":"","matching_algorithm":1,"is_insensitive":true,"document_count":0,"last_correspondence":null},{"id":15,"slug":"corresp-12","name":"Corresp 12","match":"","matching_algorithm":1,"is_insensitive":true,"document_count":0,"last_correspondence":null},{"id":16,"slug":"corresp-13","name":"Corresp 13","match":"","matching_algorithm":1,"is_insensitive":true,"document_count":0,"last_correspondence":null},{"id":18,"slug":"corresp-15","name":"Corresp 15","match":"","matching_algorithm":1,"is_insensitive":true,"document_count":0,"last_correspondence":null},{"id":19,"slug":"corresp-16","name":"Corresp 16","match":"","matching_algorithm":1,"is_insensitive":true,"document_count":0,"last_correspondence":null},{"id":20,"slug":"corresp-17","name":"Corresp 17","match":"","matching_algorithm":1,"is_insensitive":true,"document_count":0,"last_correspondence":null},{"id":21,"slug":"corresp-18","name":"Corresp 18","match":"","matching_algorithm":1,"is_insensitive":true,"document_count":0,"last_correspondence":null},{"id":22,"slug":"corresp-19","name":"Corresp 19","match":"","matching_algorithm":1,"is_insensitive":true,"document_count":0,"last_correspondence":null},{"id":23,"slug":"corresp-20","name":"Corresp 20","match":"","matching_algorithm":1,"is_insensitive":true,"document_count":0,"last_correspondence":null},{"id":24,"slug":"corresp-21","name":"Corresp 21","match":"","matching_algorithm":1,"is_insensitive":true,"document_count":0,"last_correspondence":null},{"id":25,"slug":"corresp-22","name":"Corresp 22","match":"","matching_algorithm":1,"is_insensitive":true,"document_count":0,"last_correspondence":null},{"id":26,"slug":"corresp-23","name":"Corresp 23","match":"","matching_algorithm":1,"is_insensitive":true,"document_count":0,"last_correspondence":null},{"id":5,"slug":"corresp-3","name":"Corresp 3","match":"","matching_algorithm":1,"is_insensitive":true,"document_count":0,"last_correspondence":null},{"id":6,"slug":"corresp-4","name":"Corresp 4","match":"","matching_algorithm":1,"is_insensitive":true,"document_count":0,"last_correspondence":null},{"id":7,"slug":"corresp-5","name":"Corresp 5","match":"","matching_algorithm":1,"is_insensitive":true,"document_count":0,"last_correspondence":null},{"id":8,"slug":"corresp-6","name":"Corresp 6","match":"","matching_algorithm":1,"is_insensitive":true,"document_count":0,"last_correspondence":null},{"id":10,"slug":"corresp-7","name":"Corresp 7","match":"","matching_algorithm":1,"is_insensitive":true,"document_count":0,"last_correspondence":null},{"id":11,"slug":"corresp-8","name":"Corresp 8","match":"","matching_algorithm":1,"is_insensitive":true,"document_count":0,"last_correspondence":null},{"id":12,"slug":"corresp-9","name":"Corresp 9","match":"","matching_algorithm":1,"is_insensitive":true,"document_count":0,"last_correspondence":null},{"id":17,"slug":"correspondent-14","name":"Correspondent 14","match":"","matching_algorithm":1,"is_insensitive":true,"document_count":0,"last_correspondence":null},{"id":2,"slug":"correspondent-2","name":"Correspondent 2","match":"","matching_algorithm":1,"is_insensitive":true,"document_count":7,"last_correspondence":"2021-01-20T23:37:58.204614Z"},{"id":27,"slug":"michael-shamoon","name":"Michael Shamoon","match":"","matching_algorithm":1,"is_insensitive":true,"document_count":1,"last_correspondence":"2022-03-16T03:48:50.089624Z"},{"id":4,"slug":"newest-correspondent","name":"Newest Correspondent","match":"","matching_algorithm":1,"is_insensitive":true,"document_count":1,"last_correspondence":"2021-02-07T08:00:00Z"}]} {
"count": 27,
"next": "http://localhost:8000/api/correspondents/?page=2",
"previous": null,
"results": [
{
"id": 9,
"slug": "abc-test-correspondent",
"name": "ABC Test Correspondent",
"match": "",
"matching_algorithm": 1,
"is_insensitive": true,
"document_count": 0,
"last_correspondence": null
},
{
"id": 13,
"slug": "corresp-10",
"name": "Corresp 10",
"match": "",
"matching_algorithm": 1,
"is_insensitive": true,
"document_count": 0,
"last_correspondence": null
},
{
"id": 14,
"slug": "corresp-11",
"name": "Corresp 11",
"match": "",
"matching_algorithm": 1,
"is_insensitive": true,
"document_count": 0,
"last_correspondence": null
},
{
"id": 15,
"slug": "corresp-12",
"name": "Corresp 12",
"match": "",
"matching_algorithm": 1,
"is_insensitive": true,
"document_count": 0,
"last_correspondence": null
},
{
"id": 16,
"slug": "corresp-13",
"name": "Corresp 13",
"match": "",
"matching_algorithm": 1,
"is_insensitive": true,
"document_count": 0,
"last_correspondence": null
},
{
"id": 18,
"slug": "corresp-15",
"name": "Corresp 15",
"match": "",
"matching_algorithm": 1,
"is_insensitive": true,
"document_count": 0,
"last_correspondence": null
},
{
"id": 19,
"slug": "corresp-16",
"name": "Corresp 16",
"match": "",
"matching_algorithm": 1,
"is_insensitive": true,
"document_count": 0,
"last_correspondence": null
},
{
"id": 20,
"slug": "corresp-17",
"name": "Corresp 17",
"match": "",
"matching_algorithm": 1,
"is_insensitive": true,
"document_count": 0,
"last_correspondence": null
},
{
"id": 21,
"slug": "corresp-18",
"name": "Corresp 18",
"match": "",
"matching_algorithm": 1,
"is_insensitive": true,
"document_count": 0,
"last_correspondence": null
},
{
"id": 22,
"slug": "corresp-19",
"name": "Corresp 19",
"match": "",
"matching_algorithm": 1,
"is_insensitive": true,
"document_count": 0,
"last_correspondence": null
},
{
"id": 23,
"slug": "corresp-20",
"name": "Corresp 20",
"match": "",
"matching_algorithm": 1,
"is_insensitive": true,
"document_count": 0,
"last_correspondence": null
},
{
"id": 24,
"slug": "corresp-21",
"name": "Corresp 21",
"match": "",
"matching_algorithm": 1,
"is_insensitive": true,
"document_count": 0,
"last_correspondence": null
},
{
"id": 25,
"slug": "corresp-22",
"name": "Corresp 22",
"match": "",
"matching_algorithm": 1,
"is_insensitive": true,
"document_count": 0,
"last_correspondence": null
},
{
"id": 26,
"slug": "corresp-23",
"name": "Corresp 23",
"match": "",
"matching_algorithm": 1,
"is_insensitive": true,
"document_count": 0,
"last_correspondence": null
},
{
"id": 5,
"slug": "corresp-3",
"name": "Corresp 3",
"match": "",
"matching_algorithm": 1,
"is_insensitive": true,
"document_count": 0,
"last_correspondence": null
},
{
"id": 6,
"slug": "corresp-4",
"name": "Corresp 4",
"match": "",
"matching_algorithm": 1,
"is_insensitive": true,
"document_count": 0,
"last_correspondence": null
},
{
"id": 7,
"slug": "corresp-5",
"name": "Corresp 5",
"match": "",
"matching_algorithm": 1,
"is_insensitive": true,
"document_count": 0,
"last_correspondence": null
},
{
"id": 8,
"slug": "corresp-6",
"name": "Corresp 6",
"match": "",
"matching_algorithm": 1,
"is_insensitive": true,
"document_count": 0,
"last_correspondence": null
},
{
"id": 10,
"slug": "corresp-7",
"name": "Corresp 7",
"match": "",
"matching_algorithm": 1,
"is_insensitive": true,
"document_count": 0,
"last_correspondence": null
},
{
"id": 11,
"slug": "corresp-8",
"name": "Corresp 8",
"match": "",
"matching_algorithm": 1,
"is_insensitive": true,
"document_count": 0,
"last_correspondence": null
},
{
"id": 12,
"slug": "corresp-9",
"name": "Corresp 9",
"match": "",
"matching_algorithm": 1,
"is_insensitive": true,
"document_count": 0,
"last_correspondence": null
},
{
"id": 17,
"slug": "correspondent-14",
"name": "Correspondent 14",
"match": "",
"matching_algorithm": 1,
"is_insensitive": true,
"document_count": 0,
"last_correspondence": null
},
{
"id": 2,
"slug": "correspondent-2",
"name": "Correspondent 2",
"match": "",
"matching_algorithm": 1,
"is_insensitive": true,
"document_count": 7,
"last_correspondence": "2021-01-20T23:37:58.204614Z"
},
{
"id": 27,
"slug": "correspondent-slug",
"name": "Correspondent Slug",
"match": "",
"matching_algorithm": 1,
"is_insensitive": true,
"document_count": 1,
"last_correspondence": "2022-03-16T03:48:50.089624Z"
},
{
"id": 4,
"slug": "newest-correspondent",
"name": "Newest Correspondent",
"match": "",
"matching_algorithm": 1,
"is_insensitive": true,
"document_count": 1,
"last_correspondence": "2021-02-07T08:00:00Z"
}
]
}

View File

@ -1 +1,25 @@
{"count":1,"next":null,"previous":null,"results":[{"id":1,"slug":"test","name":"Test Doc Type","match":"","matching_algorithm":1,"is_insensitive":true,"document_count":0}]} {
"count": 2,
"next": null,
"previous": null,
"results": [
{
"id": 1,
"slug": "test",
"name": "Test Doc Type",
"match": "",
"matching_algorithm": 1,
"is_insensitive": true,
"document_count": 1
},
{
"id": 2,
"slug": "test2",
"name": "Test Doc Type 2",
"match": "",
"matching_algorithm": 1,
"is_insensitive": true,
"document_count": 1
}
]
}

View File

@ -1,46 +0,0 @@
[
{
"id": 10,
"comment": "Testing new comment",
"created": "2022-08-08T04:24:55.176008Z",
"user": {
"id": 1,
"username": "user2",
"first_name": "",
"last_name": ""
}
},
{
"id": 9,
"comment": "Testing one more time",
"created": "2022-02-18T04:24:55.176008Z",
"user": {
"id": 2,
"username": "user1",
"first_name": "",
"last_name": ""
}
},
{
"id": 8,
"comment": "Another comment",
"created": "2021-11-08T04:24:47.925042Z",
"user": {
"id": 2,
"username": "user33",
"first_name": "",
"last_name": ""
}
},
{
"id": 7,
"comment": "Cupcake ipsum dolor sit amet cheesecake candy cookie tiramisu. Donut chocolate chupa chups macaroon brownie halvah pie cheesecake gummies. Sweet chocolate bar candy donut gummi bears bear claw liquorice bonbon shortbread.\n\nDonut chocolate bar candy wafer wafer tiramisu. Gummies chocolate cake muffin toffee carrot cake macaroon. Toffee toffee jelly beans danish lollipop cake.",
"created": "2021-02-08T02:37:49.724132Z",
"user": {
"id": 3,
"username": "admin",
"first_name": "",
"last_name": ""
}
}
]

View File

@ -0,0 +1,26 @@
[
{
"id": 10,
"note": "Testing new note",
"created": "2022-08-08T04:24:55.176008Z",
"user": 3
},
{
"id": 9,
"note": "Testing one more time",
"created": "2022-02-18T04:24:55.176008Z",
"user": 15
},
{
"id": 8,
"note": "Another note",
"created": "2021-11-08T04:24:47.925042Z",
"user": 3
},
{
"id": 7,
"note": "Cupcake ipsum dolor sit amet cheesecake candy cookie tiramisu. Donut chocolate chupa chups macaroon brownie halvah pie cheesecake gummies. Sweet chocolate bar candy donut gummi bears bear claw liquorice bonbon shortbread.\n\nDonut chocolate bar candy wafer wafer tiramisu. Gummies chocolate cake muffin toffee carrot cake macaroon. Toffee toffee jelly beans danish lollipop cake.",
"created": "2021-02-08T02:37:49.724132Z",
"user": 3
}
]

View File

@ -21,7 +21,27 @@
"original_file_name": "2022-03-22 no latin title.pdf", "original_file_name": "2022-03-22 no latin title.pdf",
"archived_file_name": "2022-03-22 no latin title.pdf", "archived_file_name": "2022-03-22 no latin title.pdf",
"owner": null, "owner": null,
"permissions": [] "permissions": [],
"notes": [
{
"id": 9,
"note": "Testing one more time",
"created": "2022-02-18T04:24:55.176008Z",
"user": 15
},
{
"id": 8,
"note": "Another note",
"created": "2021-11-08T04:24:47.925042Z",
"user": 3
},
{
"id": 7,
"note": "Cupcake ipsum dolor sit amet cheesecake candy cookie tiramisu. Donut chocolate chupa chups macaroon brownie halvah pie cheesecake gummies. Sweet chocolate bar candy donut gummi bears bear claw liquorice bonbon shortbread.\n\nDonut chocolate bar candy wafer wafer tiramisu. Gummies chocolate cake muffin toffee carrot cake macaroon. Toffee toffee jelly beans danish lollipop cake.",
"created": "2021-02-08T02:37:49.724132Z",
"user": 3
}
]
}, },
{ {
"id": 2, "id": 2,
@ -39,11 +59,12 @@
"original_file_name": "2022-03-23 lorem ipsum dolor sit amet.pdf", "original_file_name": "2022-03-23 lorem ipsum dolor sit amet.pdf",
"archived_file_name": "2022-03-23 llorem ipsum dolor sit amet.pdf", "archived_file_name": "2022-03-23 llorem ipsum dolor sit amet.pdf",
"owner": null, "owner": null,
"permissions": [] "permissions": [],
"notes": []
}, },
{ {
"id": 3, "id": 3,
"correspondent": null, "correspondent": 14,
"document_type": 1, "document_type": 1,
"storage_path": null, "storage_path": null,
"title": "dolor", "title": "dolor",
@ -59,12 +80,13 @@
"original_file_name": "2022-03-24 dolor.pdf", "original_file_name": "2022-03-24 dolor.pdf",
"archived_file_name": "2022-03-24 dolor.pdf", "archived_file_name": "2022-03-24 dolor.pdf",
"owner": null, "owner": null,
"permissions": [] "permissions": [],
"notes": []
}, },
{ {
"id": 4, "id": 4,
"correspondent": 9, "correspondent": 9,
"document_type": 1, "document_type": 2,
"storage_path": null, "storage_path": null,
"title": "sit amet", "title": "sit amet",
"content": "Test document PDF", "content": "Test document PDF",
@ -79,7 +101,8 @@
"original_file_name": "2022-06-01 sit amet.pdf", "original_file_name": "2022-06-01 sit amet.pdf",
"archived_file_name": "2022-06-01 sit amet.pdf", "archived_file_name": "2022-06-01 sit amet.pdf",
"owner": null, "owner": null,
"permissions": [] "permissions": [],
"notes": []
} }
] ]
} }

View File

@ -11,10 +11,10 @@
"change_user", "change_user",
"delete_user", "delete_user",
"view_user", "view_user",
"add_comment", "add_note",
"change_comment", "change_note",
"delete_comment", "delete_note",
"view_comment" "view_note"
] ]
}, },
{ {
@ -73,10 +73,10 @@
"change_task", "change_task",
"delete_task", "delete_task",
"view_task", "view_task",
"add_comment", "add_note",
"change_comment", "change_note",
"delete_comment", "delete_note",
"view_comment", "view_note",
"add_correspondent", "add_correspondent",
"change_correspondent", "change_correspondent",
"delete_correspondent", "delete_correspondent",

View File

@ -94,10 +94,10 @@
"change_task", "change_task",
"delete_task", "delete_task",
"view_task", "view_task",
"add_comment", "add_note",
"change_comment", "change_note",
"delete_comment", "delete_note",
"view_comment", "view_note",
"add_correspondent", "add_correspondent",
"change_correspondent", "change_correspondent",
"delete_correspondent", "delete_correspondent",

View File

@ -74,7 +74,7 @@
"change_task", "change_task",
"delete_task", "delete_task",
"view_task", "view_task",
"add_comment", "add_note",
"add_frontendsettings", "add_frontendsettings",
"change_frontendsettings", "change_frontendsettings",
"delete_frontendsettings", "delete_frontendsettings",

View File

@ -30,7 +30,7 @@
"django_celery_results.delete_taskresult", "django_celery_results.delete_taskresult",
"paperless_mail.add_mailaccount", "paperless_mail.add_mailaccount",
"auth.change_group", "auth.change_group",
"documents.add_comment", "documents.add_note",
"paperless_mail.delete_mailaccount", "paperless_mail.delete_mailaccount",
"authtoken.delete_tokenproxy", "authtoken.delete_tokenproxy",
"guardian.delete_groupobjectpermission", "guardian.delete_groupobjectpermission",
@ -44,7 +44,7 @@
"documents.add_documenttype", "documents.add_documenttype",
"django_q.change_success", "django_q.change_success",
"documents.delete_tag", "documents.delete_tag",
"documents.change_comment", "documents.change_note",
"django_q.delete_task", "django_q.delete_task",
"documents.add_savedviewfilterrule", "documents.add_savedviewfilterrule",
"django_q.view_task", "django_q.view_task",
@ -59,7 +59,7 @@
"documents.add_savedview", "documents.add_savedview",
"auth.delete_user", "auth.delete_user",
"documents.view_log", "documents.view_log",
"documents.view_comment", "documents.view_note",
"guardian.change_groupobjectpermission", "guardian.change_groupobjectpermission",
"sessions.delete_session", "sessions.delete_session",
"django_q.change_failure", "django_q.change_failure",
@ -139,7 +139,7 @@
"django_celery_results.view_taskresult", "django_celery_results.view_taskresult",
"contenttypes.add_contenttype", "contenttypes.add_contenttype",
"django_q.delete_success", "django_q.delete_success",
"documents.delete_comment", "documents.delete_note",
"django_q.add_failure", "django_q.add_failure",
"guardian.add_userobjectpermission", "guardian.add_userobjectpermission",
"sessions.view_session", "sessions.view_session",
@ -216,10 +216,10 @@
"change_task", "change_task",
"delete_task", "delete_task",
"view_task", "view_task",
"add_comment", "add_note",
"change_comment", "change_note",
"delete_comment", "delete_note",
"view_comment", "view_note",
"add_frontendsettings", "add_frontendsettings",
"change_frontendsettings", "change_frontendsettings",
"delete_frontendsettings", "delete_frontendsettings",
@ -256,7 +256,7 @@
"django_celery_results.delete_taskresult", "django_celery_results.delete_taskresult",
"authtoken.change_token", "authtoken.change_token",
"auth.change_group", "auth.change_group",
"documents.add_comment", "documents.add_note",
"authtoken.delete_tokenproxy", "authtoken.delete_tokenproxy",
"documents.view_documenttype", "documents.view_documenttype",
"contenttypes.delete_contenttype", "contenttypes.delete_contenttype",
@ -285,7 +285,7 @@
"django_q.change_task", "django_q.change_task",
"sessions.add_session", "sessions.add_session",
"documents.change_taskattributes", "documents.change_taskattributes",
"documents.change_comment", "documents.change_note",
"django_q.delete_task", "django_q.delete_task",
"django_q.delete_ormq", "django_q.delete_ormq",
"auth.change_permission", "auth.change_permission",
@ -311,7 +311,7 @@
"documents.view_document", "documents.view_document",
"documents.add_savedview", "documents.add_savedview",
"django_q.view_failure", "django_q.view_failure",
"documents.view_comment", "documents.view_note",
"documents.view_log", "documents.view_log",
"documents.add_log", "documents.add_log",
"documents.change_savedview", "documents.change_savedview",
@ -324,7 +324,7 @@
"django_celery_results.view_taskresult", "django_celery_results.view_taskresult",
"contenttypes.add_contenttype", "contenttypes.add_contenttype",
"django_q.delete_success", "django_q.delete_success",
"documents.delete_comment", "documents.delete_note",
"django_q.add_failure", "django_q.add_failure",
"sessions.view_session", "sessions.view_session",
"contenttypes.view_contenttype", "contenttypes.view_contenttype",
@ -373,7 +373,7 @@
"django_celery_results.delete_taskresult", "django_celery_results.delete_taskresult",
"authtoken.change_token", "authtoken.change_token",
"auth.change_group", "auth.change_group",
"documents.add_comment", "documents.add_note",
"authtoken.delete_tokenproxy", "authtoken.delete_tokenproxy",
"documents.view_documenttype", "documents.view_documenttype",
"contenttypes.delete_contenttype", "contenttypes.delete_contenttype",
@ -402,7 +402,7 @@
"django_q.change_task", "django_q.change_task",
"sessions.add_session", "sessions.add_session",
"documents.change_taskattributes", "documents.change_taskattributes",
"documents.change_comment", "documents.change_note",
"django_q.delete_task", "django_q.delete_task",
"django_q.delete_ormq", "django_q.delete_ormq",
"auth.change_permission", "auth.change_permission",
@ -429,7 +429,7 @@
"documents.view_document", "documents.view_document",
"documents.add_savedview", "documents.add_savedview",
"django_q.view_failure", "django_q.view_failure",
"documents.view_comment", "documents.view_note",
"documents.view_log", "documents.view_log",
"auth.delete_user", "auth.delete_user",
"documents.add_log", "documents.add_log",
@ -443,7 +443,7 @@
"django_celery_results.view_taskresult", "django_celery_results.view_taskresult",
"contenttypes.add_contenttype", "contenttypes.add_contenttype",
"django_q.delete_success", "django_q.delete_success",
"documents.delete_comment", "documents.delete_note",
"django_q.add_failure", "django_q.add_failure",
"sessions.view_session", "sessions.view_session",
"contenttypes.view_contenttype", "contenttypes.view_contenttype",

File diff suppressed because it is too large Load Diff

View File

@ -22,6 +22,7 @@
"@popperjs/core": "^2.11.6", "@popperjs/core": "^2.11.6",
"bootstrap": "^5.2.3", "bootstrap": "^5.2.3",
"file-saver": "^2.0.5", "file-saver": "^2.0.5",
"mime-names": "^1.0.0",
"ng2-pdf-viewer": "^9.1.2", "ng2-pdf-viewer": "^9.1.2",
"ngx-color": "^8.0.3", "ngx-color": "^8.0.3",
"ngx-cookie-service": "^15.0.0", "ngx-cookie-service": "^15.0.0",
@ -13766,6 +13767,11 @@
"node": ">= 0.6" "node": ">= 0.6"
} }
}, },
"node_modules/mime-names": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/mime-names/-/mime-names-1.0.0.tgz",
"integrity": "sha512-vLNEfYU63fz34panv/L3Lh3eW3+v0BlOB+bSGFdntv/gBNnokCbSsaNuHR9vH/NS5oWbL0HqMQf/3we4fRJyIQ=="
},
"node_modules/mime-types": { "node_modules/mime-types": {
"version": "2.1.35", "version": "2.1.35",
"resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz",

View File

@ -27,6 +27,7 @@
"@popperjs/core": "^2.11.6", "@popperjs/core": "^2.11.6",
"bootstrap": "^5.2.3", "bootstrap": "^5.2.3",
"file-saver": "^2.0.5", "file-saver": "^2.0.5",
"mime-names": "^1.0.0",
"ng2-pdf-viewer": "^9.1.2", "ng2-pdf-viewer": "^9.1.2",
"ngx-color": "^8.0.3", "ngx-color": "^8.0.3",
"ngx-cookie-service": "^15.0.0", "ngx-cookie-service": "^15.0.0",

View File

@ -65,6 +65,17 @@ const routes: Routes = [
}, },
}, },
}, },
{
path: 'documents/:id/:section',
component: DocumentDetailComponent,
canActivate: [PermissionsGuard],
data: {
requiredPermission: {
action: PermissionAction.View,
type: PermissionType.Document,
},
},
},
{ {
path: 'asn/:id', path: 'asn/:id',
component: DocumentAsnComponent, component: DocumentAsnComponent,
@ -143,17 +154,6 @@ const routes: Routes = [
}, },
}, },
}, },
{
path: 'tasks',
component: TasksComponent,
canActivate: [PermissionsGuard],
data: {
requiredPermission: {
action: PermissionAction.View,
type: PermissionType.PaperlessTask,
},
},
},
{ {
path: 'settings/:section', path: 'settings/:section',
component: SettingsComponent, component: SettingsComponent,
@ -171,6 +171,17 @@ const routes: Routes = [
component: SettingsComponent, component: SettingsComponent,
canDeactivate: [DirtyFormGuard], canDeactivate: [DirtyFormGuard],
}, },
{
path: 'tasks',
component: TasksComponent,
canActivate: [PermissionsGuard],
data: {
requiredPermission: {
action: PermissionAction.View,
type: PermissionType.PaperlessTask,
},
},
},
{ path: 'tasks', component: TasksComponent }, { path: 'tasks', component: TasksComponent },
], ],
}, },

View File

@ -70,7 +70,7 @@ import { ApiVersionInterceptor } from './interceptors/api-version.interceptor'
import { ColorSliderModule } from 'ngx-color/slider' import { ColorSliderModule } from 'ngx-color/slider'
import { ColorComponent } from './components/common/input/color/color.component' import { ColorComponent } from './components/common/input/color/color.component'
import { DocumentAsnComponent } from './components/document-asn/document-asn.component' import { DocumentAsnComponent } from './components/document-asn/document-asn.component'
import { DocumentCommentsComponent } from './components/document-comments/document-comments.component' import { DocumentNotesComponent } from './components/document-notes/document-notes.component'
import { PermissionsGuard } from './guards/permissions.guard' import { PermissionsGuard } from './guards/permissions.guard'
import { DirtyDocGuard } from './guards/dirty-doc.guard' import { DirtyDocGuard } from './guards/dirty-doc.guard'
import { DirtySavedViewGuard } from './guards/dirty-saved-view.guard' import { DirtySavedViewGuard } from './guards/dirty-saved-view.guard'
@ -196,7 +196,7 @@ function initializeApp(settings: SettingsService) {
DateComponent, DateComponent,
ColorComponent, ColorComponent,
DocumentAsnComponent, DocumentAsnComponent,
DocumentCommentsComponent, DocumentNotesComponent,
TasksComponent, TasksComponent,
UserEditDialogComponent, UserEditDialogComponent,
GroupEditDialogComponent, GroupEditDialogComponent,

View File

@ -20,7 +20,7 @@ export abstract class EditDialogComponent<
> implements OnInit > implements OnInit
{ {
constructor( constructor(
private service: AbstractPaperlessService<T>, protected service: AbstractPaperlessService<T>,
private activeModal: NgbActiveModal, private activeModal: NgbActiveModal,
private userService: UserService private userService: UserService
) {} ) {}

View File

@ -15,11 +15,22 @@
<div class="col"> <div class="col">
<app-input-text i18n-title title="Username" formControlName="username" [error]="error?.username"></app-input-text> <app-input-text i18n-title title="Username" formControlName="username" [error]="error?.username"></app-input-text>
<app-input-password i18n-title title="Password" formControlName="password" [error]="error?.password"></app-input-password> <app-input-password i18n-title title="Password" formControlName="password" [error]="error?.password"></app-input-password>
<app-input-check i18n-title title="Password is token" i18n-hint hint="Check if the password above is a token used for authentication" formControlName="is_token" [error]="error?.is_token"></app-input-check>
<app-input-text i18n-title title="Character Set" formControlName="character_set" [error]="error?.character_set"></app-input-text> <app-input-text i18n-title title="Character Set" formControlName="character_set" [error]="error?.character_set"></app-input-text>
</div> </div>
</div> </div>
</div> </div>
<div class="modal-footer"> <div class="modal-footer">
<div class="m-0 me-2">
<ngb-alert #testResultAlert *ngIf="testResult" [type]="testResult" class="mb-0 py-2" (closed)="testResult = null">{{testResultMessage}}</ngb-alert>
</div>
<button type="button" class="btn btn-outline-primary" (click)="test()" [disabled]="networkActive || testActive">
<ng-container *ngIf="testActive">
<div class="spinner-border spinner-border-sm me-2" role="status"></div>
<span class="visually-hidden mr-1" i18n>Loading...</span>
</ng-container>
<ng-container i18n>Test</ng-container>
</button>
<button type="button" class="btn btn-outline-secondary" (click)="cancel()" i18n [disabled]="networkActive">Cancel</button> <button type="button" class="btn btn-outline-secondary" (click)="cancel()" i18n [disabled]="networkActive">Cancel</button>
<button type="submit" class="btn btn-primary" i18n [disabled]="networkActive">Save</button> <button type="submit" class="btn btn-primary" i18n [disabled]="networkActive">Save</button>
</div> </div>

View File

@ -0,0 +1,4 @@
::ng-deep .alert-dismissible .btn-close {
padding-top: 0.75rem !important;
padding-bottom: 0.75rem !important;
}

View File

@ -1,6 +1,6 @@
import { Component } from '@angular/core' import { Component, ViewChild } from '@angular/core'
import { FormControl, FormGroup } from '@angular/forms' import { FormControl, FormGroup } from '@angular/forms'
import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap' import { NgbActiveModal, NgbAlert } from '@ng-bootstrap/ng-bootstrap'
import { EditDialogComponent } from 'src/app/components/common/edit-dialog/edit-dialog.component' import { EditDialogComponent } from 'src/app/components/common/edit-dialog/edit-dialog.component'
import { import {
IMAPSecurity, IMAPSecurity,
@ -21,6 +21,12 @@ const IMAP_SECURITY_OPTIONS = [
styleUrls: ['./mail-account-edit-dialog.component.scss'], styleUrls: ['./mail-account-edit-dialog.component.scss'],
}) })
export class MailAccountEditDialogComponent extends EditDialogComponent<PaperlessMailAccount> { export class MailAccountEditDialogComponent extends EditDialogComponent<PaperlessMailAccount> {
testActive: boolean = false
testResult: string
alertTimeout
@ViewChild('testResultAlert', { static: false }) testResultAlert: NgbAlert
constructor( constructor(
service: MailAccountService, service: MailAccountService,
activeModal: NgbActiveModal, activeModal: NgbActiveModal,
@ -45,6 +51,7 @@ export class MailAccountEditDialogComponent extends EditDialogComponent<Paperles
imap_security: new FormControl(IMAPSecurity.SSL), imap_security: new FormControl(IMAPSecurity.SSL),
username: new FormControl(null), username: new FormControl(null),
password: new FormControl(null), password: new FormControl(null),
is_token: new FormControl(false),
character_set: new FormControl('UTF-8'), character_set: new FormControl('UTF-8'),
}) })
} }
@ -52,4 +59,33 @@ export class MailAccountEditDialogComponent extends EditDialogComponent<Paperles
get imapSecurityOptions() { get imapSecurityOptions() {
return IMAP_SECURITY_OPTIONS return IMAP_SECURITY_OPTIONS
} }
test() {
this.testActive = true
this.testResult = null
clearTimeout(this.alertTimeout)
const mailService = this.service as MailAccountService
const newObject = Object.assign(
Object.assign({}, this.object),
this.objectForm.value
)
mailService.test(newObject).subscribe({
next: (result: { success: boolean }) => {
this.testActive = false
this.testResult = result.success ? 'success' : 'danger'
this.alertTimeout = setTimeout(() => this.testResultAlert.close(), 5000)
},
error: (e) => {
this.testActive = false
this.testResult = 'danger'
this.alertTimeout = setTimeout(() => this.testResultAlert.close(), 5000)
},
})
}
get testResultMessage() {
return this.testResult === 'success'
? $localize`Successfully connected to the mail server`
: $localize`Unable to connect to the mail server`
}
} }

View File

@ -1,21 +1,29 @@
<div class="btn-group w-100" ngbDropdown role="group" (openChange)="dropdownOpenChange($event)" #dropdown="ngbDropdown"> <div class="btn-group w-100" ngbDropdown role="group" (openChange)="dropdownOpenChange($event)" #dropdown="ngbDropdown">
<button class="btn btn-sm" id="dropdown{{title}}" ngbDropdownToggle [ngClass]="!editing && selectionModel.selectionSize() > 0 ? 'btn-primary' : 'btn-outline-primary'" [disabled]="disabled"> <button class="btn btn-sm" id="dropdown_{{name}}" ngbDropdownToggle [ngClass]="!editing && selectionModel.selectionSize() > 0 ? 'btn-primary' : 'btn-outline-primary'" [disabled]="disabled">
<svg class="toolbaricon" fill="currentColor"> <svg class="toolbaricon" fill="currentColor">
<use attr.xlink:href="assets/bootstrap-icons.svg#{{icon}}" /> <use attr.xlink:href="assets/bootstrap-icons.svg#{{icon}}" />
</svg> </svg>
<div class="d-none d-sm-inline">&nbsp;{{title}}</div> <div class="d-none d-sm-inline">&nbsp;{{title}}</div>
<ng-container *ngIf="!editing && selectionModel.totalCount > 0"> <ng-container *ngIf="!editing && selectionModel.totalCount > 0">
<app-clearable-badge [number]="multiple ? selectionModel.totalCount : undefined" [selected]="!multiple && selectionModel.selectionSize() > 0" (cleared)="reset()"></app-clearable-badge> <app-clearable-badge [number]="selectionModel.totalCount" [selected]="selectionModel.selectionSize() > 0" (cleared)="reset()"></app-clearable-badge>
</ng-container> </ng-container>
</button> </button>
<div class="dropdown-menu py-0 shadow" ngbDropdownMenu attr.aria-labelledby="dropdown{{title}}"> <div class="dropdown-menu py-0 shadow" ngbDropdownMenu attr.aria-labelledby="dropdown_{{name}}">
<div class="list-group list-group-flush"> <div class="list-group list-group-flush">
<div *ngIf="!editing && multiple" class="list-group-item d-flex"> <div *ngIf="!editing && manyToOne" class="list-group-item d-flex">
<div class="btn-group btn-group-xs flex-fill"> <div class="btn-group btn-group-xs flex-fill" role="group">
<input [(ngModel)]="selectionModel.logicalOperator" [disabled]="!operatorToggleEnabled" (ngModelChange)="selectionModel.toggleOperator()" type="radio" class="btn-check" id="logicalOperatorAnd" value="and"> <input [(ngModel)]="selectionModel.logicalOperator" [disabled]="!modifierToggleEnabled" (ngModelChange)="selectionModel.toggleOperator()" type="radio" class="btn-check" id="logicalOperatorAnd_{{name}}" name="logicalOperatorAnd_{{name}}" value="and">
<label class="btn btn-outline-primary" for="logicalOperatorAnd" i18n>All</label> <label class="btn btn-outline-primary" for="logicalOperatorAnd_{{name}}" i18n>All</label>
<input [(ngModel)]="selectionModel.logicalOperator" [disabled]="!operatorToggleEnabled" (ngModelChange)="selectionModel.toggleOperator()" type="radio" class="btn-check" id="logicalOperatorOr" value="or"> <input [(ngModel)]="selectionModel.logicalOperator" [disabled]="!modifierToggleEnabled" (ngModelChange)="selectionModel.toggleOperator()" type="radio" class="btn-check" id="logicalOperatorOr_{{name}}" name="logicalOperatorOr_{{name}}" value="or">
<label class="btn btn-outline-primary" for="logicalOperatorOr" i18n>Any</label> <label class="btn btn-outline-primary" for="logicalOperatorOr_{{name}}" i18n>Any</label>
</div>
</div>
<div *ngIf="!editing && !manyToOne" class="list-group-item d-flex">
<div class="btn-group btn-group-xs flex-fill" role="group">
<input [(ngModel)]="selectionModel.intersection" [disabled]="!modifierToggleEnabled" (ngModelChange)="selectionModel.toggleIntersection()" type="radio" class="btn-check" id="intersectionInclude_{{name}}" name="intersectionInclude_{{name}}" value="include">
<label class="btn btn-outline-primary" for="intersectionInclude_{{name}}" i18n>Include</label>
<input [(ngModel)]="selectionModel.intersection" [disabled]="!modifierToggleEnabled" (ngModelChange)="selectionModel.toggleIntersection()" type="radio" class="btn-check" id="intersectionExclude_{{name}}" name="intersectionExclude_{{name}}" value="exclude">
<label class="btn btn-outline-primary" for="intersectionExclude_{{name}}" i18n>Exclude</label>
</div> </div>
</div> </div>
<div class="list-group-item"> <div class="list-group-item">
@ -34,7 +42,7 @@
<use xlink:href="assets/bootstrap-icons.svg#arrow-right" /> <use xlink:href="assets/bootstrap-icons.svg#arrow-right" />
</svg> </svg>
</button> </button>
<div *ngIf="!editing && multiple" class="list-group-item list-group-item-note pt-1 pb-2"> <div *ngIf="!editing && manyToOne" class="list-group-item list-group-item-note pt-1 pb-2">
<small i18n>Click again to exclude items.</small> <small i18n>Click again to exclude items.</small>
</div> </div>
</div> </div>

View File

@ -18,12 +18,25 @@ export interface ChangedItems {
itemsToRemove: MatchingModel[] itemsToRemove: MatchingModel[]
} }
export enum LogicalOperator {
And = 'and',
Or = 'or',
}
export enum Intersection {
Include = 'include',
Exclude = 'exclude',
}
export class FilterableDropdownSelectionModel { export class FilterableDropdownSelectionModel {
changed = new Subject<FilterableDropdownSelectionModel>() changed = new Subject<FilterableDropdownSelectionModel>()
multiple = false manyToOne = false
private _logicalOperator = 'and' singleSelect = false
temporaryLogicalOperator = this._logicalOperator private _logicalOperator: LogicalOperator = LogicalOperator.And
temporaryLogicalOperator: LogicalOperator = this._logicalOperator
private _intersection: Intersection = Intersection.Include
temporaryIntersection: Intersection = this._intersection
items: MatchingModel[] = [] items: MatchingModel[] = []
@ -86,7 +99,30 @@ export class FilterableDropdownSelectionModel {
(state != ToggleableItemState.Selected && (state != ToggleableItemState.Selected &&
state != ToggleableItemState.Excluded) state != ToggleableItemState.Excluded)
) { ) {
this.temporarySelectionStates.set(id, ToggleableItemState.Selected) if (this.manyToOne || this.singleSelect) {
this.temporarySelectionStates.set(id, ToggleableItemState.Selected)
if (this.singleSelect) {
for (let key of this.temporarySelectionStates.keys()) {
if (key != id) {
this.temporarySelectionStates.delete(key)
}
}
}
} else {
let newState =
this.intersection == Intersection.Include
? ToggleableItemState.Selected
: ToggleableItemState.Excluded
if (!id) newState = ToggleableItemState.Selected
if (
state == ToggleableItemState.Excluded &&
this.intersection == Intersection.Exclude
) {
newState = ToggleableItemState.NotSelected
}
this.temporarySelectionStates.set(id, newState)
}
} else if ( } else if (
state == ToggleableItemState.Selected || state == ToggleableItemState.Selected ||
state == ToggleableItemState.Excluded state == ToggleableItemState.Excluded
@ -94,14 +130,6 @@ export class FilterableDropdownSelectionModel {
this.temporarySelectionStates.delete(id) this.temporarySelectionStates.delete(id)
} }
if (!this.multiple) {
for (let key of this.temporarySelectionStates.keys()) {
if (key != id) {
this.temporarySelectionStates.delete(key)
}
}
}
if (!id) { if (!id) {
for (let key of this.temporarySelectionStates.keys()) { for (let key of this.temporarySelectionStates.keys()) {
if (key) { if (key) {
@ -119,19 +147,36 @@ export class FilterableDropdownSelectionModel {
exclude(id: number, fireEvent: boolean = true) { exclude(id: number, fireEvent: boolean = true) {
let state = this.temporarySelectionStates.get(id) let state = this.temporarySelectionStates.get(id)
if (state == null || state != ToggleableItemState.Excluded) { if (id && (state == null || state != ToggleableItemState.Excluded)) {
this.temporarySelectionStates.set(id, ToggleableItemState.Excluded) this.temporaryLogicalOperator = this._logicalOperator = this.manyToOne
this.temporaryLogicalOperator = this._logicalOperator = 'and' ? LogicalOperator.And
} else if (state == ToggleableItemState.Excluded) { : LogicalOperator.Or
this.temporarySelectionStates.delete(id)
}
if (!this.multiple) { if (this.manyToOne || this.singleSelect) {
for (let key of this.temporarySelectionStates.keys()) { this.temporarySelectionStates.set(id, ToggleableItemState.Excluded)
if (key != id) {
this.temporarySelectionStates.delete(key) if (this.singleSelect) {
for (let key of this.temporarySelectionStates.keys()) {
if (key != id) {
this.temporarySelectionStates.delete(key)
}
}
} }
} else {
let newState =
this.intersection == Intersection.Include
? ToggleableItemState.Selected
: ToggleableItemState.Excluded
if (
state == ToggleableItemState.Selected &&
this.intersection == Intersection.Include
) {
newState = ToggleableItemState.NotSelected
}
this.temporarySelectionStates.set(id, newState)
} }
} else if (!id || state == ToggleableItemState.Excluded) {
this.temporarySelectionStates.delete(id)
} }
if (fireEvent) { if (fireEvent) {
@ -143,11 +188,11 @@ export class FilterableDropdownSelectionModel {
return this.selectionStates.get(id) || ToggleableItemState.NotSelected return this.selectionStates.get(id) || ToggleableItemState.NotSelected
} }
get logicalOperator(): string { get logicalOperator(): LogicalOperator {
return this.temporaryLogicalOperator return this.temporaryLogicalOperator
} }
set logicalOperator(operator: string) { set logicalOperator(operator: LogicalOperator) {
this.temporaryLogicalOperator = operator this.temporaryLogicalOperator = operator
} }
@ -155,6 +200,26 @@ export class FilterableDropdownSelectionModel {
this.changed.next(this) this.changed.next(this)
} }
get intersection(): Intersection {
return this.temporaryIntersection
}
set intersection(intersection: Intersection) {
this.temporaryIntersection = intersection
}
toggleIntersection() {
if (this.temporarySelectionStates.size === 0) return
let newState =
this.intersection == Intersection.Include
? ToggleableItemState.Selected
: ToggleableItemState.Excluded
this.temporarySelectionStates.forEach((state, key) => {
this.temporarySelectionStates.set(key, newState)
})
this.changed.next(this)
}
get(id: number) { get(id: number) {
return ( return (
this.temporarySelectionStates.get(id) || ToggleableItemState.NotSelected this.temporarySelectionStates.get(id) || ToggleableItemState.NotSelected
@ -171,7 +236,8 @@ export class FilterableDropdownSelectionModel {
clear(fireEvent = true) { clear(fireEvent = true) {
this.temporarySelectionStates.clear() this.temporarySelectionStates.clear()
this.temporaryLogicalOperator = this._logicalOperator = 'and' this.temporaryLogicalOperator = this._logicalOperator = LogicalOperator.And
this.temporaryIntersection = this._intersection = Intersection.Include
if (fireEvent) { if (fireEvent) {
this.changed.next(this) this.changed.next(this)
} }
@ -194,6 +260,8 @@ export class FilterableDropdownSelectionModel {
return true return true
} else if (this.temporaryLogicalOperator !== this._logicalOperator) { } else if (this.temporaryLogicalOperator !== this._logicalOperator) {
return true return true
} else if (this.temporaryIntersection !== this._intersection) {
return true
} else { } else {
return false return false
} }
@ -217,13 +285,18 @@ export class FilterableDropdownSelectionModel {
this.selectionStates.set(key, value) this.selectionStates.set(key, value)
}) })
this._logicalOperator = this.temporaryLogicalOperator this._logicalOperator = this.temporaryLogicalOperator
this._intersection = this.temporaryIntersection
} }
reset() { reset(complete: boolean = false) {
this.temporarySelectionStates.clear() this.temporarySelectionStates.clear()
this.selectionStates.forEach((value, key) => { if (complete) {
this.temporarySelectionStates.set(key, value) this.selectionStates.clear()
}) } else {
this.selectionStates.forEach((value, key) => {
this.temporarySelectionStates.set(key, value)
})
}
} }
diff(): ChangedItems { diff(): ChangedItems {
@ -269,14 +342,16 @@ export class FilterableDropdownComponent {
return this._selectionModel.items return this._selectionModel.items
} }
_selectionModel = new FilterableDropdownSelectionModel() _selectionModel: FilterableDropdownSelectionModel =
new FilterableDropdownSelectionModel()
@Input() @Input()
set selectionModel(model: FilterableDropdownSelectionModel) { set selectionModel(model: FilterableDropdownSelectionModel) {
if (this.selectionModel) { if (this.selectionModel) {
this.selectionModel.changed.complete() this.selectionModel.changed.complete()
model.items = this.selectionModel.items model.items = this.selectionModel.items
model.multiple = this.selectionModel.multiple model.manyToOne = this.selectionModel.manyToOne
model.singleSelect = this.editing && !this.selectionModel.manyToOne
} }
model.changed.subscribe((updatedModel) => { model.changed.subscribe((updatedModel) => {
this.selectionModelChange.next(updatedModel) this.selectionModelChange.next(updatedModel)
@ -292,12 +367,12 @@ export class FilterableDropdownComponent {
selectionModelChange = new EventEmitter<FilterableDropdownSelectionModel>() selectionModelChange = new EventEmitter<FilterableDropdownSelectionModel>()
@Input() @Input()
set multiple(value: boolean) { set manyToOne(manyToOne: boolean) {
this.selectionModel.multiple = value this.selectionModel.manyToOne = manyToOne
} }
get multiple() { get manyToOne() {
return this.selectionModel.multiple return this.selectionModel.manyToOne
} }
@Input() @Input()
@ -327,16 +402,20 @@ export class FilterableDropdownComponent {
@Output() @Output()
opened = new EventEmitter() opened = new EventEmitter()
get operatorToggleEnabled(): boolean { get modifierToggleEnabled(): boolean {
return ( return this.manyToOne
this.selectionModel.selectionSize() > 1 && ? this.selectionModel.selectionSize() > 1 &&
this.selectionModel.getExcludedItems().length == 0 this.selectionModel.getExcludedItems().length == 0
) : !this.selectionModel.isNoneSelected()
} }
@Input() @Input()
documentCounts: SelectionDataItem[] documentCounts: SelectionDataItem[]
get name(): string {
return this.title ? this.title.replace(/\s/g, '_').toLowerCase() : null
}
getUpdatedDocumentCount(id: number) { getUpdatedDocumentCount(id: number) {
if (this.documentCounts) { if (this.documentCounts) {
return this.documentCounts.find((c) => c.id === id)?.document_count return this.documentCounts.find((c) => c.id === id)?.document_count
@ -346,7 +425,6 @@ export class FilterableDropdownComponent {
modelIsDirty: boolean = false modelIsDirty: boolean = false
constructor(private filterPipe: FilterPipe) { constructor(private filterPipe: FilterPipe) {
this.selectionModel = new FilterableDropdownSelectionModel()
this.selectionModelChange.subscribe((updatedModel) => { this.selectionModelChange.subscribe((updatedModel) => {
this.modelIsDirty = updatedModel.isDirty() this.modelIsDirty = updatedModel.isDirty()
}) })
@ -400,7 +478,7 @@ export class FilterableDropdownComponent {
} }
reset() { reset() {
this.selectionModel.reset() this.selectionModel.reset(true)
this.selectionModelChange.emit(this.selectionModel) this.selectionModelChange.emit(this.selectionModel)
} }
} }

View File

@ -5,6 +5,7 @@
[disabled]="disabled" [disabled]="disabled"
[style.color]="textColor" [style.color]="textColor"
[style.background]="backgroundColor" [style.background]="backgroundColor"
[class.private]="isPrivate"
[clearable]="allowNull" [clearable]="allowNull"
[items]="items" [items]="items"
[addTag]="allowCreateNew && addItemRef" [addTag]="allowCreateNew && addItemRef"

View File

@ -12,3 +12,8 @@
} }
} }
} }
::ng-deep .private .ng-value-container {
font-style: italic;
opacity: .75;
}

View File

@ -26,8 +26,23 @@ export class SelectComponent extends AbstractInputComponent<number> {
this.addItemRef = this.addItem.bind(this) this.addItemRef = this.addItem.bind(this)
} }
_items: any[]
@Input() @Input()
items: any[] set items(items) {
if (this.value && items.find((i) => i.id === this.value) === undefined) {
items.push({
id: this.value,
name: $localize`Private`,
private: true,
})
}
this._items = items
}
get items(): any[] {
return this._items
}
@Input() @Input()
textColor: any textColor: any
@ -61,6 +76,10 @@ export class SelectComponent extends AbstractInputComponent<number> {
return this.createNew.observers.length > 0 return this.createNew.observers.length > 0
} }
get isPrivate(): boolean {
return this.items?.find((i) => i.id === this.value)?.private
}
getSuggestions() { getSuggestions() {
if (this.suggestions && this.items) { if (this.suggestions && this.items) {
return this.suggestions return this.suggestions

View File

@ -0,0 +1,9 @@
.h2 {
min-height: calc(1.325rem + 0.9vw);
}
@media (min-width: 1200px) {
.h2 {
min-height: 2.8rem;
}
}

View File

@ -59,7 +59,7 @@ export class PermissionsSelectComponent
this.updateDisabledStates() this.updateDisabledStates()
} }
inheritedWarning: string = $localize`Inerhited from group` inheritedWarning: string = $localize`Inherited from group`
constructor(private readonly permissionsService: PermissionsService) { constructor(private readonly permissionsService: PermissionsService) {
for (const type in PermissionType) { for (const type in PermissionType) {

View File

@ -1,2 +1,9 @@
<span *ngIf="!clickable" class="badge" [style.background]="tag.color" [style.color]="tag.text_color">{{tag.name}}</span> <ng-container *ngIf="tag !== undefined; else privateTag" >
<a [title]="linkTitle" *ngIf="clickable" class="badge" [style.background]="tag.color" [style.color]="tag.text_color">{{tag.name}}</a> <span *ngIf="!clickable" class="badge" [style.background]="tag.color" [style.color]="tag.text_color">{{tag.name}}</span>
<a [title]="linkTitle" *ngIf="clickable" class="badge" [style.background]="tag.color" [style.color]="tag.text_color">{{tag.name}}</a>
</ng-container>
<ng-template #privateTag>
<span *ngIf="!clickable" class="badge private" i18n>Private</span>
<a [title]="linkTitle" *ngIf="clickable" class="badge private" i18n>Private</a>
</ng-template>

View File

@ -4,3 +4,10 @@ a {
word-break: break-word; word-break: break-word;
text-align: end; text-align: end;
} }
.private {
background-color: #000000;
color: #ffffff;
opacity: .5;
font-style: italic;
}

View File

@ -1,6 +1,46 @@
<app-widget-frame title="Statistics" [loading]="loading" i18n-title> <app-widget-frame title="Statistics" [loading]="loading" i18n-title>
<ng-container content> <ng-container content>
<p class="card-text" i18n *ngIf="statistics?.documents_inbox !== null">Documents in inbox: {{statistics?.documents_inbox}}</p> <div class="list-group border-light">
<p class="card-text" i18n>Total documents: {{statistics?.documents_total}}</p> <a class="list-group-item list-group-item-action d-flex justify-content-between align-items-center" title="Go to inbox" i18n-title href="javascript:void()" *ngIf="statistics?.documents_inbox !== null" (click)="goToInbox()">
<ng-container i18n>Documents in inbox</ng-container>:
<span class="badge rounded-pill" [class.bg-primary]="statistics?.documents_inbox > 0" [class.bg-muted]="statistics?.documents_inbox === 0">{{statistics?.documents_inbox}}</span>
</a>
<a class="list-group-item list-group-item-action d-flex justify-content-between align-items-center" title="Go to documents" i18n-title routerLink="/documents/">
<ng-container i18n>Total documents</ng-container>:
<span class="badge bg-primary rounded-pill">{{statistics?.documents_total}}</span>
</a>
<div class="list-group-item d-flex justify-content-between align-items-center" routerLink="/documents/">
<ng-container i18n>Total characters</ng-container>:
<span class="badge bg-secondary text-light rounded-pill">{{statistics?.character_count | number}}</span>
</div>
<div *ngIf="statistics?.document_file_type_counts?.length > 1" class="list-group-item filetypes">
<div class="d-flex justify-content-between align-items-center my-2">
<div class="progress flex-grow-1">
<div *ngFor="let filetype of statistics?.document_file_type_counts; let i = index; let last = last"
class="progress-bar bg-primary text-primary-contrast"
role="progressbar"
[ngbPopover]="getFileTypeName(filetype)"
i18n-ngbPopover
triggers="mouseenter:mouseleave"
[attr.aria-label]="getFileTypeName(filetype)"
[class.me-1px]="!last"
[style.width]="getFileTypePercent(filetype) + '%'"
[style.opacity]="getItemOpacity(i)"
[attr.aria-valuenow]="getFileTypePercent(filetype)"
aria-valuemin="0"
aria-valuemax="100">
</div>
</div>
</div>
<div class="d-flex flex-wrap align-items-start">
<div class="d-flex" *ngFor="let filetype of statistics?.document_file_type_counts; let i = index">
<div class="text-nowrap me-2">
<span class="badge rounded-pill bg-primary d-inline-block p-0 me-1" [style.opacity]="getItemOpacity(i)"></span>
<small class="text-nowrap"><span class="fw-bold">{{ getFileTypeExtension(filetype) }}</span>&nbsp;<span class="text-muted">({{getFileTypePercent(filetype) | number: '1.0-1'}}%)</span></small>
</div>
</div>
</div>
</div>
</div>
</ng-container> </ng-container>
</app-widget-frame> </app-widget-frame>

View File

@ -0,0 +1,10 @@
.filetypes {
.progress {
height: 0.6rem;
}
.badge {
height: 0.6rem;
width: 0.6rem;
}
}

View File

@ -1,12 +1,23 @@
import { HttpClient } from '@angular/common/http' import { HttpClient } from '@angular/common/http'
import { Component, OnDestroy, OnInit } from '@angular/core' import { Component, OnDestroy, OnInit } from '@angular/core'
import { Observable, Subscription } from 'rxjs' import { Observable, Subscription } from 'rxjs'
import { FILTER_HAS_TAGS_ALL } from 'src/app/data/filter-rule-type'
import { ConsumerStatusService } from 'src/app/services/consumer-status.service' import { ConsumerStatusService } from 'src/app/services/consumer-status.service'
import { DocumentListViewService } from 'src/app/services/document-list-view.service'
import { environment } from 'src/environments/environment' import { environment } from 'src/environments/environment'
import * as mimeTypeNames from 'mime-names'
export interface Statistics { export interface Statistics {
documents_total?: number documents_total?: number
documents_inbox?: number documents_inbox?: number
inbox_tag?: number
document_file_type_counts?: DocumentFileType[]
character_count?: number
}
interface DocumentFileType {
mime_type: string
mime_type_count: number
} }
@Component({ @Component({
@ -19,7 +30,8 @@ export class StatisticsWidgetComponent implements OnInit, OnDestroy {
constructor( constructor(
private http: HttpClient, private http: HttpClient,
private consumerStatusService: ConsumerStatusService private consumerStatusService: ConsumerStatusService,
private documentListViewService: DocumentListViewService
) {} ) {}
statistics: Statistics = {} statistics: Statistics = {}
@ -34,10 +46,43 @@ export class StatisticsWidgetComponent implements OnInit, OnDestroy {
this.loading = true this.loading = true
this.getStatistics().subscribe((statistics) => { this.getStatistics().subscribe((statistics) => {
this.loading = false this.loading = false
const fileTypeMax = 5
if (statistics.document_file_type_counts?.length > fileTypeMax) {
const others = statistics.document_file_type_counts.slice(fileTypeMax)
statistics.document_file_type_counts =
statistics.document_file_type_counts.slice(0, fileTypeMax)
statistics.document_file_type_counts.push({
mime_type: $localize`Other`,
mime_type_count: others.reduce(
(currentValue, documentFileType) =>
documentFileType.mime_type_count + currentValue,
0
),
})
}
this.statistics = statistics this.statistics = statistics
}) })
} }
getFileTypeExtension(filetype: DocumentFileType): string {
return (
mimeTypeNames[filetype.mime_type]?.extensions[0]?.toUpperCase() ??
filetype.mime_type
)
}
getFileTypeName(filetype: DocumentFileType): string {
return mimeTypeNames[filetype.mime_type]?.name ?? filetype.mime_type
}
getFileTypePercent(filetype: DocumentFileType): number {
return (filetype.mime_type_count / this.statistics?.documents_total) * 100
}
getItemOpacity(i: number): number {
return 1 - i / this.statistics?.document_file_type_counts.length
}
ngOnInit(): void { ngOnInit(): void {
this.reload() this.reload()
this.subscription = this.consumerStatusService this.subscription = this.consumerStatusService
@ -50,4 +95,13 @@ export class StatisticsWidgetComponent implements OnInit, OnDestroy {
ngOnDestroy(): void { ngOnDestroy(): void {
this.subscription.unsubscribe() this.subscription.unsubscribe()
} }
goToInbox() {
this.documentListViewService.quickFilter([
{
rule_type: FILTER_HAS_TAGS_ALL,
value: this.statistics.inbox_tag.toString(),
},
])
}
} }

View File

@ -1,104 +0,0 @@
import { Component, Input } from '@angular/core'
import { DocumentCommentsService } from 'src/app/services/rest/document-comments.service'
import { PaperlessDocumentComment } from 'src/app/data/paperless-document-comment'
import { FormControl, FormGroup } from '@angular/forms'
import { first } from 'rxjs/operators'
import { ToastService } from 'src/app/services/toast.service'
import { ComponentWithPermissions } from '../with-permissions/with-permissions.component'
@Component({
selector: 'app-document-comments',
templateUrl: './document-comments.component.html',
styleUrls: ['./document-comments.component.scss'],
})
export class DocumentCommentsComponent extends ComponentWithPermissions {
commentForm: FormGroup = new FormGroup({
newComment: new FormControl(''),
})
networkActive = false
comments: PaperlessDocumentComment[] = []
newCommentError: boolean = false
private _documentId: number
@Input()
set documentId(id: number) {
if (id != this._documentId) {
this._documentId = id
this.update()
}
}
constructor(
private commentsService: DocumentCommentsService,
private toastService: ToastService
) {
super()
}
update(): void {
this.networkActive = true
this.commentsService
.getComments(this._documentId)
.pipe(first())
.subscribe((comments) => {
this.comments = comments
this.networkActive = false
})
}
addComment() {
const comment: string = this.commentForm
.get('newComment')
.value.toString()
.trim()
if (comment.length == 0) {
this.newCommentError = true
return
}
this.newCommentError = false
this.networkActive = true
this.commentsService.addComment(this._documentId, comment).subscribe({
next: (result) => {
this.comments = result
this.commentForm.get('newComment').reset()
this.networkActive = false
},
error: (e) => {
this.networkActive = false
this.toastService.showError(
$localize`Error saving comment: ${e.toString()}`
)
},
})
}
deleteComment(commentId: number) {
this.commentsService.deleteComment(this._documentId, commentId).subscribe({
next: (result) => {
this.comments = result
this.networkActive = false
},
error: (e) => {
this.networkActive = false
this.toastService.showError(
$localize`Error deleting comment: ${e.toString()}`
)
},
})
}
displayName(comment: PaperlessDocumentComment): string {
if (!comment.user) return ''
let nameComponents = []
if (comment.user.first_name) nameComponents.unshift(comment.user.first_name)
if (comment.user.last_name) nameComponents.unshift(comment.user.last_name)
if (comment.user.username) {
if (nameComponents.length > 0)
nameComponents.push(`(${comment.user.username})`)
else nameComponents.push(comment.user.username)
}
return nameComponents.join(' ')
}
}

View File

@ -67,8 +67,8 @@
<form [formGroup]='documentForm' (ngSubmit)="save()"> <form [formGroup]='documentForm' (ngSubmit)="save()">
<ul ngbNav #nav="ngbNav" class="nav-tabs"> <ul ngbNav #nav="ngbNav" class="nav-tabs" (navChange)="onNavChange($event)" [(activeId)]="activeNavID">
<li [ngbNavItem]="1"> <li [ngbNavItem]="DocumentDetailNavIDs.Details">
<a ngbNavLink i18n>Details</a> <a ngbNavLink i18n>Details</a>
<ng-template ngbNavContent> <ng-template ngbNavContent>
@ -87,7 +87,7 @@
</ng-template> </ng-template>
</li> </li>
<li [ngbNavItem]="2"> <li [ngbNavItem]="DocumentDetailNavIDs.Content">
<a ngbNavLink i18n>Content</a> <a ngbNavLink i18n>Content</a>
<ng-template ngbNavContent> <ng-template ngbNavContent>
<div class="mb-3"> <div class="mb-3">
@ -96,7 +96,7 @@
</ng-template> </ng-template>
</li> </li>
<li [ngbNavItem]="3"> <li [ngbNavItem]="DocumentDetailNavIDs.Metadata">
<a ngbNavLink i18n>Metadata</a> <a ngbNavLink i18n>Metadata</a>
<ng-template ngbNavContent> <ng-template ngbNavContent>
@ -147,7 +147,7 @@
</ng-template> </ng-template>
</li> </li>
<li [ngbNavItem]="4" class="d-md-none"> <li [ngbNavItem]="DocumentDetailNavIDs.Preview" class="d-md-none">
<a ngbNavLink i18n>Preview</a> <a ngbNavLink i18n>Preview</a>
<ng-template ngbNavContent *ngIf="!pdfPreview.offsetParent"> <ng-template ngbNavContent *ngIf="!pdfPreview.offsetParent">
<div class="position-relative"> <div class="position-relative">
@ -171,14 +171,14 @@
</ng-template> </ng-template>
</li> </li>
<li [ngbNavItem]="5" *ngIf="commentsEnabled"> <li [ngbNavItem]="DocumentDetailNavIDs.Notes" *ngIf="notesEnabled">
<a ngbNavLink i18n>Comments</a> <a ngbNavLink i18n>Notes <span *ngIf="document?.notes.length" class="badge text-bg-secondary ms-1">{{document.notes.length}}</span></a>
<ng-template ngbNavContent> <ng-template ngbNavContent>
<app-document-comments [documentId]="documentId"></app-document-comments> <app-document-notes [documentId]="documentId" [notes]="document?.notes" (updated)="notesUpdated($event)"></app-document-notes>
</ng-template> </ng-template>
</li> </li>
<li [ngbNavItem]="6" *appIfOwner="document"> <li [ngbNavItem]="DocumentDetailNavIDs.Permissions" *appIfOwner="document">
<a ngbNavLink i18n>Permissions</a> <a ngbNavLink i18n>Permissions</a>
<ng-template ngbNavContent> <ng-template ngbNavContent>
<div class="mb-3"> <div class="mb-3">

View File

@ -1,7 +1,7 @@
import { Component, OnInit, OnDestroy, ViewChild } from '@angular/core' import { Component, OnInit, OnDestroy, ViewChild } from '@angular/core'
import { FormControl, FormGroup } from '@angular/forms' import { FormControl, FormGroup } from '@angular/forms'
import { ActivatedRoute, Router } from '@angular/router' import { ActivatedRoute, Router } from '@angular/router'
import { NgbModal, NgbNav } from '@ng-bootstrap/ng-bootstrap' import { NgbModal, NgbNav, NgbNavChangeEvent } from '@ng-bootstrap/ng-bootstrap'
import { PaperlessCorrespondent } from 'src/app/data/paperless-correspondent' import { PaperlessCorrespondent } from 'src/app/data/paperless-correspondent'
import { PaperlessDocument } from 'src/app/data/paperless-document' import { PaperlessDocument } from 'src/app/data/paperless-document'
import { PaperlessDocumentMetadata } from 'src/app/data/paperless-document-metadata' import { PaperlessDocumentMetadata } from 'src/app/data/paperless-document-metadata'
@ -42,6 +42,16 @@ import {
} from 'src/app/services/permissions.service' } from 'src/app/services/permissions.service'
import { PaperlessUser } from 'src/app/data/paperless-user' import { PaperlessUser } from 'src/app/data/paperless-user'
import { UserService } from 'src/app/services/rest/user.service' import { UserService } from 'src/app/services/rest/user.service'
import { PaperlessDocumentNote } from 'src/app/data/paperless-document-note'
enum DocumentDetailNavIDs {
Details = 1,
Content = 2,
Metadata = 3,
Preview = 4,
Notes = 5,
Permissions = 6,
}
@Component({ @Component({
selector: 'app-document-detail', selector: 'app-document-detail',
@ -117,6 +127,8 @@ export class DocumentDetailComponent
PermissionAction = PermissionAction PermissionAction = PermissionAction
PermissionType = PermissionType PermissionType = PermissionType
DocumentDetailNavIDs = DocumentDetailNavIDs
activeNavID: number
constructor( constructor(
private documentsService: DocumentService, private documentsService: DocumentService,
@ -282,6 +294,18 @@ export class DocumentDetailComponent
this.router.navigate(['404']) this.router.navigate(['404'])
}, },
}) })
this.route.paramMap.subscribe((paramMap) => {
const section = paramMap.get('section')
if (section) {
const navIDKey: string = Object.keys(DocumentDetailNavIDs).find(
(navID) => navID.toLowerCase() == section
)
if (navIDKey) {
this.activeNavID = DocumentDetailNavIDs[navIDKey]
}
}
})
} }
ngOnDestroy(): void { ngOnDestroy(): void {
@ -289,6 +313,18 @@ export class DocumentDetailComponent
this.unsubscribeNotifier.complete() this.unsubscribeNotifier.complete()
} }
onNavChange(navChangeEvent: NgbNavChangeEvent) {
const [foundNavIDkey] = Object.entries(DocumentDetailNavIDs).find(
([, navIDValue]) => navIDValue == navChangeEvent.nextId
)
if (foundNavIDkey)
this.router.navigate([
'documents',
this.documentId,
foundNavIDkey.toLowerCase(),
])
}
updateComponent(doc: PaperlessDocument) { updateComponent(doc: PaperlessDocument) {
this.document = doc this.document = doc
this.requiresPassword = false this.requiresPassword = false
@ -622,9 +658,9 @@ export class DocumentDetailComponent
} }
} }
get commentsEnabled(): boolean { get notesEnabled(): boolean {
return ( return (
this.settings.get(SETTINGS_KEYS.COMMENTS_ENABLED) && this.settings.get(SETTINGS_KEYS.NOTES_ENABLED) &&
this.permissionsService.currentUserCan( this.permissionsService.currentUserCan(
PermissionAction.View, PermissionAction.View,
PermissionType.Document PermissionType.Document
@ -632,6 +668,11 @@ export class DocumentDetailComponent
) )
} }
notesUpdated(notes: PaperlessDocumentNote[]) {
this.document.notes = notes
this.openDocumentService.refreshDocument(this.documentId)
}
get userIsOwner(): boolean { get userIsOwner(): boolean {
let doc: PaperlessDocument = Object.assign({}, this.document) let doc: PaperlessDocument = Object.assign({}, this.document)
// dont disable while editing // dont disable while editing

View File

@ -1,6 +1,6 @@
<div class="row"> <div class="row">
<div class="col-auto mb-2 mb-xl-0" role="group" aria-label="Select"> <div class="col-auto mb-2 mb-xl-0" role="group" aria-label="Select">
<button class="btn btn-sm btn-outline-danger" (click)="list.selectNone()"> <button class="btn btn-sm btn-outline-secondary" (click)="list.selectNone()">
<svg width="1em" height="1em" viewBox="0 0 16 16" fill="currentColor"> <svg width="1em" height="1em" viewBox="0 0 16 16" fill="currentColor">
<use xlink:href="assets/bootstrap-icons.svg#slash-circle" /> <use xlink:href="assets/bootstrap-icons.svg#slash-circle" />
</svg>&nbsp;<ng-container i18n>Cancel</ng-container> </svg>&nbsp;<ng-container i18n>Cancel</ng-container>
@ -30,7 +30,7 @@
[items]="tags" [items]="tags"
[disabled]="!userCanEditAll" [disabled]="!userCanEditAll"
[editing]="true" [editing]="true"
[multiple]="true" [manyToOne]="true"
[applyOnClose]="applyOnClose" [applyOnClose]="applyOnClose"
(opened)="openTagsDropdown()" (opened)="openTagsDropdown()"
[(selectionModel)]="tagSelectionModel" [(selectionModel)]="tagSelectionModel"

View File

@ -26,7 +26,7 @@
</div> </div>
<p class="card-text"> <p class="card-text">
<span *ngIf="document.__search_hit__ && document.__search_hit__.highlights" [innerHtml]="document.__search_hit__.highlights"></span> <span *ngIf="document.__search_hit__ && document.__search_hit__.highlights" [innerHtml]="document.__search_hit__.highlights"></span>
<span *ngFor="let highlight of searchCommentHighlights" class="d-block"> <span *ngFor="let highlight of searchNoteHighlights" class="d-block">
<svg width="1em" height="1em" fill="currentColor" class="me-2"> <svg width="1em" height="1em" fill="currentColor" class="me-2">
<use xlink:href="assets/bootstrap-icons.svg#chat-left-text"/> <use xlink:href="assets/bootstrap-icons.svg#chat-left-text"/>
</svg> </svg>
@ -65,24 +65,31 @@
</a> </a>
</div> </div>
<div class="list-group list-group-horizontal border-0 card-info ms-md-auto mt-2 mt-md-0"> <div class="list-group list-group-horizontal border-0 card-info ms-md-auto mt-2 mt-md-0">
<button *ngIf="notesEnabled && document.notes.length" routerLink="/documents/{{document.id}}/notes" class="list-group-item btn btn-sm bg-light text-dark p-1 border-0 me-2" title="View notes" i18n-title>
<svg class="metadata-icon me-2 text-muted" fill="currentColor">
<use xlink:href="assets/bootstrap-icons.svg#chat-left-text"/>
</svg>
<small i18n>{{document.notes.length}} Notes</small>
</button>
<button *ngIf="document.document_type" type="button" class="list-group-item btn btn-sm bg-light text-dark p-1 border-0 me-2" title="Filter by document type" i18n-title <button *ngIf="document.document_type" type="button" class="list-group-item btn btn-sm bg-light text-dark p-1 border-0 me-2" title="Filter by document type" i18n-title
(click)="clickDocumentType.emit(document.document_type);$event.stopPropagation()"> (click)="clickDocumentType.emit(document.document_type);$event.stopPropagation()">
<svg class="metadata-icon me-2 text-muted bi bi-file-earmark" viewBox="0 0 16 16" fill="currentColor"> <svg class="metadata-icon me-2 text-muted" fill="currentColor">
<path d="M14 4.5V14a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V2a2 2 0 0 1 2-2h5.5L14 4.5zm-3 0A1.5 1.5 0 0 1 9.5 3V1H4a1 1 0 0 0-1 1v12a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1V4.5h-2z"/> <use xlink:href="assets/bootstrap-icons.svg#file-earmark"/>
</svg> </svg>
<small>{{(document.document_type$ | async)?.name}}</small> <small>{{(document.document_type$ | async)?.name}}</small>
</button> </button>
<button *ngIf="document.storage_path" type="button" class="list-group-item btn btn-sm bg-light text-dark p-1 border-0 me-2" title="Filter by storage path" i18n-title <button *ngIf="document.storage_path" type="button" class="list-group-item btn btn-sm bg-light text-dark p-1 border-0 me-2" title="Filter by storage path" i18n-title
(click)="clickStoragePath.emit(document.storage_path);$event.stopPropagation()"> (click)="clickStoragePath.emit(document.storage_path);$event.stopPropagation()">
<svg class="metadata-icon me-2 text-muted bi bi-folder" viewBox="0 0 16 16" fill="currentColor"> <svg class="metadata-icon me-2 text-muted" fill="currentColor">
<path d="M0 2a1 1 0 0 1 1-1h14a1 1 0 0 1 1 1v2a1 1 0 0 1-1 1v7.5a2.5 2.5 0 0 1-2.5 2.5h-9A2.5 2.5 0 0 1 1 12.5V5a1 1 0 0 1-1-1V2zm2 3v7.5A1.5 1.5 0 0 0 3.5 14h9a1.5 1.5 0 0 0 1.5-1.5V5H2zm13-3H1v2h14V2zM5 7.5a.5.5 0 0 1 .5-.5h5a.5.5 0 0 1 0 1h-5a.5.5 0 0 1-.5-.5z"/> <use xlink:href="assets/bootstrap-icons.svg#archive"/>
</svg> </svg>
<small>{{(document.storage_path$ | async)?.name}}</small> <small>{{(document.storage_path$ | async)?.name}}</small>
</button> </button>
<div *ngIf="document.archive_serial_number" class="list-group-item me-2 bg-light text-dark p-1 border-0"> <div *ngIf="document.archive_serial_number" class="list-group-item me-2 bg-light text-dark p-1 border-0">
<svg class="metadata-icon me-2 text-muted bi bi-upc-scan" viewBox="0 0 16 16" fill="currentColor"> <svg class="metadata-icon me-2 text-muted" fill="currentColor">
<path d="M1.5 1a.5.5 0 0 0-.5.5v3a.5.5 0 0 1-1 0v-3A1.5 1.5 0 0 1 1.5 0h3a.5.5 0 0 1 0 1h-3zM11 .5a.5.5 0 0 1 .5-.5h3A1.5 1.5 0 0 1 16 1.5v3a.5.5 0 0 1-1 0v-3a.5.5 0 0 0-.5-.5h-3a.5.5 0 0 1-.5-.5zM.5 11a.5.5 0 0 1 .5.5v3a.5.5 0 0 0 .5.5h3a.5.5 0 0 1 0 1h-3A1.5 1.5 0 0 1 0 14.5v-3a.5.5 0 0 1 .5-.5zm15 0a.5.5 0 0 1 .5.5v3a1.5 1.5 0 0 1-1.5 1.5h-3a.5.5 0 0 1 0-1h3a.5.5 0 0 0 .5-.5v-3a.5.5 0 0 1 .5-.5zM3 4.5a.5.5 0 0 1 1 0v7a.5.5 0 0 1-1 0v-7zm2 0a.5.5 0 0 1 1 0v7a.5.5 0 0 1-1 0v-7zm2 0a.5.5 0 0 1 1 0v7a.5.5 0 0 1-1 0v-7zm2 0a.5.5 0 0 1 .5-.5h1a.5.5 0 0 1 .5.5v7a.5.5 0 0 1-.5.5h-1a.5.5 0 0 1-.5-.5v-7zm3 0a.5.5 0 0 1 1 0v7a.5.5 0 0 1-1 0v-7z"/> <use xlink:href="assets/bootstrap-icons.svg#upc-scan"/>
</svg> </svg>
<small>#{{document.archive_serial_number}}</small> <small>#{{document.archive_serial_number}}</small>
</div> </div>
@ -94,9 +101,8 @@
</div> </div>
</ng-template> </ng-template>
<div class="list-group-item bg-light text-dark p-1 border-0" [ngbTooltip]="dateTooltip"> <div class="list-group-item bg-light text-dark p-1 border-0" [ngbTooltip]="dateTooltip">
<svg class="metadata-icon me-2 text-muted bi bi-calendar-event" viewBox="0 0 16 16" fill="currentColor"> <svg class="metadata-icon me-2 text-muted" fill="currentColor">
<path d="M11 6.5a.5.5 0 0 1 .5-.5h1a.5.5 0 0 1 .5.5v1a.5.5 0 0 1-.5.5h-1a.5.5 0 0 1-.5-.5v-1z"/> <use xlink:href="assets/bootstrap-icons.svg#calendar-event"/>
<path d="M3.5 0a.5.5 0 0 1 .5.5V1h8V.5a.5.5 0 0 1 1 0V1h1a2 2 0 0 1 2 2v11a2 2 0 0 1-2 2H2a2 2 0 0 1-2-2V3a2 2 0 0 1 2-2h1V.5a.5.5 0 0 1 .5-.5zM1 4v10a1 1 0 0 0 1 1h12a1 1 0 0 0 1-1V4H1z"/>
</svg> </svg>
<small>{{document.created_date | customDate:'mediumDate'}}</small> <small>{{document.created_date | customDate:'mediumDate'}}</small>
</div> </div>

View File

@ -73,12 +73,6 @@
} }
} }
.metadata-icon {
width: 0.9rem;
height: 0.9rem;
padding: 0.05rem;
}
.search-score { .search-score {
padding-top: 0.35rem !important; padding-top: 0.35rem !important;
} }

View File

@ -73,16 +73,14 @@ export class DocumentCardLargeComponent extends ComponentWithPermissions {
} }
} }
get searchCommentHighlights() { get searchNoteHighlights() {
let highlights = [] let highlights = []
if ( if (
this.document['__search_hit__'] && this.document['__search_hit__'] &&
this.document['__search_hit__'].comment_highlights this.document['__search_hit__'].note_highlights
) { ) {
// only show comments with a match // only show notes with a match
highlights = ( highlights = (this.document['__search_hit__'].note_highlights as string)
this.document['__search_hit__'].comment_highlights as string
)
.split(',') .split(',')
.filter((higlight) => higlight.includes('<span')) .filter((higlight) => higlight.includes('<span'))
} }
@ -136,4 +134,8 @@ export class DocumentCardLargeComponent extends ComponentWithPermissions {
(this.document.content.length > 500 ? '...' : '') (this.document.content.length > 500 ? '...' : '')
) )
} }
get notesEnabled(): boolean {
return this.settingsService.get(SETTINGS_KEYS.NOTES_ENABLED)
}
} }

View File

@ -13,12 +13,20 @@
<div class="tags d-flex flex-column text-end position-absolute me-1 fs-6"> <div class="tags d-flex flex-column text-end position-absolute me-1 fs-6">
<app-tag *ngFor="let t of getTagsLimited$() | async" [tag]="t" (click)="clickTag.emit(t.id);$event.stopPropagation()" [clickable]="true" linkTitle="Toggle tag filter" i18n-linkTitle></app-tag> <app-tag *ngFor="let t of getTagsLimited$() | async" [tag]="t" (click)="clickTag.emit(t.id);$event.stopPropagation()" [clickable]="true" linkTitle="Toggle tag filter" i18n-linkTitle></app-tag>
<div *ngIf="moreTags"> <div *ngIf="moreTags">
<span class="badge badge-secondary">+ {{moreTags}}</span> <span class="badge text-dark">+ {{moreTags}}</span>
</div> </div>
</div> </div>
</div> </div>
<div class="card-body p-2"> <a *ngIf="notesEnabled && document.notes.length" routerLink="/documents/{{document.id}}/notes" class="document-card-notes py-2 px-1">
<span class="badge rounded-pill bg-light border text-primary">
<svg class="metadata-icon ms-1 me-1" fill="currentColor">
<use xlink:href="assets/bootstrap-icons.svg#chat-left-text"/>
</svg>
{{document.notes.length}}</span>
</a>
<div class="card-body bg-light p-2">
<p class="card-text"> <p class="card-text">
<ng-container *ngIf="document.correspondent"> <ng-container *ngIf="document.correspondent">
<a title="Toggle correspondent filter" i18n-title (click)="clickCorrespondent.emit(document.correspondent);$event.stopPropagation()" class="fw-bold btn-link">{{(document.correspondent$ | async)?.name}}</a>: <a title="Toggle correspondent filter" i18n-title (click)="clickCorrespondent.emit(document.correspondent);$event.stopPropagation()" class="fw-bold btn-link">{{(document.correspondent$ | async)?.name}}</a>:

View File

@ -5,7 +5,7 @@
.doc-img { .doc-img {
object-fit: cover; object-fit: cover;
object-position: top left; object-position: top left;
height: 175px; height: 180px;
mix-blend-mode: multiply; mix-blend-mode: multiply;
} }
@ -34,6 +34,12 @@
display: block; display: block;
} }
.document-card-notes {
position: absolute;
right: 0;
top: 142px;
}
.card-selected { .card-selected {
border-color:var(--bs-primary); border-color:var(--bs-primary);
@ -58,12 +64,6 @@
color: var(--bs-primary); color: var(--bs-primary);
} }
} }
.metadata-icon {
width: 0.9rem;
height: 0.9rem;
padding: 0.05rem;
}
} }
.card-footer .btn { .card-footer .btn {

View File

@ -74,11 +74,12 @@ export class DocumentCardSmallComponent extends ComponentWithPermissions {
} }
getTagsLimited$() { getTagsLimited$() {
const limit = this.document.notes.length > 0 ? 6 : 7
return this.document.tags$.pipe( return this.document.tags$.pipe(
map((tags) => { map((tags) => {
if (tags.length > 7) { if (tags.length > limit) {
this.moreTags = tags.length - 6 this.moreTags = tags.length - (limit - 1)
return tags.slice(0, 6) return tags.slice(0, limit - 1)
} else { } else {
return tags return tags
} }
@ -110,4 +111,8 @@ export class DocumentCardSmallComponent extends ComponentWithPermissions {
mouseLeaveCard() { mouseLeaveCard() {
this.popover.close() this.popover.close()
} }
get notesEnabled(): boolean {
return this.settingsService.get(SETTINGS_KEYS.NOTES_ENABLED)
}
} }

View File

@ -14,19 +14,19 @@
</div> </div>
</div> </div>
<div class="btn-group flex-fill" role="group"> <div class="btn-group flex-fill" role="group">
<input type="radio" class="btn-check" [(ngModel)]="displayMode" value="details" (ngModelChange)="saveDisplayMode()" id="displayModeDetails"> <input type="radio" class="btn-check" [(ngModel)]="displayMode" value="details" (ngModelChange)="saveDisplayMode()" id="displayModeDetails" name="displayModeDetails">
<label for="displayModeDetails" class="btn btn-outline-primary btn-sm"> <label for="displayModeDetails" class="btn btn-outline-primary btn-sm">
<svg class="toolbaricon" fill="currentColor"> <svg class="toolbaricon" fill="currentColor">
<use xlink:href="assets/bootstrap-icons.svg#list-ul" /> <use xlink:href="assets/bootstrap-icons.svg#list-ul" />
</svg> </svg>
</label> </label>
<input type="radio" class="btn-check" [(ngModel)]="displayMode" value="smallCards" (ngModelChange)="saveDisplayMode()" id="displayModeSmall"> <input type="radio" class="btn-check" [(ngModel)]="displayMode" value="smallCards" (ngModelChange)="saveDisplayMode()" id="displayModeSmall" name="displayModeSmall">
<label for="displayModeSmall" class="btn btn-outline-primary btn-sm"> <label for="displayModeSmall" class="btn btn-outline-primary btn-sm">
<svg class="toolbaricon" fill="currentColor"> <svg class="toolbaricon" fill="currentColor">
<use xlink:href="assets/bootstrap-icons.svg#grid" /> <use xlink:href="assets/bootstrap-icons.svg#grid" />
</svg> </svg>
</label> </label>
<input type="radio" class="btn-check" [(ngModel)]="displayMode" value="largeCards" (ngModelChange)="saveDisplayMode()" id="displayModeLarge"> <input type="radio" class="btn-check" [(ngModel)]="displayMode" value="largeCards" (ngModelChange)="saveDisplayMode()" id="displayModeLarge" name="displayModeLarge">
<label for="displayModeLarge" class="btn btn-outline-primary btn-sm"> <label for="displayModeLarge" class="btn btn-outline-primary btn-sm">
<svg class="toolbaricon" fill="currentColor"> <svg class="toolbaricon" fill="currentColor">
<use xlink:href="assets/bootstrap-icons.svg#hdd-stack" /> <use xlink:href="assets/bootstrap-icons.svg#hdd-stack" />
@ -123,42 +123,56 @@
<th></th> <th></th>
<th class="d-none d-lg-table-cell" <th class="d-none d-lg-table-cell"
appSortable="archive_serial_number" appSortable="archive_serial_number"
title="Sort by ASN" i18n-title
[currentSortField]="list.sortField" [currentSortField]="list.sortField"
[currentSortReverse]="list.sortReverse" [currentSortReverse]="list.sortReverse"
(sort)="onSort($event)" (sort)="onSort($event)"
i18n>ASN</th> i18n>ASN</th>
<th class="d-none d-md-table-cell" <th class="d-none d-md-table-cell"
appSortable="correspondent__name" appSortable="correspondent__name"
title="Sort by correspondent" i18n-title
[currentSortField]="list.sortField" [currentSortField]="list.sortField"
[currentSortReverse]="list.sortReverse" [currentSortReverse]="list.sortReverse"
(sort)="onSort($event)" (sort)="onSort($event)"
i18n>Correspondent</th> i18n>Correspondent</th>
<th <th
appSortable="title" appSortable="title"
title="Sort by title" i18n-title
[currentSortField]="list.sortField" [currentSortField]="list.sortField"
[currentSortReverse]="list.sortReverse" [currentSortReverse]="list.sortReverse"
(sort)="onSort($event)" (sort)="onSort($event)"
i18n>Title</th> i18n>Title</th>
<th *ngIf="notesEnabled" class="d-none d-xl-table-cell"
appSortable="num_notes"
title="Sort by notes" i18n-title
[currentSortField]="list.sortField"
[currentSortReverse]="list.sortReverse"
(sort)="onSort($event)"
i18n>Notes</th>
<th class="d-none d-xl-table-cell" <th class="d-none d-xl-table-cell"
appSortable="document_type__name" appSortable="document_type__name"
title="Sort by document type" i18n-title
[currentSortField]="list.sortField" [currentSortField]="list.sortField"
[currentSortReverse]="list.sortReverse" [currentSortReverse]="list.sortReverse"
(sort)="onSort($event)" (sort)="onSort($event)"
i18n>Document type</th> i18n>Document type</th>
<th class="d-none d-xl-table-cell" <th class="d-none d-xl-table-cell"
appSortable="storage_path__name" appSortable="storage_path__name"
title="Sort by storage path" i18n-title
[currentSortField]="list.sortField" [currentSortField]="list.sortField"
[currentSortReverse]="list.sortReverse" [currentSortReverse]="list.sortReverse"
(sort)="onSort($event)" (sort)="onSort($event)"
i18n>Storage path</th> i18n>Storage path</th>
<th <th
appSortable="created" appSortable="created"
title="Sort by created date" i18n-title
[currentSortField]="list.sortField" [currentSortField]="list.sortField"
[currentSortReverse]="list.sortReverse" [currentSortReverse]="list.sortReverse"
(sort)="onSort($event)" (sort)="onSort($event)"
i18n>Created</th> i18n>Created</th>
<th class="d-none d-xl-table-cell" <th class="d-none d-xl-table-cell"
appSortable="added" appSortable="added"
title="Sort by added date" i18n-title
[currentSortField]="list.sortField" [currentSortField]="list.sortField"
[currentSortReverse]="list.sortReverse" [currentSortReverse]="list.sortReverse"
(sort)="onSort($event)" (sort)="onSort($event)"
@ -184,6 +198,15 @@
<a routerLink="/documents/{{d.id}}" title="Edit document" i18n-title style="overflow-wrap: anywhere;">{{d.title | documentTitle}}</a> <a routerLink="/documents/{{d.id}}" title="Edit document" i18n-title style="overflow-wrap: anywhere;">{{d.title | documentTitle}}</a>
<app-tag [tag]="t" *ngFor="let t of d.tags$ | async" class="ms-1" clickable="true" linkTitle="Filter by tag" i18n-linkTitle (click)="clickTag(t.id);$event.stopPropagation()"></app-tag> <app-tag [tag]="t" *ngFor="let t of d.tags$ | async" class="ms-1" clickable="true" linkTitle="Filter by tag" i18n-linkTitle (click)="clickTag(t.id);$event.stopPropagation()"></app-tag>
</td> </td>
<td *ngIf="notesEnabled" class="d-none d-xl-table-cell">
<a *ngIf="d.notes.length" routerLink="/documents/{{d.id}}/notes" class="btn btn-sm p-0">
<span class="badge rounded-pill bg-light border text-primary">
<svg class="metadata-icon ms-1 me-1" fill="currentColor">
<use xlink:href="assets/bootstrap-icons.svg#chat-left-text"/>
</svg>
{{d.notes.length}}</span>
</a>
</td>
<td class="d-none d-xl-table-cell"> <td class="d-none d-xl-table-cell">
<ng-container *ngIf="d.document_type"> <ng-container *ngIf="d.document_type">
<a (click)="clickDocumentType(d.document_type);$event.stopPropagation()" title="Filter by document type" i18n-title>{{(d.document_type$ | async)?.name}}</a> <a (click)="clickDocumentType(d.document_type);$event.stopPropagation()" title="Filter by document type" i18n-title>{{(d.document_type$ | async)?.name}}</a>

View File

@ -6,6 +6,10 @@ tr {
user-select: none; user-select: none;
} }
th {
cursor: pointer;
}
.table-row-selected { .table-row-selected {
background-color: var(--pngx-primary-faded); background-color: var(--pngx-primary-faded);
} }

View File

@ -17,6 +17,7 @@ import {
import { FILTER_FULLTEXT_MORELIKE } from 'src/app/data/filter-rule-type' import { FILTER_FULLTEXT_MORELIKE } from 'src/app/data/filter-rule-type'
import { PaperlessDocument } from 'src/app/data/paperless-document' import { PaperlessDocument } from 'src/app/data/paperless-document'
import { PaperlessSavedView } from 'src/app/data/paperless-saved-view' import { PaperlessSavedView } from 'src/app/data/paperless-saved-view'
import { SETTINGS_KEYS } from 'src/app/data/paperless-uisettings'
import { import {
SortableDirective, SortableDirective,
SortEvent, SortEvent,
@ -29,6 +30,7 @@ import {
DOCUMENT_SORT_FIELDS_FULLTEXT, DOCUMENT_SORT_FIELDS_FULLTEXT,
} from 'src/app/services/rest/document.service' } from 'src/app/services/rest/document.service'
import { SavedViewService } from 'src/app/services/rest/saved-view.service' import { SavedViewService } from 'src/app/services/rest/saved-view.service'
import { SettingsService } from 'src/app/services/settings.service'
import { ToastService } from 'src/app/services/toast.service' import { ToastService } from 'src/app/services/toast.service'
import { ComponentWithPermissions } from '../with-permissions/with-permissions.component' import { ComponentWithPermissions } from '../with-permissions/with-permissions.component'
import { FilterEditorComponent } from './filter-editor/filter-editor.component' import { FilterEditorComponent } from './filter-editor/filter-editor.component'
@ -51,7 +53,8 @@ export class DocumentListComponent
private toastService: ToastService, private toastService: ToastService,
private modalService: NgbModal, private modalService: NgbModal,
private consumerStatusService: ConsumerStatusService, private consumerStatusService: ConsumerStatusService,
public openDocumentsService: OpenDocumentsService public openDocumentsService: OpenDocumentsService,
private settingsService: SettingsService
) { ) {
super() super()
} }
@ -289,4 +292,8 @@ export class DocumentListComponent
trackByDocumentId(index, item: PaperlessDocument) { trackByDocumentId(index, item: PaperlessDocument) {
return item.id return item.id
} }
get notesEnabled(): boolean {
return this.settingsService.get(SETTINGS_KEYS.NOTES_ENABLED)
}
} }

View File

@ -27,7 +27,7 @@
<app-filterable-dropdown class="flex-fill" title="Tags" icon="tag-fill" i18n-title <app-filterable-dropdown class="flex-fill" title="Tags" icon="tag-fill" i18n-title
filterPlaceholder="Filter tags" i18n-filterPlaceholder filterPlaceholder="Filter tags" i18n-filterPlaceholder
[items]="tags" [items]="tags"
[multiple]="true" [manyToOne]="true"
[(selectionModel)]="tagSelectionModel" [(selectionModel)]="tagSelectionModel"
(selectionModelChange)="updateRules()" (selectionModelChange)="updateRules()"
(opened)="onTagsDropdownOpen()" (opened)="onTagsDropdownOpen()"

View File

@ -21,10 +21,10 @@ import {
FILTER_ADDED_AFTER, FILTER_ADDED_AFTER,
FILTER_ADDED_BEFORE, FILTER_ADDED_BEFORE,
FILTER_ASN, FILTER_ASN,
FILTER_CORRESPONDENT, FILTER_HAS_CORRESPONDENT_ANY,
FILTER_CREATED_AFTER, FILTER_CREATED_AFTER,
FILTER_CREATED_BEFORE, FILTER_CREATED_BEFORE,
FILTER_DOCUMENT_TYPE, FILTER_HAS_DOCUMENT_TYPE_ANY,
FILTER_FULLTEXT_MORELIKE, FILTER_FULLTEXT_MORELIKE,
FILTER_FULLTEXT_QUERY, FILTER_FULLTEXT_QUERY,
FILTER_HAS_ANY_TAG, FILTER_HAS_ANY_TAG,
@ -33,12 +33,22 @@ import {
FILTER_DOES_NOT_HAVE_TAG, FILTER_DOES_NOT_HAVE_TAG,
FILTER_TITLE, FILTER_TITLE,
FILTER_TITLE_CONTENT, FILTER_TITLE_CONTENT,
FILTER_STORAGE_PATH, FILTER_HAS_STORAGE_PATH_ANY,
FILTER_ASN_ISNULL, FILTER_ASN_ISNULL,
FILTER_ASN_GT, FILTER_ASN_GT,
FILTER_ASN_LT, FILTER_ASN_LT,
FILTER_DOES_NOT_HAVE_CORRESPONDENT,
FILTER_DOES_NOT_HAVE_DOCUMENT_TYPE,
FILTER_DOES_NOT_HAVE_STORAGE_PATH,
FILTER_DOCUMENT_TYPE,
FILTER_CORRESPONDENT,
FILTER_STORAGE_PATH,
} from 'src/app/data/filter-rule-type' } from 'src/app/data/filter-rule-type'
import { FilterableDropdownSelectionModel } from '../../common/filterable-dropdown/filterable-dropdown.component' import {
FilterableDropdownSelectionModel,
Intersection,
LogicalOperator,
} from '../../common/filterable-dropdown/filterable-dropdown.component'
import { ToggleableItemState } from '../../common/filterable-dropdown/toggleable-dropdown-button/toggleable-dropdown-button.component' import { ToggleableItemState } from '../../common/filterable-dropdown/toggleable-dropdown-button/toggleable-dropdown-button.component'
import { import {
DocumentService, DocumentService,
@ -93,7 +103,7 @@ export class FilterEditorComponent implements OnInit, OnDestroy {
if (this.filterRules.length == 1) { if (this.filterRules.length == 1) {
let rule = this.filterRules[0] let rule = this.filterRules[0]
switch (this.filterRules[0].rule_type) { switch (this.filterRules[0].rule_type) {
case FILTER_CORRESPONDENT: case FILTER_HAS_CORRESPONDENT_ANY:
if (rule.value) { if (rule.value) {
return $localize`Correspondent: ${ return $localize`Correspondent: ${
this.correspondents.find((c) => c.id == +rule.value)?.name this.correspondents.find((c) => c.id == +rule.value)?.name
@ -102,7 +112,7 @@ export class FilterEditorComponent implements OnInit, OnDestroy {
return $localize`Without correspondent` return $localize`Without correspondent`
} }
case FILTER_DOCUMENT_TYPE: case FILTER_HAS_DOCUMENT_TYPE_ANY:
if (rule.value) { if (rule.value) {
return $localize`Type: ${ return $localize`Type: ${
this.documentTypes.find((dt) => dt.id == +rule.value)?.name this.documentTypes.find((dt) => dt.id == +rule.value)?.name
@ -335,6 +345,7 @@ export class FilterEditorComponent implements OnInit, OnDestroy {
this.dateAddedBefore = rule.value this.dateAddedBefore = rule.value
break break
case FILTER_HAS_TAGS_ALL: case FILTER_HAS_TAGS_ALL:
this.tagSelectionModel.logicalOperator = LogicalOperator.And
this.tagSelectionModel.set( this.tagSelectionModel.set(
rule.value ? +rule.value : null, rule.value ? +rule.value : null,
ToggleableItemState.Selected, ToggleableItemState.Selected,
@ -342,7 +353,7 @@ export class FilterEditorComponent implements OnInit, OnDestroy {
) )
break break
case FILTER_HAS_TAGS_ANY: case FILTER_HAS_TAGS_ANY:
this.tagSelectionModel.logicalOperator = 'or' this.tagSelectionModel.logicalOperator = LogicalOperator.Or
this.tagSelectionModel.set( this.tagSelectionModel.set(
rule.value ? +rule.value : null, rule.value ? +rule.value : null,
ToggleableItemState.Selected, ToggleableItemState.Selected,
@ -360,26 +371,59 @@ export class FilterEditorComponent implements OnInit, OnDestroy {
) )
break break
case FILTER_CORRESPONDENT: case FILTER_CORRESPONDENT:
case FILTER_HAS_CORRESPONDENT_ANY:
this.correspondentSelectionModel.logicalOperator = LogicalOperator.Or
this.correspondentSelectionModel.intersection = Intersection.Include
this.correspondentSelectionModel.set( this.correspondentSelectionModel.set(
rule.value ? +rule.value : null, rule.value ? +rule.value : null,
ToggleableItemState.Selected, ToggleableItemState.Selected,
false false
) )
break break
case FILTER_DOES_NOT_HAVE_CORRESPONDENT:
this.correspondentSelectionModel.intersection = Intersection.Exclude
this.correspondentSelectionModel.set(
rule.value ? +rule.value : null,
ToggleableItemState.Excluded,
false
)
break
case FILTER_DOCUMENT_TYPE: case FILTER_DOCUMENT_TYPE:
case FILTER_HAS_DOCUMENT_TYPE_ANY:
this.documentTypeSelectionModel.logicalOperator = LogicalOperator.Or
this.documentTypeSelectionModel.intersection = Intersection.Include
this.documentTypeSelectionModel.set( this.documentTypeSelectionModel.set(
rule.value ? +rule.value : null, rule.value ? +rule.value : null,
ToggleableItemState.Selected, ToggleableItemState.Selected,
false false
) )
break break
case FILTER_DOES_NOT_HAVE_DOCUMENT_TYPE:
this.documentTypeSelectionModel.intersection = Intersection.Exclude
this.documentTypeSelectionModel.set(
rule.value ? +rule.value : null,
ToggleableItemState.Excluded,
false
)
break
case FILTER_STORAGE_PATH: case FILTER_STORAGE_PATH:
case FILTER_HAS_STORAGE_PATH_ANY:
this.storagePathSelectionModel.logicalOperator = LogicalOperator.Or
this.storagePathSelectionModel.intersection = Intersection.Include
this.storagePathSelectionModel.set( this.storagePathSelectionModel.set(
rule.value ? +rule.value : null, rule.value ? +rule.value : null,
ToggleableItemState.Selected, ToggleableItemState.Selected,
false false
) )
break break
case FILTER_DOES_NOT_HAVE_STORAGE_PATH:
this.storagePathSelectionModel.intersection = Intersection.Exclude
this.storagePathSelectionModel.set(
rule.value ? +rule.value : null,
ToggleableItemState.Excluded,
false
)
break
case FILTER_ASN_ISNULL: case FILTER_ASN_ISNULL:
this.textFilterTarget = TEXT_FILTER_TARGET_ASN this.textFilterTarget = TEXT_FILTER_TARGET_ASN
this.textFilterModifier = this.textFilterModifier =
@ -469,7 +513,7 @@ export class FilterEditorComponent implements OnInit, OnDestroy {
filterRules.push({ rule_type: FILTER_HAS_ANY_TAG, value: 'false' }) filterRules.push({ rule_type: FILTER_HAS_ANY_TAG, value: 'false' })
} else { } else {
const tagFilterType = const tagFilterType =
this.tagSelectionModel.logicalOperator == 'and' this.tagSelectionModel.logicalOperator == LogicalOperator.And
? FILTER_HAS_TAGS_ALL ? FILTER_HAS_TAGS_ALL
: FILTER_HAS_TAGS_ANY : FILTER_HAS_TAGS_ANY
this.tagSelectionModel this.tagSelectionModel
@ -491,28 +535,66 @@ export class FilterEditorComponent implements OnInit, OnDestroy {
}) })
}) })
} }
this.correspondentSelectionModel if (this.correspondentSelectionModel.isNoneSelected()) {
.getSelectedItems() filterRules.push({ rule_type: FILTER_CORRESPONDENT, value: null })
.forEach((correspondent) => { } else {
filterRules.push({ this.correspondentSelectionModel
rule_type: FILTER_CORRESPONDENT, .getSelectedItems()
value: correspondent.id?.toString(), .forEach((correspondent) => {
filterRules.push({
rule_type: FILTER_HAS_CORRESPONDENT_ANY,
value: correspondent.id?.toString(),
})
}) })
}) this.correspondentSelectionModel
this.documentTypeSelectionModel .getExcludedItems()
.getSelectedItems() .forEach((correspondent) => {
.forEach((documentType) => { filterRules.push({
filterRules.push({ rule_type: FILTER_DOES_NOT_HAVE_CORRESPONDENT,
rule_type: FILTER_DOCUMENT_TYPE, value: correspondent.id?.toString(),
value: documentType.id?.toString(), })
}) })
}) }
this.storagePathSelectionModel.getSelectedItems().forEach((storagePath) => { if (this.documentTypeSelectionModel.isNoneSelected()) {
filterRules.push({ filterRules.push({ rule_type: FILTER_DOCUMENT_TYPE, value: null })
rule_type: FILTER_STORAGE_PATH, } else {
value: storagePath.id?.toString(), this.documentTypeSelectionModel
}) .getSelectedItems()
}) .forEach((documentType) => {
filterRules.push({
rule_type: FILTER_HAS_DOCUMENT_TYPE_ANY,
value: documentType.id?.toString(),
})
})
this.documentTypeSelectionModel
.getExcludedItems()
.forEach((documentType) => {
filterRules.push({
rule_type: FILTER_DOES_NOT_HAVE_DOCUMENT_TYPE,
value: documentType.id?.toString(),
})
})
}
if (this.storagePathSelectionModel.isNoneSelected()) {
filterRules.push({ rule_type: FILTER_STORAGE_PATH, value: null })
} else {
this.storagePathSelectionModel
.getSelectedItems()
.forEach((storagePath) => {
filterRules.push({
rule_type: FILTER_HAS_STORAGE_PATH_ANY,
value: storagePath.id?.toString(),
})
})
this.storagePathSelectionModel
.getExcludedItems()
.forEach((storagePath) => {
filterRules.push({
rule_type: FILTER_DOES_NOT_HAVE_STORAGE_PATH,
value: storagePath.id?.toString(),
})
})
}
if (this.dateCreatedBefore) { if (this.dateCreatedBefore) {
filterRules.push({ filterRules.push({
rule_type: FILTER_CREATED_BEFORE, rule_type: FILTER_CREATED_BEFORE,

View File

@ -1,27 +1,28 @@
<div *ngIf="comments"> <div *ngIf="notes">
<form [formGroup]="commentForm" class="needs-validation mt-3" *appIfPermissions="{ action: PermissionAction.Add, type: PermissionType.Comment }" novalidate> <form [formGroup]="noteForm" class="needs-validation mt-3" *appIfPermissions="{ action: PermissionAction.Add, type: PermissionType.Note }" novalidate>
<div class="form-group"> <div class="form-group">
<textarea class="form-control form-control-sm" [class.is-invalid]="newCommentError" rows="3" formControlName="newComment" placeholder="Enter comment" i18n-placeholder required></textarea> <textarea class="form-control form-control-sm" [class.is-invalid]="newNoteError" rows="3" formControlName="newNote" placeholder="Enter note" i18n-placeholder (keydown)="noteFormKeydown($event)" required></textarea>
<div class="invalid-feedback" i18n> <div class="invalid-feedback" i18n>
Please enter a comment. Please enter a note.
</div> </div>
</div> </div>
<div class="form-group mt-2 d-flex justify-content-end align-items-center"> <div class="form-group mt-2 d-flex justify-content-end align-items-center">
<div *ngIf="networkActive" class="spinner-border spinner-border-sm fw-normal me-auto" role="status"></div> <div *ngIf="networkActive" class="spinner-border spinner-border-sm fw-normal me-auto" role="status"></div>
<button type="button" class="btn btn-primary btn-sm" [disabled]="networkActive" (click)="addComment()" i18n>Add comment</button> <button type="button" class="btn btn-primary btn-sm" [disabled]="networkActive" (click)="addNote()" i18n>Add note</button>
</div> </div>
</form> </form>
<hr> <hr>
<div *ngFor="let comment of comments" class="card border mb-3"> <div *ngFor="let note of notes" class="card border mb-3">
<div class="card-body text-dark"> <div class="card-body text-dark">
<p class="card-text">{{comment.comment}}</p> <p class="card-text">{{note.note}}</p>
</div> </div>
<div class="d-flex card-footer small bg-light text-primary justify-content-between align-items-center"> <div class="d-flex card-footer small bg-light text-primary justify-content-between align-items-center">
<span>{{displayName(comment)}} - {{ comment.created | customDate}}</span> <span>{{displayName(note)}} - {{ note.created | customDate}}</span>
<button type="button" class="btn btn-link btn-sm p-0 fade" (click)="deleteComment(comment.id)" *appIfPermissions="{ action: PermissionAction.Delete, type: PermissionType.Comment }"> <button type="button" class="btn btn-link btn-sm p-0 fade" title="Delete note" i18n-title (click)="deleteNote(note.id)" *appIfPermissions="{ action: PermissionAction.Delete, type: PermissionType.Note }">
<svg width="13" height="13" fill="currentColor"> <svg width="13" height="13" fill="currentColor">
<use xlink:href="assets/bootstrap-icons.svg#trash" /> <use xlink:href="assets/bootstrap-icons.svg#trash" />
</svg> </svg>
<span class="visually-hidden" i18n>Delete note</span>
</button> </button>
</div> </div>
</div> </div>

View File

@ -0,0 +1,106 @@
import { Component, Input, Output, EventEmitter } from '@angular/core'
import { DocumentNotesService } from 'src/app/services/rest/document-notes.service'
import { PaperlessDocumentNote } from 'src/app/data/paperless-document-note'
import { FormControl, FormGroup } from '@angular/forms'
import { first } from 'rxjs/operators'
import { ToastService } from 'src/app/services/toast.service'
import { ComponentWithPermissions } from '../with-permissions/with-permissions.component'
import { UserService } from 'src/app/services/rest/user.service'
import { PaperlessUser } from 'src/app/data/paperless-user'
@Component({
selector: 'app-document-notes',
templateUrl: './document-notes.component.html',
styleUrls: ['./document-notes.component.scss'],
})
export class DocumentNotesComponent extends ComponentWithPermissions {
noteForm: FormGroup = new FormGroup({
newNote: new FormControl(''),
})
networkActive = false
newNoteError: boolean = false
@Input()
documentId: number
@Input()
notes: PaperlessDocumentNote[] = []
@Output()
updated: EventEmitter<PaperlessDocumentNote[]> = new EventEmitter()
users: PaperlessUser[]
constructor(
private notesService: DocumentNotesService,
private toastService: ToastService,
private usersService: UserService
) {
super()
this.usersService.listAll().subscribe({
next: (users) => {
this.users = users.results
},
})
}
addNote() {
const note: string = this.noteForm.get('newNote').value.toString().trim()
if (note.length == 0) {
this.newNoteError = true
return
}
this.newNoteError = false
this.networkActive = true
this.notesService.addNote(this.documentId, note).subscribe({
next: (result) => {
this.notes = result
this.noteForm.get('newNote').reset()
this.networkActive = false
this.updated.emit(this.notes)
},
error: (e) => {
this.networkActive = false
this.toastService.showError(
$localize`Error saving note: ${e.toString()}`
)
},
})
}
deleteNote(noteId: number) {
this.notesService.deleteNote(this.documentId, noteId).subscribe({
next: (result) => {
this.notes = result
this.networkActive = false
this.updated.emit(this.notes)
},
error: (e) => {
this.networkActive = false
this.toastService.showError(
$localize`Error deleting note: ${e.toString()}`
)
},
})
}
displayName(note: PaperlessDocumentNote): string {
if (!note.user) return ''
const user = this.users.find((u) => u.id === note.user)
if (!user) return ''
const nameComponents = []
if (user.first_name) nameComponents.unshift(user.first_name)
if (user.last_name) nameComponents.unshift(user.last_name)
if (user.username) {
if (nameComponents.length > 0) nameComponents.push(`(${user.username})`)
else nameComponents.push(user.username)
}
return nameComponents.join(' ')
}
noteFormKeydown(event: KeyboardEvent) {
if ((event.metaKey || event.ctrlKey) && event.key === 'Enter') {
this.addNote()
}
}
}

View File

@ -1,6 +1,6 @@
import { Component } from '@angular/core' import { Component } from '@angular/core'
import { NgbModal } from '@ng-bootstrap/ng-bootstrap' import { NgbModal } from '@ng-bootstrap/ng-bootstrap'
import { FILTER_CORRESPONDENT } from 'src/app/data/filter-rule-type' import { FILTER_HAS_CORRESPONDENT_ANY } from 'src/app/data/filter-rule-type'
import { PaperlessCorrespondent } from 'src/app/data/paperless-correspondent' import { PaperlessCorrespondent } from 'src/app/data/paperless-correspondent'
import { CustomDatePipe } from 'src/app/pipes/custom-date.pipe' import { CustomDatePipe } from 'src/app/pipes/custom-date.pipe'
import { DocumentListViewService } from 'src/app/services/document-list-view.service' import { DocumentListViewService } from 'src/app/services/document-list-view.service'
@ -35,7 +35,7 @@ export class CorrespondentListComponent extends ManagementListComponent<Paperles
toastService, toastService,
documentListViewService, documentListViewService,
permissionsService, permissionsService,
FILTER_CORRESPONDENT, FILTER_HAS_CORRESPONDENT_ANY,
$localize`correspondent`, $localize`correspondent`,
$localize`correspondents`, $localize`correspondents`,
PermissionType.Correspondent, PermissionType.Correspondent,

View File

@ -1,6 +1,6 @@
import { Component } from '@angular/core' import { Component } from '@angular/core'
import { NgbModal } from '@ng-bootstrap/ng-bootstrap' import { NgbModal } from '@ng-bootstrap/ng-bootstrap'
import { FILTER_DOCUMENT_TYPE } from 'src/app/data/filter-rule-type' import { FILTER_HAS_DOCUMENT_TYPE_ANY } from 'src/app/data/filter-rule-type'
import { PaperlessDocumentType } from 'src/app/data/paperless-document-type' import { PaperlessDocumentType } from 'src/app/data/paperless-document-type'
import { DocumentListViewService } from 'src/app/services/document-list-view.service' import { DocumentListViewService } from 'src/app/services/document-list-view.service'
import { import {
@ -32,7 +32,7 @@ export class DocumentTypeListComponent extends ManagementListComponent<Paperless
toastService, toastService,
documentListViewService, documentListViewService,
permissionsService, permissionsService,
FILTER_DOCUMENT_TYPE, FILTER_HAS_DOCUMENT_TYPE_ANY,
$localize`document type`, $localize`document type`,
$localize`document types`, $localize`document types`,
PermissionType.DocumentType, PermissionType.DocumentType,

View File

@ -167,8 +167,13 @@ export abstract class ManagementListComponent<T extends ObjectWithId>
activeModal.componentInstance.succeeded.subscribe({ activeModal.componentInstance.succeeded.subscribe({
next: () => { next: () => {
if (activeModal.componentInstance.error) { if (activeModal.componentInstance.error) {
const errorDetail = activeModal.componentInstance.error.error
? activeModal.componentInstance.error.error[0]
: null
this.toastService.showInfo( this.toastService.showInfo(
$localize`Error occurred while saving ${this.typeName} : ${activeModal.componentInstance.error}.` $localize`Error occurred while saving ${this.typeName}${
errorDetail ? ': ' + errorDetail : ''
}.`
) )
} else { } else {
this.reloadData() this.reloadData()

View File

@ -156,11 +156,11 @@
</div> </div>
</div> </div>
<h4 class="mt-4" i18n>Comments</h4> <h4 class="mt-4" i18n>Notes</h4>
<div class="row mb-3"> <div class="row mb-3">
<div class="offset-md-3 col"> <div class="offset-md-3 col">
<app-input-check i18n-title title="Enable comments" formControlName="commentsEnabled"></app-input-check> <app-input-check i18n-title title="Enable notes" formControlName="notesEnabled"></app-input-check>
</div> </div>
</div> </div>

View File

@ -85,7 +85,7 @@ export class SettingsComponent
displayLanguage: new FormControl(null), displayLanguage: new FormControl(null),
dateLocale: new FormControl(null), dateLocale: new FormControl(null),
dateFormat: new FormControl(null), dateFormat: new FormControl(null),
commentsEnabled: new FormControl(null), notesEnabled: new FormControl(null),
updateCheckingEnabled: new FormControl(null), updateCheckingEnabled: new FormControl(null),
notificationsConsumerNewDocument: new FormControl(null), notificationsConsumerNewDocument: new FormControl(null),
@ -196,7 +196,7 @@ export class SettingsComponent
displayLanguage: this.settings.getLanguage(), displayLanguage: this.settings.getLanguage(),
dateLocale: this.settings.get(SETTINGS_KEYS.DATE_LOCALE), dateLocale: this.settings.get(SETTINGS_KEYS.DATE_LOCALE),
dateFormat: this.settings.get(SETTINGS_KEYS.DATE_FORMAT), dateFormat: this.settings.get(SETTINGS_KEYS.DATE_FORMAT),
commentsEnabled: this.settings.get(SETTINGS_KEYS.COMMENTS_ENABLED), notesEnabled: this.settings.get(SETTINGS_KEYS.NOTES_ENABLED),
updateCheckingEnabled: this.settings.get( updateCheckingEnabled: this.settings.get(
SETTINGS_KEYS.UPDATE_CHECKING_ENABLED SETTINGS_KEYS.UPDATE_CHECKING_ENABLED
), ),
@ -552,8 +552,8 @@ export class SettingsComponent
this.settingsForm.value.notificationsConsumerSuppressOnDashboard this.settingsForm.value.notificationsConsumerSuppressOnDashboard
) )
this.settings.set( this.settings.set(
SETTINGS_KEYS.COMMENTS_ENABLED, SETTINGS_KEYS.NOTES_ENABLED,
this.settingsForm.value.commentsEnabled this.settingsForm.value.notesEnabled
) )
this.settings.set( this.settings.set(
SETTINGS_KEYS.UPDATE_CHECKING_ENABLED, SETTINGS_KEYS.UPDATE_CHECKING_ENABLED,

View File

@ -1,6 +1,6 @@
import { Component } from '@angular/core' import { Component } from '@angular/core'
import { NgbModal } from '@ng-bootstrap/ng-bootstrap' import { NgbModal } from '@ng-bootstrap/ng-bootstrap'
import { FILTER_STORAGE_PATH } from 'src/app/data/filter-rule-type' import { FILTER_HAS_STORAGE_PATH_ANY } from 'src/app/data/filter-rule-type'
import { PaperlessStoragePath } from 'src/app/data/paperless-storage-path' import { PaperlessStoragePath } from 'src/app/data/paperless-storage-path'
import { DocumentListViewService } from 'src/app/services/document-list-view.service' import { DocumentListViewService } from 'src/app/services/document-list-view.service'
import { import {
@ -32,7 +32,7 @@ export class StoragePathListComponent extends ManagementListComponent<PaperlessS
toastService, toastService,
documentListViewService, documentListViewService,
permissionsService, permissionsService,
FILTER_STORAGE_PATH, FILTER_HAS_STORAGE_PATH_ANY,
$localize`storage path`, $localize`storage path`,
$localize`storage paths`, $localize`storage paths`,
PermissionType.StoragePath, PermissionType.StoragePath,

View File

@ -8,8 +8,12 @@ export const FILTER_ASN_GT = 23
export const FILTER_ASN_LT = 24 export const FILTER_ASN_LT = 24
export const FILTER_CORRESPONDENT = 3 export const FILTER_CORRESPONDENT = 3
export const FILTER_HAS_CORRESPONDENT_ANY = 26
export const FILTER_DOES_NOT_HAVE_CORRESPONDENT = 27
export const FILTER_DOCUMENT_TYPE = 4 export const FILTER_DOCUMENT_TYPE = 4
export const FILTER_HAS_DOCUMENT_TYPE_ANY = 28
export const FILTER_DOES_NOT_HAVE_DOCUMENT_TYPE = 29
export const FILTER_IS_IN_INBOX = 5 export const FILTER_IS_IN_INBOX = 5
export const FILTER_HAS_TAGS_ALL = 6 export const FILTER_HAS_TAGS_ALL = 6
@ -18,6 +22,8 @@ export const FILTER_DOES_NOT_HAVE_TAG = 17
export const FILTER_HAS_TAGS_ANY = 22 export const FILTER_HAS_TAGS_ANY = 22
export const FILTER_STORAGE_PATH = 25 export const FILTER_STORAGE_PATH = 25
export const FILTER_HAS_STORAGE_PATH_ANY = 30
export const FILTER_DOES_NOT_HAVE_STORAGE_PATH = 31
export const FILTER_CREATED_BEFORE = 8 export const FILTER_CREATED_BEFORE = 8
export const FILTER_CREATED_AFTER = 9 export const FILTER_CREATED_AFTER = 9
@ -63,6 +69,18 @@ export const FILTER_RULE_TYPES: FilterRuleType[] = [
datatype: 'correspondent', datatype: 'correspondent',
multi: false, multi: false,
}, },
{
id: FILTER_HAS_CORRESPONDENT_ANY,
filtervar: 'correspondent__id__in',
datatype: 'correspondent',
multi: true,
},
{
id: FILTER_DOES_NOT_HAVE_CORRESPONDENT,
filtervar: 'correspondent__id__none',
datatype: 'correspondent',
multi: true,
},
{ {
id: FILTER_STORAGE_PATH, id: FILTER_STORAGE_PATH,
filtervar: 'storage_path__id', filtervar: 'storage_path__id',
@ -70,6 +88,18 @@ export const FILTER_RULE_TYPES: FilterRuleType[] = [
datatype: 'storage_path', datatype: 'storage_path',
multi: false, multi: false,
}, },
{
id: FILTER_HAS_STORAGE_PATH_ANY,
filtervar: 'storage_path__id__in',
datatype: 'storage_path',
multi: true,
},
{
id: FILTER_DOES_NOT_HAVE_STORAGE_PATH,
filtervar: 'storage_path__id__none',
datatype: 'storage_path',
multi: true,
},
{ {
id: FILTER_DOCUMENT_TYPE, id: FILTER_DOCUMENT_TYPE,
filtervar: 'document_type__id', filtervar: 'document_type__id',
@ -77,6 +107,18 @@ export const FILTER_RULE_TYPES: FilterRuleType[] = [
datatype: 'document_type', datatype: 'document_type',
multi: false, multi: false,
}, },
{
id: FILTER_HAS_DOCUMENT_TYPE_ANY,
filtervar: 'document_type__id__in',
datatype: 'document_type',
multi: true,
},
{
id: FILTER_DOES_NOT_HAVE_DOCUMENT_TYPE,
filtervar: 'document_type__id__none',
datatype: 'document_type',
multi: true,
},
{ {
id: FILTER_IS_IN_INBOX, id: FILTER_IS_IN_INBOX,
filtervar: 'is_in_inbox', filtervar: 'is_in_inbox',

View File

@ -1,8 +0,0 @@
import { ObjectWithId } from './object-with-id'
import { PaperlessUser } from './paperless-user'
export interface PaperlessDocumentComment extends ObjectWithId {
created?: Date
comment?: string
user?: PaperlessUser
}

View File

@ -0,0 +1,7 @@
import { ObjectWithId } from './object-with-id'
export interface PaperlessDocumentNote extends ObjectWithId {
created?: Date
note?: string
user?: number // PaperlessUser
}

View File

@ -4,13 +4,14 @@ import { PaperlessDocumentType } from './paperless-document-type'
import { Observable } from 'rxjs' import { Observable } from 'rxjs'
import { PaperlessStoragePath } from './paperless-storage-path' import { PaperlessStoragePath } from './paperless-storage-path'
import { ObjectWithPermissions } from './object-with-permissions' import { ObjectWithPermissions } from './object-with-permissions'
import { PaperlessDocumentNote } from './paperless-document-note'
export interface SearchHit { export interface SearchHit {
score?: number score?: number
rank?: number rank?: number
highlights?: string highlights?: string
comment_highlights?: string note_highlights?: string
} }
export interface PaperlessDocument extends ObjectWithPermissions { export interface PaperlessDocument extends ObjectWithPermissions {
@ -54,5 +55,7 @@ export interface PaperlessDocument extends ObjectWithPermissions {
archive_serial_number?: number archive_serial_number?: number
notes?: PaperlessDocumentNote[]
__search_hit__?: SearchHit __search_hit__?: SearchHit
} }

View File

@ -20,4 +20,6 @@ export interface PaperlessMailAccount extends ObjectWithId {
password: string password: string
character_set?: string character_set?: string
is_token: boolean
} }

View File

@ -34,7 +34,7 @@ export const SETTINGS_KEYS = {
'general-settings:notifications:consumer-failed', 'general-settings:notifications:consumer-failed',
NOTIFICATIONS_CONSUMER_SUPPRESS_ON_DASHBOARD: NOTIFICATIONS_CONSUMER_SUPPRESS_ON_DASHBOARD:
'general-settings:notifications:consumer-suppress-on-dashboard', 'general-settings:notifications:consumer-suppress-on-dashboard',
COMMENTS_ENABLED: 'general-settings:comments-enabled', NOTES_ENABLED: 'general-settings:notes-enabled',
SLIM_SIDEBAR: 'general-settings:slim-sidebar', SLIM_SIDEBAR: 'general-settings:slim-sidebar',
UPDATE_CHECKING_ENABLED: 'general-settings:update-checking:enabled', UPDATE_CHECKING_ENABLED: 'general-settings:update-checking:enabled',
UPDATE_CHECKING_BACKEND_SETTING: UPDATE_CHECKING_BACKEND_SETTING:
@ -125,7 +125,7 @@ export const SETTINGS: PaperlessUiSetting[] = [
default: true, default: true,
}, },
{ {
key: SETTINGS_KEYS.COMMENTS_ENABLED, key: SETTINGS_KEYS.NOTES_ENABLED,
type: 'boolean', type: 'boolean',
default: true, default: true,
}, },

View File

@ -35,15 +35,16 @@ export class OpenDocumentsService {
refreshDocument(id: number) { refreshDocument(id: number) {
let index = this.openDocuments.findIndex((doc) => doc.id == id) let index = this.openDocuments.findIndex((doc) => doc.id == id)
if (index > -1) { if (index > -1) {
this.documentService.get(id).subscribe( this.documentService.get(id).subscribe({
(doc) => { next: (doc) => {
this.openDocuments[index] = doc this.openDocuments[index] = doc
this.save()
}, },
(error) => { error: () => {
this.openDocuments.splice(index, 1) this.openDocuments.splice(index, 1)
this.save() this.save()
} },
) })
} }
} }

View File

@ -18,7 +18,7 @@ export enum PermissionType {
SavedView = '%s_savedview', SavedView = '%s_savedview',
PaperlessTask = '%s_paperlesstask', PaperlessTask = '%s_paperlesstask',
UISettings = '%s_uisettings', UISettings = '%s_uisettings',
Comment = '%s_comment', Note = '%s_note',
MailAccount = '%s_mailaccount', MailAccount = '%s_mailaccount',
MailRule = '%s_mailrule', MailRule = '%s_mailrule',
User = '%s_user', User = '%s_user',

View File

@ -2,10 +2,8 @@ import { HttpClient, HttpParams } from '@angular/common/http'
import { Observable } from 'rxjs' import { Observable } from 'rxjs'
import { map, publishReplay, refCount } from 'rxjs/operators' import { map, publishReplay, refCount } from 'rxjs/operators'
import { ObjectWithId } from 'src/app/data/object-with-id' import { ObjectWithId } from 'src/app/data/object-with-id'
import { PaperlessUser } from 'src/app/data/paperless-user'
import { Results } from 'src/app/data/results' import { Results } from 'src/app/data/results'
import { environment } from 'src/environments/environment' import { environment } from 'src/environments/environment'
import { PermissionAction, PermissionType } from '../permissions.service'
export abstract class AbstractPaperlessService<T extends ObjectWithId> { export abstract class AbstractPaperlessService<T extends ObjectWithId> {
protected baseUrl: string = environment.apiBaseUrl protected baseUrl: string = environment.apiBaseUrl

View File

@ -1,37 +0,0 @@
import { Injectable } from '@angular/core'
import { HttpClient, HttpParams } from '@angular/common/http'
import { PaperlessDocumentComment } from 'src/app/data/paperless-document-comment'
import { AbstractPaperlessService } from './abstract-paperless-service'
import { Observable } from 'rxjs'
@Injectable({
providedIn: 'root',
})
export class DocumentCommentsService extends AbstractPaperlessService<PaperlessDocumentComment> {
constructor(http: HttpClient) {
super(http, 'documents')
}
getComments(documentId: number): Observable<PaperlessDocumentComment[]> {
return this.http.get<PaperlessDocumentComment[]>(
this.getResourceUrl(documentId, 'comments')
)
}
addComment(id: number, comment): Observable<PaperlessDocumentComment[]> {
return this.http.post<PaperlessDocumentComment[]>(
this.getResourceUrl(id, 'comments'),
{ comment: comment }
)
}
deleteComment(
documentId: number,
commentId: number
): Observable<PaperlessDocumentComment[]> {
return this.http.delete<PaperlessDocumentComment[]>(
this.getResourceUrl(documentId, 'comments'),
{ params: new HttpParams({ fromString: `id=${commentId}` }) }
)
}
}

View File

@ -0,0 +1,37 @@
import { Injectable } from '@angular/core'
import { HttpClient, HttpParams } from '@angular/common/http'
import { PaperlessDocumentNote } from 'src/app/data/paperless-document-note'
import { AbstractPaperlessService } from './abstract-paperless-service'
import { Observable } from 'rxjs'
@Injectable({
providedIn: 'root',
})
export class DocumentNotesService extends AbstractPaperlessService<PaperlessDocumentNote> {
constructor(http: HttpClient) {
super(http, 'documents')
}
getNotes(documentId: number): Observable<PaperlessDocumentNote[]> {
return this.http.get<PaperlessDocumentNote[]>(
this.getResourceUrl(documentId, 'notes')
)
}
addNote(id: number, note: string): Observable<PaperlessDocumentNote[]> {
return this.http.post<PaperlessDocumentNote[]>(
this.getResourceUrl(id, 'notes'),
{ note: note }
)
}
deleteNote(
documentId: number,
noteId: number
): Observable<PaperlessDocumentNote[]> {
return this.http.delete<PaperlessDocumentNote[]>(
this.getResourceUrl(documentId, 'notes'),
{ params: new HttpParams({ fromString: `id=${noteId}` }) }
)
}
}

View File

@ -22,6 +22,7 @@ export const DOCUMENT_SORT_FIELDS = [
{ field: 'created', name: $localize`Created` }, { field: 'created', name: $localize`Created` },
{ field: 'added', name: $localize`Added` }, { field: 'added', name: $localize`Added` },
{ field: 'modified', name: $localize`Modified` }, { field: 'modified', name: $localize`Modified` },
{ field: 'num_notes', name: $localize`Notes` },
] ]
export const DOCUMENT_SORT_FIELDS_FULLTEXT = [ export const DOCUMENT_SORT_FIELDS_FULLTEXT = [

View File

@ -48,4 +48,8 @@ export class MailAccountService extends AbstractPaperlessService<PaperlessMailAc
delete(o: PaperlessMailAccount) { delete(o: PaperlessMailAccount) {
return super.delete(o).pipe(tap(() => this.reload())) return super.delete(o).pipe(tap(() => this.reload()))
} }
test(o: PaperlessMailAccount) {
return this.http.post(this.getResourceUrl() + 'test/', o)
}
} }

View File

@ -86,12 +86,12 @@ export function queryParamsFromFilterRules(filterRules: FilterRule[]): Params {
let params = {} let params = {}
for (let rule of filterRules) { for (let rule of filterRules) {
let ruleType = FILTER_RULE_TYPES.find((t) => t.id == rule.rule_type) let ruleType = FILTER_RULE_TYPES.find((t) => t.id == rule.rule_type)
if (ruleType.multi) { if (ruleType.isnull_filtervar && rule.value == null) {
params[ruleType.isnull_filtervar] = 1
} else if (ruleType.multi) {
params[ruleType.filtervar] = params[ruleType.filtervar] params[ruleType.filtervar] = params[ruleType.filtervar]
? params[ruleType.filtervar] + ',' + rule.value ? params[ruleType.filtervar] + ',' + rule.value
: rule.value : rule.value
} else if (ruleType.isnull_filtervar && rule.value == null) {
params[ruleType.isnull_filtervar] = 1
} else { } else {
params[ruleType.filtervar] = rule.value params[ruleType.filtervar] = rule.value
if (ruleType.datatype == 'boolean') if (ruleType.datatype == 'boolean')

View File

@ -436,6 +436,12 @@ textarea,
height: 12px; height: 12px;
} }
.metadata-icon {
width: 0.9rem;
height: 0.9rem;
padding: 0.05rem;
}
table.table { table.table {
color: var(--bs-body-color); color: var(--bs-body-color);
@ -623,3 +629,7 @@ code {
.accordion-button::after { .accordion-button::after {
filter: invert(0.5) saturate(0); filter: invert(0.5) saturate(0);
} }
.me-1px {
margin-right: 1px !important;
}

View File

@ -16,6 +16,7 @@ body {
--pngx-primary-darken-15: hsl(var(--pngx-primary), calc(var(--pngx-primary-lightness) - 15%)); --pngx-primary-darken-15: hsl(var(--pngx-primary), calc(var(--pngx-primary-lightness) - 15%));
--pngx-primary-darken-18: hsl(var(--pngx-primary), calc(var(--pngx-primary-lightness) - 18%)); --pngx-primary-darken-18: hsl(var(--pngx-primary), calc(var(--pngx-primary-lightness) - 18%));
--pngx-primary-darken-27: hsl(var(--pngx-primary), calc(var(--pngx-primary-lightness) - 27%)); --pngx-primary-darken-27: hsl(var(--pngx-primary), calc(var(--pngx-primary-lightness) - 27%));
--pngx-success-darken-10: hsl(152, 69%, 11%); // based on success #198754
--pngx-bg-alt: #fff; --pngx-bg-alt: #fff;
--pngx-bg-darker: var(--bs-gray-100); --pngx-bg-darker: var(--bs-gray-100);
--pngx-focus-alpha: 0.3; --pngx-focus-alpha: 0.3;
@ -133,6 +134,14 @@ $form-check-radio-checked-bg-image-dark: url("data:image/svg+xml,<svg xmlns='htt
filter: brightness(.8); filter: brightness(.8);
} }
.badge.bg-light.border {
border-color: rgba(0,0,0,0) !important;
}
.document-card .card-body.bg-light {
background-color: var(--bs-body-bg);
}
.doc-img { .doc-img {
mix-blend-mode: normal; mix-blend-mode: normal;
border-radius: 0; border-radius: 0;
@ -197,6 +206,12 @@ $form-check-radio-checked-bg-image-dark: url("data:image/svg+xml,<svg xmlns='htt
--bs-alert-border-color: var(--pngx-bg-darker); --bs-alert-border-color: var(--pngx-bg-darker);
} }
.alert-success {
--bs-alert-color: var(--pngx-body-color-accent);
--bs-alert-bg: var(--pngx-success-darken-10);
--bs-alert-border-color: var(--pngx-bg-darker);
}
.table-striped > tbody > tr:nth-of-type(odd) > * { .table-striped > tbody > tr:nth-of-type(odd) > * {
color: var(--pngx-body-color-accent); color: var(--pngx-body-color-accent);
} }
@ -223,6 +238,18 @@ $form-check-radio-checked-bg-image-dark: url("data:image/svg+xml,<svg xmlns='htt
.dropdown-menu { .dropdown-menu {
--bs-dropdown-color: var(--bs-body-color); --bs-dropdown-color: var(--bs-body-color);
} }
.card .list-group-item {
--bs-border-color: rgb(var(--bs-dark-rgb));
.bg-secondary {
background-color: rgb(var(--bs-dark-rgb)) !important;
}
}
.preview-sticky.bg-white {
background-color: var(--pngx-bg-darker) !important;
}
} }
body.color-scheme-dark { body.color-scheme-dark {

View File

@ -4,6 +4,7 @@ from guardian.admin import GuardedModelAdmin
from .models import Correspondent from .models import Correspondent
from .models import Document from .models import Document
from .models import DocumentType from .models import DocumentType
from .models import Note
from .models import PaperlessTask from .models import PaperlessTask
from .models import SavedView from .models import SavedView
from .models import SavedViewFilterRule from .models import SavedViewFilterRule
@ -131,6 +132,13 @@ class TaskAdmin(admin.ModelAdmin):
) )
class NotesAdmin(GuardedModelAdmin):
list_display = ("user", "created", "note", "document")
list_filter = ("created", "user")
list_display_links = ("created",)
admin.site.register(Correspondent, CorrespondentAdmin) admin.site.register(Correspondent, CorrespondentAdmin)
admin.site.register(Tag, TagAdmin) admin.site.register(Tag, TagAdmin)
admin.site.register(DocumentType, DocumentTypeAdmin) admin.site.register(DocumentType, DocumentTypeAdmin)
@ -138,3 +146,4 @@ admin.site.register(Document, DocumentAdmin)
admin.site.register(SavedView, SavedViewAdmin) admin.site.register(SavedView, SavedViewAdmin)
admin.site.register(StoragePath, StoragePathAdmin) admin.site.register(StoragePath, StoragePathAdmin)
admin.site.register(PaperlessTask, TaskAdmin) admin.site.register(PaperlessTask, TaskAdmin)
admin.site.register(Note, NotesAdmin)

View File

@ -36,29 +36,30 @@ class DocumentTypeFilterSet(FilterSet):
fields = {"name": CHAR_KWARGS} fields = {"name": CHAR_KWARGS}
class TagsFilter(Filter): class ObjectFilter(Filter):
def __init__(self, exclude=False, in_list=False): def __init__(self, exclude=False, in_list=False, field_name=""):
super().__init__() super().__init__()
self.exclude = exclude self.exclude = exclude
self.in_list = in_list self.in_list = in_list
self.field_name = field_name
def filter(self, qs, value): def filter(self, qs, value):
if not value: if not value:
return qs return qs
try: try:
tag_ids = [int(x) for x in value.split(",")] object_ids = [int(x) for x in value.split(",")]
except ValueError: except ValueError:
return qs return qs
if self.in_list: if self.in_list:
qs = qs.filter(tags__id__in=tag_ids).distinct() qs = qs.filter(**{f"{self.field_name}__id__in": object_ids}).distinct()
else: else:
for tag_id in tag_ids: for obj_id in object_ids:
if self.exclude: if self.exclude:
qs = qs.exclude(tags__id=tag_id) qs = qs.exclude(**{f"{self.field_name}__id": obj_id})
else: else:
qs = qs.filter(tags__id=tag_id) qs = qs.filter(**{f"{self.field_name}__id": obj_id})
return qs return qs
@ -90,11 +91,17 @@ class DocumentFilterSet(FilterSet):
exclude=True, exclude=True,
) )
tags__id__all = TagsFilter() tags__id__all = ObjectFilter(field_name="tags")
tags__id__none = TagsFilter(exclude=True) tags__id__none = ObjectFilter(field_name="tags", exclude=True)
tags__id__in = TagsFilter(in_list=True) tags__id__in = ObjectFilter(field_name="tags", in_list=True)
correspondent__id__none = ObjectFilter(field_name="correspondent", exclude=True)
document_type__id__none = ObjectFilter(field_name="document_type", exclude=True)
storage_path__id__none = ObjectFilter(field_name="storage_path", exclude=True)
is_in_inbox = InboxFilter() is_in_inbox = InboxFilter()

View File

@ -6,8 +6,8 @@ from contextlib import contextmanager
from dateutil.parser import isoparse from dateutil.parser import isoparse
from django.conf import settings from django.conf import settings
from django.utils import timezone from django.utils import timezone
from documents.models import Comment
from documents.models import Document from documents.models import Document
from documents.models import Note
from guardian.shortcuts import get_users_with_perms from guardian.shortcuts import get_users_with_perms
from whoosh import classify from whoosh import classify
from whoosh import highlight from whoosh import highlight
@ -52,7 +52,7 @@ def get_schema():
path=TEXT(sortable=True), path=TEXT(sortable=True),
path_id=NUMERIC(), path_id=NUMERIC(),
has_path=BOOLEAN(), has_path=BOOLEAN(),
comments=TEXT(), notes=TEXT(),
owner=TEXT(), owner=TEXT(),
owner_id=NUMERIC(), owner_id=NUMERIC(),
has_owner=BOOLEAN(), has_owner=BOOLEAN(),
@ -98,7 +98,7 @@ def open_index_searcher():
def update_document(writer: AsyncWriter, doc: Document): def update_document(writer: AsyncWriter, doc: Document):
tags = ",".join([t.name for t in doc.tags.all()]) tags = ",".join([t.name for t in doc.tags.all()])
tags_ids = ",".join([str(t.id) for t in doc.tags.all()]) tags_ids = ",".join([str(t.id) for t in doc.tags.all()])
comments = ",".join([str(c.comment) for c in Comment.objects.filter(document=doc)]) notes = ",".join([str(c.note) for c in Note.objects.filter(document=doc)])
asn = doc.archive_serial_number asn = doc.archive_serial_number
if asn is not None and ( if asn is not None and (
asn < Document.ARCHIVE_SERIAL_NUMBER_MIN asn < Document.ARCHIVE_SERIAL_NUMBER_MIN
@ -136,7 +136,7 @@ def update_document(writer: AsyncWriter, doc: Document):
path=doc.storage_path.name if doc.storage_path else None, path=doc.storage_path.name if doc.storage_path else None,
path_id=doc.storage_path.id if doc.storage_path else None, path_id=doc.storage_path.id if doc.storage_path else None,
has_path=doc.storage_path is not None, has_path=doc.storage_path is not None,
comments=comments, notes=notes,
owner=doc.owner.username if doc.owner else None, owner=doc.owner.username if doc.owner else None,
owner_id=doc.owner.id if doc.owner else None, owner_id=doc.owner.id if doc.owner else None,
has_owner=doc.owner is not None, has_owner=doc.owner is not None,
@ -293,7 +293,7 @@ class DelayedFullTextQuery(DelayedQuery):
def _get_query(self): def _get_query(self):
q_str = self.query_params["query"] q_str = self.query_params["query"]
qp = MultifieldParser( qp = MultifieldParser(
["content", "title", "correspondent", "tag", "type", "comments"], ["content", "title", "correspondent", "tag", "type", "notes"],
self.searcher.ixreader.schema, self.searcher.ixreader.schema,
) )
qp.add_plugin(DateParserPlugin(basedate=timezone.now())) qp.add_plugin(DateParserPlugin(basedate=timezone.now()))

View File

@ -1,10 +1,10 @@
import logging import logging
import os import os
from concurrent.futures import ThreadPoolExecutor
from fnmatch import filter from fnmatch import filter
from pathlib import Path from pathlib import Path
from pathlib import PurePath from pathlib import PurePath
from threading import Event from threading import Event
from threading import Thread
from time import monotonic from time import monotonic
from time import sleep from time import sleep
from typing import Final from typing import Final
@ -168,11 +168,15 @@ def _consume_wait_unmodified(file: str) -> None:
class Handler(FileSystemEventHandler): class Handler(FileSystemEventHandler):
def __init__(self, pool: ThreadPoolExecutor) -> None:
super().__init__()
self._pool = pool
def on_created(self, event): def on_created(self, event):
Thread(target=_consume_wait_unmodified, args=(event.src_path,)).start() self._pool.submit(_consume_wait_unmodified, event.src_path)
def on_moved(self, event): def on_moved(self, event):
Thread(target=_consume_wait_unmodified, args=(event.dest_path,)).start() self._pool.submit(_consume_wait_unmodified, event.dest_path)
class Command(BaseCommand): class Command(BaseCommand):
@ -246,17 +250,18 @@ class Command(BaseCommand):
timeout = self.testing_timeout_s timeout = self.testing_timeout_s
logger.debug(f"Configuring timeout to {timeout}s") logger.debug(f"Configuring timeout to {timeout}s")
observer = PollingObserver(timeout=settings.CONSUMER_POLLING) with ThreadPoolExecutor(max_workers=4) as pool:
observer.schedule(Handler(), directory, recursive=recursive) observer = PollingObserver(timeout=settings.CONSUMER_POLLING)
observer.start() observer.schedule(Handler(pool), directory, recursive=recursive)
try: observer.start()
while observer.is_alive(): try:
observer.join(timeout) while observer.is_alive():
if self.stop_flag.is_set(): observer.join(timeout)
observer.stop() if self.stop_flag.is_set():
except KeyboardInterrupt: observer.stop()
observer.stop() except KeyboardInterrupt:
observer.join() observer.stop()
observer.join()
def handle_inotify(self, directory, recursive, is_testing: bool): def handle_inotify(self, directory, recursive, is_testing: bool):
logger.info(f"Using inotify to watch directory for changes: {directory}") logger.info(f"Using inotify to watch directory for changes: {directory}")

View File

@ -17,10 +17,10 @@ from django.core.management.base import BaseCommand
from django.core.management.base import CommandError from django.core.management.base import CommandError
from django.db import transaction from django.db import transaction
from django.utils import timezone from django.utils import timezone
from documents.models import Comment
from documents.models import Correspondent from documents.models import Correspondent
from documents.models import Document from documents.models import Document
from documents.models import DocumentType from documents.models import DocumentType
from documents.models import Note
from documents.models import SavedView from documents.models import SavedView
from documents.models import SavedViewFilterRule from documents.models import SavedViewFilterRule
from documents.models import StoragePath from documents.models import StoragePath
@ -206,7 +206,7 @@ class Command(BaseCommand):
self.files_in_export_dir.add(x.resolve()) self.files_in_export_dir.add(x.resolve())
# 2. Create manifest, containing all correspondents, types, tags, storage paths # 2. Create manifest, containing all correspondents, types, tags, storage paths
# comments, documents and ui_settings # note, documents and ui_settings
with transaction.atomic(): with transaction.atomic():
manifest = json.loads( manifest = json.loads(
serializers.serialize("json", Correspondent.objects.all()), serializers.serialize("json", Correspondent.objects.all()),
@ -222,11 +222,11 @@ class Command(BaseCommand):
serializers.serialize("json", StoragePath.objects.all()), serializers.serialize("json", StoragePath.objects.all()),
) )
comments = json.loads( notes = json.loads(
serializers.serialize("json", Comment.objects.all()), serializers.serialize("json", Note.objects.all()),
) )
if not self.split_manifest: if not self.split_manifest:
manifest += comments manifest += notes
documents = Document.objects.order_by("id") documents = Document.objects.order_by("id")
document_map = {d.pk: d for d in documents} document_map = {d.pk: d for d in documents}
@ -359,7 +359,7 @@ class Command(BaseCommand):
content += list( content += list(
filter( filter(
lambda d: d["fields"]["document"] == document_dict["pk"], lambda d: d["fields"]["document"] == document_dict["pk"],
comments, notes,
), ),
) )
manifest_name.write_text(json.dumps(content, indent=2)) manifest_name.write_text(json.dumps(content, indent=2))

View File

@ -0,0 +1,54 @@
# Generated by Django 4.1.5 on 2023-03-15 07:10
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("documents", "1033_alter_documenttype_options_alter_tag_options_and_more"),
]
operations = [
migrations.AlterField(
model_name="savedviewfilterrule",
name="rule_type",
field=models.PositiveIntegerField(
choices=[
(0, "title contains"),
(1, "content contains"),
(2, "ASN is"),
(3, "correspondent is"),
(4, "document type is"),
(5, "is in inbox"),
(6, "has tag"),
(7, "has any tag"),
(8, "created before"),
(9, "created after"),
(10, "created year is"),
(11, "created month is"),
(12, "created day is"),
(13, "added before"),
(14, "added after"),
(15, "modified before"),
(16, "modified after"),
(17, "does not have tag"),
(18, "does not have ASN"),
(19, "title or content contains"),
(20, "fulltext query"),
(21, "more like this"),
(22, "has tags in"),
(23, "ASN greater than"),
(24, "ASN less than"),
(25, "storage path is"),
(26, "has correspondent in"),
(27, "does not have correspondent in"),
(28, "has document type in"),
(29, "does not have document type in"),
(30, "has storage path in"),
(31, "does not have storage path in"),
],
verbose_name="rule type",
),
),
]

View File

@ -0,0 +1,61 @@
# Generated by Django 4.1.5 on 2023-03-17 22:15
from django.conf import settings
from django.db import migrations
from django.db import models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
("documents", "1034_alter_savedviewfilterrule_rule_type"),
]
operations = [
migrations.RenameModel(
old_name="Comment",
new_name="Note",
),
migrations.RenameField(model_name="note", old_name="comment", new_name="note"),
migrations.AlterModelOptions(
name="note",
options={
"ordering": ("created",),
"verbose_name": "note",
"verbose_name_plural": "notes",
},
),
migrations.AlterField(
model_name="note",
name="document",
field=models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.CASCADE,
related_name="notes",
to="documents.document",
verbose_name="document",
),
),
migrations.AlterField(
model_name="note",
name="note",
field=models.TextField(
blank=True, help_text="Note for the document", verbose_name="content"
),
),
migrations.AlterField(
model_name="note",
name="user",
field=models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name="notes",
to=settings.AUTH_USER_MODEL,
verbose_name="user",
),
),
]

View File

@ -447,6 +447,12 @@ class SavedViewFilterRule(models.Model):
(23, _("ASN greater than")), (23, _("ASN greater than")),
(24, _("ASN less than")), (24, _("ASN less than")),
(25, _("storage path is")), (25, _("storage path is")),
(26, _("has correspondent in")),
(27, _("does not have correspondent in")),
(28, _("has document type in")),
(29, _("does not have document type in")),
(30, _("has storage path in")),
(31, _("does not have storage path in")),
] ]
saved_view = models.ForeignKey( saved_view = models.ForeignKey(
@ -629,11 +635,11 @@ class PaperlessTask(models.Model):
) )
class Comment(models.Model): class Note(models.Model):
comment = models.TextField( note = models.TextField(
_("content"), _("content"),
blank=True, blank=True,
help_text=_("Comment for the document"), help_text=_("Note for the document"),
) )
created = models.DateTimeField( created = models.DateTimeField(
@ -646,7 +652,7 @@ class Comment(models.Model):
Document, Document,
blank=True, blank=True,
null=True, null=True,
related_name="documents", related_name="notes",
on_delete=models.CASCADE, on_delete=models.CASCADE,
verbose_name=_("document"), verbose_name=_("document"),
) )
@ -655,15 +661,15 @@ class Comment(models.Model):
User, User,
blank=True, blank=True,
null=True, null=True,
related_name="users", related_name="notes",
on_delete=models.SET_NULL, on_delete=models.SET_NULL,
verbose_name=_("user"), verbose_name=_("user"),
) )
class Meta: class Meta:
ordering = ("created",) ordering = ("created",)
verbose_name = _("comment") verbose_name = _("note")
verbose_name_plural = _("comments") verbose_name_plural = _("notes")
def __str__(self): def __str__(self):
return self.content return self.note

View File

@ -78,10 +78,11 @@ class MatchingModelSerializer(serializers.ModelSerializer):
if hasattr(self, "user") if hasattr(self, "user")
else None else None
) )
pk = self.instance.pk if hasattr(self.instance, "pk") else None
if ("name" in data or "owner" in data) and self.Meta.model.objects.filter( if ("name" in data or "owner" in data) and self.Meta.model.objects.filter(
name=name, name=name,
owner=owner, owner=owner,
).exists(): ).exclude(pk=pk).exists():
raise serializers.ValidationError( raise serializers.ValidationError(
{"error": "Object violates owner / name unique constraint"}, {"error": "Object violates owner / name unique constraint"},
) )
@ -442,6 +443,7 @@ class DocumentSerializer(OwnedObjectSerializer, DynamicFieldsModelSerializer):
"owner", "owner",
"permissions", "permissions",
"set_permissions", "set_permissions",
"notes",
) )

View File

@ -38,7 +38,7 @@ from documents.models import PaperlessTask
from documents.models import SavedView from documents.models import SavedView
from documents.models import StoragePath from documents.models import StoragePath
from documents.models import Tag from documents.models import Tag
from documents.models import Comment from documents.models import Note
from documents.tests.utils import DirectoriesMixin from documents.tests.utils import DirectoriesMixin
from paperless import version from paperless import version
from rest_framework.test import APITestCase from rest_framework.test import APITestCase
@ -1039,9 +1039,24 @@ class TestDocumentApi(DirectoriesMixin, APITestCase):
def test_statistics(self): def test_statistics(self):
doc1 = Document.objects.create(title="none1", checksum="A") doc1 = Document.objects.create(
doc2 = Document.objects.create(title="none2", checksum="B") title="none1",
doc3 = Document.objects.create(title="none3", checksum="C") checksum="A",
mime_type="application/pdf",
content="abc",
)
doc2 = Document.objects.create(
title="none2",
checksum="B",
mime_type="application/pdf",
content="123",
)
doc3 = Document.objects.create(
title="none3",
checksum="C",
mime_type="text/plain",
content="hello",
)
tag_inbox = Tag.objects.create(name="t1", is_inbox_tag=True) tag_inbox = Tag.objects.create(name="t1", is_inbox_tag=True)
@ -1051,6 +1066,16 @@ class TestDocumentApi(DirectoriesMixin, APITestCase):
self.assertEqual(response.status_code, status.HTTP_200_OK) self.assertEqual(response.status_code, status.HTTP_200_OK)
self.assertEqual(response.data["documents_total"], 3) self.assertEqual(response.data["documents_total"], 3)
self.assertEqual(response.data["documents_inbox"], 1) self.assertEqual(response.data["documents_inbox"], 1)
self.assertEqual(response.data["inbox_tag"], tag_inbox.pk)
self.assertEqual(
response.data["document_file_type_counts"][0]["mime_type_count"],
2,
)
self.assertEqual(
response.data["document_file_type_counts"][1]["mime_type_count"],
1,
)
self.assertEqual(response.data["character_count"], 11)
def test_statistics_no_inbox_tag(self): def test_statistics_no_inbox_tag(self):
Document.objects.create(title="none1", checksum="A") Document.objects.create(title="none1", checksum="A")
@ -1058,6 +1083,7 @@ class TestDocumentApi(DirectoriesMixin, APITestCase):
response = self.client.get("/api/statistics/") response = self.client.get("/api/statistics/")
self.assertEqual(response.status_code, status.HTTP_200_OK) self.assertEqual(response.status_code, status.HTTP_200_OK)
self.assertEqual(response.data["documents_inbox"], None) self.assertEqual(response.data["documents_inbox"], None)
self.assertEqual(response.data["inbox_tag"], None)
@mock.patch("documents.views.consume_file.delay") @mock.patch("documents.views.consume_file.delay")
def test_upload(self, m): def test_upload(self, m):
@ -1717,28 +1743,28 @@ class TestDocumentApi(DirectoriesMixin, APITestCase):
1, 1,
) )
def test_get_existing_comments(self): def test_get_existing_notes(self):
""" """
GIVEN: GIVEN:
- A document with a single comment - A document with a single note
WHEN: WHEN:
- API reuqest for document comments is made - API reuqest for document notes is made
THEN: THEN:
- The associated comment is returned - The associated note is returned
""" """
doc = Document.objects.create( doc = Document.objects.create(
title="test", title="test",
mime_type="application/pdf", mime_type="application/pdf",
content="this is a document which will have comments!", content="this is a document which will have notes!",
) )
comment = Comment.objects.create( note = Note.objects.create(
comment="This is a comment.", note="This is a note.",
document=doc, document=doc,
user=self.user, user=self.user,
) )
response = self.client.get( response = self.client.get(
f"/api/documents/{doc.pk}/comments/", f"/api/documents/{doc.pk}/notes/",
format="json", format="json",
) )
@ -1754,39 +1780,39 @@ class TestDocumentApi(DirectoriesMixin, APITestCase):
self.assertDictEqual( self.assertDictEqual(
resp_data, resp_data,
{ {
"id": comment.id, "id": note.id,
"comment": comment.comment, "note": note.note,
"user": { "user": {
"id": comment.user.id, "id": note.user.id,
"username": comment.user.username, "username": note.user.username,
"first_name": comment.user.first_name, "first_name": note.user.first_name,
"last_name": comment.user.last_name, "last_name": note.user.last_name,
}, },
}, },
) )
def test_create_comment(self): def test_create_note(self):
""" """
GIVEN: GIVEN:
- Existing document - Existing document
WHEN: WHEN:
- API request is made to add a comment - API request is made to add a note
THEN: THEN:
- Comment is created and associated with document - note is created and associated with document
""" """
doc = Document.objects.create( doc = Document.objects.create(
title="test", title="test",
mime_type="application/pdf", mime_type="application/pdf",
content="this is a document which will have comments added", content="this is a document which will have notes added",
) )
resp = self.client.post( resp = self.client.post(
f"/api/documents/{doc.pk}/comments/", f"/api/documents/{doc.pk}/notes/",
data={"comment": "this is a posted comment"}, data={"note": "this is a posted note"},
) )
self.assertEqual(resp.status_code, status.HTTP_200_OK) self.assertEqual(resp.status_code, status.HTTP_200_OK)
response = self.client.get( response = self.client.get(
f"/api/documents/{doc.pk}/comments/", f"/api/documents/{doc.pk}/notes/",
format="json", format="json",
) )
@ -1798,48 +1824,48 @@ class TestDocumentApi(DirectoriesMixin, APITestCase):
resp_data = resp_data[0] resp_data = resp_data[0]
self.assertEqual(resp_data["comment"], "this is a posted comment") self.assertEqual(resp_data["note"], "this is a posted note")
def test_delete_comment(self): def test_delete_note(self):
""" """
GIVEN: GIVEN:
- Existing document - Existing document
WHEN: WHEN:
- API request is made to add a comment - API request is made to add a note
THEN: THEN:
- Comment is created and associated with document - note is created and associated with document
""" """
doc = Document.objects.create( doc = Document.objects.create(
title="test", title="test",
mime_type="application/pdf", mime_type="application/pdf",
content="this is a document which will have comments!", content="this is a document which will have notes!",
) )
comment = Comment.objects.create( note = Note.objects.create(
comment="This is a comment.", note="This is a note.",
document=doc, document=doc,
user=self.user, user=self.user,
) )
response = self.client.delete( response = self.client.delete(
f"/api/documents/{doc.pk}/comments/?id={comment.pk}", f"/api/documents/{doc.pk}/notes/?id={note.pk}",
format="json", format="json",
) )
self.assertEqual(response.status_code, status.HTTP_200_OK) self.assertEqual(response.status_code, status.HTTP_200_OK)
self.assertEqual(len(Comment.objects.all()), 0) self.assertEqual(len(Note.objects.all()), 0)
def test_get_comments_no_doc(self): def test_get_notes_no_doc(self):
""" """
GIVEN: GIVEN:
- A request to get comments from a non-existent document - A request to get notes from a non-existent document
WHEN: WHEN:
- API request for document comments is made - API request for document notes is made
THEN: THEN:
- HTTP status.HTTP_404_NOT_FOUND is returned - HTTP status.HTTP_404_NOT_FOUND is returned
""" """
response = self.client.get( response = self.client.get(
"/api/documents/500/comments/", "/api/documents/500/notes/",
format="json", format="json",
) )
self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND) self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND)

View File

@ -13,10 +13,10 @@ from django.test import override_settings
from django.test import TestCase from django.test import TestCase
from django.utils import timezone from django.utils import timezone
from documents.management.commands import document_exporter from documents.management.commands import document_exporter
from documents.models import Comment
from documents.models import Correspondent from documents.models import Correspondent
from documents.models import Document from documents.models import Document
from documents.models import DocumentType from documents.models import DocumentType
from documents.models import Note
from documents.models import StoragePath from documents.models import StoragePath
from documents.models import Tag from documents.models import Tag
from documents.models import User from documents.models import User
@ -66,8 +66,8 @@ class TestExportImport(DirectoriesMixin, FileSystemAssertsMixin, TestCase):
storage_type=Document.STORAGE_TYPE_GPG, storage_type=Document.STORAGE_TYPE_GPG,
) )
self.comment = Comment.objects.create( self.note = Note.objects.create(
comment="This is a comment. amaze.", note="This is a note. amaze.",
document=self.d1, document=self.d1,
user=self.user, user=self.user,
) )
@ -199,8 +199,8 @@ class TestExportImport(DirectoriesMixin, FileSystemAssertsMixin, TestCase):
checksum = hashlib.md5(f.read()).hexdigest() checksum = hashlib.md5(f.read()).hexdigest()
self.assertEqual(checksum, element["fields"]["archive_checksum"]) self.assertEqual(checksum, element["fields"]["archive_checksum"])
elif element["model"] == "documents.comment": elif element["model"] == "documents.note":
self.assertEqual(element["fields"]["comment"], self.comment.comment) self.assertEqual(element["fields"]["note"], self.note.note)
self.assertEqual(element["fields"]["document"], self.d1.id) self.assertEqual(element["fields"]["document"], self.d1.id)
self.assertEqual(element["fields"]["user"], self.user.id) self.assertEqual(element["fields"]["user"], self.user.id)

View File

@ -20,7 +20,9 @@ from django.db.models import Case
from django.db.models import Count from django.db.models import Count
from django.db.models import IntegerField from django.db.models import IntegerField
from django.db.models import Max from django.db.models import Max
from django.db.models import Sum
from django.db.models import When from django.db.models import When
from django.db.models.functions import Length
from django.db.models.functions import Lower from django.db.models.functions import Lower
from django.http import Http404 from django.http import Http404
from django.http import HttpResponse from django.http import HttpResponse
@ -72,10 +74,10 @@ from .matching import match_correspondents
from .matching import match_document_types from .matching import match_document_types
from .matching import match_storage_paths from .matching import match_storage_paths
from .matching import match_tags from .matching import match_tags
from .models import Comment
from .models import Correspondent from .models import Correspondent
from .models import Document from .models import Document
from .models import DocumentType from .models import DocumentType
from .models import Note
from .models import PaperlessTask from .models import PaperlessTask
from .models import SavedView from .models import SavedView
from .models import StoragePath from .models import StoragePath
@ -186,6 +188,7 @@ class TagViewSet(ModelViewSet, PassUserMixin):
) )
def get_serializer_class(self, *args, **kwargs): def get_serializer_class(self, *args, **kwargs):
print(self.request.version)
if int(self.request.version) == 1: if int(self.request.version) == 1:
return TagSerializerVersion1 return TagSerializerVersion1
else: else:
@ -230,7 +233,7 @@ class DocumentViewSet(
GenericViewSet, GenericViewSet,
): ):
model = Document model = Document
queryset = Document.objects.all() queryset = Document.objects.annotate(num_notes=Count("notes"))
serializer_class = DocumentSerializer serializer_class = DocumentSerializer
pagination_class = StandardPagination pagination_class = StandardPagination
permission_classes = (IsAuthenticated, PaperlessObjectPermissions) permission_classes = (IsAuthenticated, PaperlessObjectPermissions)
@ -251,10 +254,11 @@ class DocumentViewSet(
"modified", "modified",
"added", "added",
"archive_serial_number", "archive_serial_number",
"num_notes",
) )
def get_queryset(self): def get_queryset(self):
return Document.objects.distinct() return Document.objects.distinct().annotate(num_notes=Count("notes"))
def get_serializer(self, *args, **kwargs): def get_serializer(self, *args, **kwargs):
super().get_serializer(*args, **kwargs) super().get_serializer(*args, **kwargs)
@ -441,11 +445,11 @@ class DocumentViewSet(
except (FileNotFoundError, Document.DoesNotExist): except (FileNotFoundError, Document.DoesNotExist):
raise Http404() raise Http404()
def getComments(self, doc): def getNotes(self, doc):
return [ return [
{ {
"id": c.id, "id": c.id,
"comment": c.comment, "note": c.note,
"created": c.created, "created": c.created,
"user": { "user": {
"id": c.user.id, "id": c.user.id,
@ -454,11 +458,11 @@ class DocumentViewSet(
"last_name": c.user.last_name, "last_name": c.user.last_name,
}, },
} }
for c in Comment.objects.filter(document=doc).order_by("-created") for c in Note.objects.filter(document=doc).order_by("-created")
] ]
@action(methods=["get", "post", "delete"], detail=True) @action(methods=["get", "post", "delete"], detail=True)
def comments(self, request, pk=None): def notes(self, request, pk=None):
try: try:
doc = Document.objects.get(pk=pk) doc = Document.objects.get(pk=pk)
except Document.DoesNotExist: except Document.DoesNotExist:
@ -468,17 +472,17 @@ class DocumentViewSet(
if request.method == "GET": if request.method == "GET":
try: try:
return Response(self.getComments(doc)) return Response(self.getNotes(doc))
except Exception as e: except Exception as e:
logger.warning(f"An error occurred retrieving comments: {str(e)}") logger.warning(f"An error occurred retrieving notes: {str(e)}")
return Response( return Response(
{"error": "Error retreiving comments, check logs for more detail."}, {"error": "Error retreiving notes, check logs for more detail."},
) )
elif request.method == "POST": elif request.method == "POST":
try: try:
c = Comment.objects.create( c = Note.objects.create(
document=doc, document=doc,
comment=request.data["comment"], note=request.data["note"],
user=currentUser, user=currentUser,
) )
c.save() c.save()
@ -487,23 +491,23 @@ class DocumentViewSet(
index.add_or_update_document(self.get_object()) index.add_or_update_document(self.get_object())
return Response(self.getComments(doc)) return Response(self.getNotes(doc))
except Exception as e: except Exception as e:
logger.warning(f"An error occurred saving comment: {str(e)}") logger.warning(f"An error occurred saving note: {str(e)}")
return Response( return Response(
{ {
"error": "Error saving comment, check logs for more detail.", "error": "Error saving note, check logs for more detail.",
}, },
) )
elif request.method == "DELETE": elif request.method == "DELETE":
comment = Comment.objects.get(id=int(request.GET.get("id"))) note = Note.objects.get(id=int(request.GET.get("id")))
comment.delete() note.delete()
from documents import index from documents import index
index.add_or_update_document(self.get_object()) index.add_or_update_document(self.get_object())
return Response(self.getComments(doc)) return Response(self.getNotes(doc))
return Response( return Response(
{ {
@ -515,14 +519,14 @@ class DocumentViewSet(
class SearchResultSerializer(DocumentSerializer, PassUserMixin): class SearchResultSerializer(DocumentSerializer, PassUserMixin):
def to_representation(self, instance): def to_representation(self, instance):
doc = Document.objects.get(id=instance["id"]) doc = Document.objects.get(id=instance["id"])
comments = ",".join( notes = ",".join(
[str(c.comment) for c in Comment.objects.filter(document=instance["id"])], [str(c.note) for c in Note.objects.filter(document=instance["id"])],
) )
r = super().to_representation(doc) r = super().to_representation(doc)
r["__search_hit__"] = { r["__search_hit__"] = {
"score": instance.score, "score": instance.score,
"highlights": instance.highlights("content", text=doc.content), "highlights": instance.highlights("content", text=doc.content),
"comment_highlights": instance.highlights("comments", text=comments) "note_highlights": instance.highlights("notes", text=notes)
if doc if doc
else None, else None,
"rank": instance.rank, "rank": instance.rank,
@ -794,17 +798,38 @@ class StatisticsView(APIView):
def get(self, request, format=None): def get(self, request, format=None):
documents_total = Document.objects.all().count() documents_total = Document.objects.all().count()
if Tag.objects.filter(is_inbox_tag=True).exists():
documents_inbox = ( inbox_tag = Tag.objects.filter(is_inbox_tag=True)
Document.objects.filter(tags__is_inbox_tag=True).distinct().count()
documents_inbox = (
Document.objects.filter(tags__is_inbox_tag=True).distinct().count()
if inbox_tag.exists()
else None
)
document_file_type_counts = (
Document.objects.values("mime_type")
.annotate(mime_type_count=Count("mime_type"))
.order_by("-mime_type_count")
if documents_total > 0
else 0
)
character_count = (
Document.objects.annotate(
characters=Length("content"),
) )
else: .aggregate(Sum("characters"))
documents_inbox = None .get("characters__sum")
)
return Response( return Response(
{ {
"documents_total": documents_total, "documents_total": documents_total,
"documents_inbox": documents_inbox, "documents_inbox": documents_inbox,
"inbox_tag": inbox_tag.first().pk if inbox_tag.exists() else None,
"document_file_type_counts": document_file_type_counts,
"character_count": character_count,
}, },
) )

View File

@ -358,7 +358,7 @@ TEMPLATES = [
CHANNEL_LAYERS = { CHANNEL_LAYERS = {
"default": { "default": {
"BACKEND": "channels_redis.core.RedisChannelLayer", "BACKEND": "channels_redis.pubsub.RedisPubSubChannelLayer",
"CONFIG": { "CONFIG": {
"hosts": [_CHANNELS_REDIS_URL], "hosts": [_CHANNELS_REDIS_URL],
"capacity": 2000, # default 100 "capacity": 2000, # default 100
@ -509,7 +509,12 @@ if os.getenv("PAPERLESS_DBHOST"):
else: # Default to PostgresDB else: # Default to PostgresDB
engine = "django.db.backends.postgresql_psycopg2" engine = "django.db.backends.postgresql_psycopg2"
options = {"sslmode": os.getenv("PAPERLESS_DBSSLMODE", "prefer")} options = {
"sslmode": os.getenv("PAPERLESS_DBSSLMODE", "prefer"),
"sslrootcert": os.getenv("PAPERLESS_DBSSLROOTCERT", None),
"sslcert": os.getenv("PAPERLESS_DBSSLCERT", None),
"sslkey": os.getenv("PAPERLESS_DBSSLKEY", None),
}
DATABASES["default"]["ENGINE"] = engine DATABASES["default"]["ENGINE"] = engine
DATABASES["default"]["OPTIONS"].update(options) DATABASES["default"]["OPTIONS"].update(options)
@ -606,11 +611,20 @@ LOGGING = {
"maxBytes": LOGROTATE_MAX_SIZE, "maxBytes": LOGROTATE_MAX_SIZE,
"backupCount": LOGROTATE_MAX_BACKUPS, "backupCount": LOGROTATE_MAX_BACKUPS,
}, },
"file_celery": {
"class": "concurrent_log_handler.ConcurrentRotatingFileHandler",
"formatter": "verbose",
"filename": os.path.join(LOGGING_DIR, "celery.log"),
"maxBytes": LOGROTATE_MAX_SIZE,
"backupCount": LOGROTATE_MAX_BACKUPS,
},
}, },
"root": {"handlers": ["console"]}, "root": {"handlers": ["console"]},
"loggers": { "loggers": {
"paperless": {"handlers": ["file_paperless"], "level": "DEBUG"}, "paperless": {"handlers": ["file_paperless"], "level": "DEBUG"},
"paperless_mail": {"handlers": ["file_mail"], "level": "DEBUG"}, "paperless_mail": {"handlers": ["file_mail"], "level": "DEBUG"},
"celery": {"handlers": ["file_celery"], "level": "DEBUG"},
"kombu": {"handlers": ["file_celery"], "level": "DEBUG"},
}, },
} }

View File

@ -29,6 +29,7 @@ from paperless.consumers import StatusConsumer
from paperless.views import FaviconView from paperless.views import FaviconView
from paperless.views import GroupViewSet from paperless.views import GroupViewSet
from paperless.views import UserViewSet from paperless.views import UserViewSet
from paperless_mail.views import MailAccountTestView
from paperless_mail.views import MailAccountViewSet from paperless_mail.views import MailAccountViewSet
from paperless_mail.views import MailRuleViewSet from paperless_mail.views import MailRuleViewSet
from rest_framework.authtoken import views from rest_framework.authtoken import views
@ -102,6 +103,11 @@ urlpatterns = [
AcknowledgeTasksView.as_view(), AcknowledgeTasksView.as_view(),
name="acknowledge_tasks", name="acknowledge_tasks",
), ),
re_path(
r"^mail_accounts/test/",
MailAccountTestView.as_view(),
name="mail_accounts_test",
),
path("token/", views.obtain_auth_token), path("token/", views.obtain_auth_token),
] ]
+ api_router.urls, + api_router.urls,

View File

@ -202,20 +202,21 @@ def mailbox_login(mailbox: MailBox, account: MailAccount):
try: try:
mailbox.login(account.username, account.password) if account.is_token:
mailbox.xoauth2(account.username, account.password)
else:
try:
_ = account.password.encode("ascii")
use_ascii_login = True
except UnicodeEncodeError:
use_ascii_login = False
except UnicodeEncodeError: if use_ascii_login:
logger.debug("Falling back to AUTH=PLAIN") mailbox.login(account.username, account.password)
else:
logger.debug("Falling back to AUTH=PLAIN")
mailbox.login_utf8(account.username, account.password)
try:
mailbox.login_utf8(account.username, account.password)
except Exception as e:
logger.error(
"Unable to authenticate with mail server using AUTH=PLAIN",
)
raise MailError(
f"Error while authenticating account {account}",
) from e
except Exception as e: except Exception as e:
logger.error( logger.error(
f"Error while authenticating account {account}: {e}", f"Error while authenticating account {account}: {e}",

View File

@ -0,0 +1,19 @@
# Generated by Django 4.1.7 on 2023-03-22 17:51
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("paperless_mail", "0019_mailrule_filter_to"),
]
operations = [
migrations.AddField(
model_name="mailaccount",
name="is_token",
field=models.BooleanField(
default=False, verbose_name="Is token authentication"
),
),
]

View File

@ -38,6 +38,8 @@ class MailAccount(document_models.ModelWithOwner):
password = models.CharField(_("password"), max_length=256) password = models.CharField(_("password"), max_length=256)
is_token = models.BooleanField(_("Is token authentication"), default=False)
character_set = models.CharField( character_set = models.CharField(
_("character set"), _("character set"),
max_length=256, max_length=256,

Some files were not shown because too many files have changed in this diff Show More