Merge branch 'dev' of github.com:paperless-ngx/paperless-ngx into patch-1

This commit is contained in:
Andrew Berry 2024-03-06 10:34:33 -05:00
commit 16ad7dd792
No known key found for this signature in database
GPG Key ID: 22C19D01CEBFC72B
147 changed files with 39977 additions and 21529 deletions

View File

@ -47,7 +47,7 @@ repos:
exclude: "(^Pipfile\\.lock$)" exclude: "(^Pipfile\\.lock$)"
# Python hooks # Python hooks
- repo: https://github.com/astral-sh/ruff-pre-commit - repo: https://github.com/astral-sh/ruff-pre-commit
rev: 'v0.2.2' rev: 'v0.3.0'
hooks: hooks:
- id: ruff - id: ruff
- repo: https://github.com/psf/black-pre-commit-mirror - repo: https://github.com/psf/black-pre-commit-mirror

View File

@ -59,7 +59,8 @@ ARG GS_VERSION=10.02.1
ENV PYTHONDONTWRITEBYTECODE=1 \ ENV PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1 \ PYTHONUNBUFFERED=1 \
# Ignore warning from Whitenoise # Ignore warning from Whitenoise
PYTHONWARNINGS="ignore:::django.http.response:517" PYTHONWARNINGS="ignore:::django.http.response:517" \
PNGX_CONTAINERIZED=1
# #
# Begin installation and configuration # Begin installation and configuration

View File

@ -7,7 +7,7 @@ name = "pypi"
dateparser = "~=1.2" dateparser = "~=1.2"
# WARNING: django does not use semver. # WARNING: django does not use semver.
# Only patch versions are guaranteed to not introduce breaking changes. # Only patch versions are guaranteed to not introduce breaking changes.
django = "~=4.2.10" django = "~=4.2.11"
django-allauth = "*" django-allauth = "*"
django-auditlog = "*" django-auditlog = "*"
django-celery-results = "*" django-celery-results = "*"

653
Pipfile.lock generated
View File

@ -1,7 +1,7 @@
{ {
"_meta": { "_meta": {
"hash": { "hash": {
"sha256": "cdd620926150607295aa42447d26c89d2a372309e3229c4f8f711ebe0ed19670" "sha256": "7bc15a3bbd521f85a8cdcc85be8adf7c942acb53c6d461199d7f8b1ef63ac651"
}, },
"pipfile-spec": 6, "pipfile-spec": 6,
"requires": {}, "requires": {},
@ -24,11 +24,11 @@
}, },
"anyio": { "anyio": {
"hashes": [ "hashes": [
"sha256:745843b39e829e108e518c489b31dc757de7d2131d53fac32bd8df268227bfee", "sha256:048e05d0f6caeed70d731f3db756d35dcc1f35747c8c403364a8332c630441b8",
"sha256:e1875bb4b4e2de1669f4bc7869b6d3f54231cdced71605e6e64c9be77e3be50f" "sha256:f75253795a87df48568485fd18cdd2a3fa5c4f7c5be8e5e36637733fce06fed6"
], ],
"markers": "python_version >= '3.8'", "markers": "python_version >= '3.8'",
"version": "==4.2.0" "version": "==4.3.0"
}, },
"asgiref": { "asgiref": {
"hashes": [ "hashes": [
@ -43,7 +43,7 @@
"sha256:4640d96be84d82d02ed59ea2b7105a0f7b33abe8703703cd0ab0bf87c427522f", "sha256:4640d96be84d82d02ed59ea2b7105a0f7b33abe8703703cd0ab0bf87c427522f",
"sha256:7405140ff1230c310e51dc27b3145b9092d659ce68ff733fb0cefe3ee42be028" "sha256:7405140ff1230c310e51dc27b3145b9092d659ce68ff733fb0cefe3ee42be028"
], ],
"markers": "python_full_version <= '3.11.2'", "markers": "python_version >= '3.7'",
"version": "==4.0.3" "version": "==4.0.3"
}, },
"billiard": { "billiard": {
@ -392,42 +392,41 @@
}, },
"cryptography": { "cryptography": {
"hashes": [ "hashes": [
"sha256:01911714117642a3f1792c7f376db572aadadbafcd8d75bb527166009c9f1d1b", "sha256:0270572b8bd2c833c3981724b8ee9747b3ec96f699a9665470018594301439ee",
"sha256:0e89f7b84f421c56e7ff69f11c441ebda73b8a8e6488d322ef71746224c20fce", "sha256:111a0d8553afcf8eb02a4fea6ca4f59d48ddb34497aa8706a6cf536f1a5ec576",
"sha256:12d341bd42cdb7d4937b0cabbdf2a94f949413ac4504904d0cdbdce4a22cbf88", "sha256:16a48c23a62a2f4a285699dba2e4ff2d1cff3115b9df052cdd976a18856d8e3d",
"sha256:15a1fb843c48b4a604663fa30af60818cd28f895572386e5f9b8a665874c26e7", "sha256:1b95b98b0d2af784078fa69f637135e3c317091b615cd0905f8b8a087e86fa30",
"sha256:1cdcdbd117681c88d717437ada72bdd5be9de117f96e3f4d50dab3f59fd9ab20", "sha256:1f71c10d1e88467126f0efd484bd44bca5e14c664ec2ede64c32f20875c0d413",
"sha256:1df6fcbf60560d2113b5ed90f072dc0b108d64750d4cbd46a21ec882c7aefce9", "sha256:2424ff4c4ac7f6b8177b53c17ed5d8fa74ae5955656867f5a8affaca36a27abb",
"sha256:3c6048f217533d89f2f8f4f0fe3044bf0b2090453b7b73d0b77db47b80af8dff", "sha256:2bce03af1ce5a5567ab89bd90d11e7bbdff56b8af3acbbec1faded8f44cb06da",
"sha256:3e970a2119507d0b104f0a8e281521ad28fc26f2820687b3436b8c9a5fcf20d1", "sha256:329906dcc7b20ff3cad13c069a78124ed8247adcac44b10bea1130e36caae0b4",
"sha256:44a64043f743485925d3bcac548d05df0f9bb445c5fcca6681889c7c3ab12764", "sha256:37dd623507659e08be98eec89323469e8c7b4c1407c85112634ae3dbdb926fdd",
"sha256:4e36685cb634af55e0677d435d425043967ac2f3790ec652b2b88ad03b85c27b", "sha256:3eaafe47ec0d0ffcc9349e1708be2aaea4c6dd4978d76bf6eb0cb2c13636c6fc",
"sha256:5f8907fcf57392cd917892ae83708761c6ff3c37a8e835d7246ff0ad251d9298", "sha256:5e6275c09d2badf57aea3afa80d975444f4be8d3bc58f7f80d2a484c6f9485c8",
"sha256:69b22ab6506a3fe483d67d1ed878e1602bdd5912a134e6202c1ec672233241c1", "sha256:6fe07eec95dfd477eb9530aef5bead34fec819b3aaf6c5bd6d20565da607bfe1",
"sha256:6bfadd884e7280df24d26f2186e4e07556a05d37393b0f220a840b083dc6a824", "sha256:7367d7b2eca6513681127ebad53b2582911d1736dc2ffc19f2c3ae49997496bc",
"sha256:6d0fbe73728c44ca3a241eff9aefe6496ab2656d6e7a4ea2459865f2e8613257", "sha256:7cde5f38e614f55e28d831754e8a3bacf9ace5d1566235e39d91b35502d6936e",
"sha256:6ffb03d419edcab93b4b19c22ee80c007fb2d708429cecebf1dd3258956a563a", "sha256:9481ffe3cf013b71b2428b905c4f7a9a4f76ec03065b05ff499bb5682a8d9ad8",
"sha256:810bcf151caefc03e51a3d61e53335cd5c7316c0a105cc695f0959f2c638b129", "sha256:98d8dc6d012b82287f2c3d26ce1d2dd130ec200c8679b6213b3c73c08b2b7940",
"sha256:831a4b37accef30cccd34fcb916a5d7b5be3cbbe27268a02832c3e450aea39cb", "sha256:a011a644f6d7d03736214d38832e030d8268bcff4a41f728e6030325fea3e400",
"sha256:887623fe0d70f48ab3f5e4dbf234986b1329a64c066d719432d0698522749929", "sha256:a2913c5375154b6ef2e91c10b5720ea6e21007412f6437504ffea2109b5a33d7",
"sha256:a0298bdc6e98ca21382afe914c642620370ce0470a01e1bef6dd9b5354c36854", "sha256:a30596bae9403a342c978fb47d9b0ee277699fa53bbafad14706af51fe543d16",
"sha256:a1327f280c824ff7885bdeef8578f74690e9079267c1c8bd7dc5cc5aa065ae52", "sha256:b03c2ae5d2f0fc05f9a2c0c997e1bc18c8229f392234e8a0194f202169ccd278",
"sha256:c1f25b252d2c87088abc8bbc4f1ecbf7c919e05508a7e8628e6875c40bc70923", "sha256:b6cd2203306b63e41acdf39aa93b86fb566049aeb6dc489b70e34bcd07adca74",
"sha256:c3a5cbc620e1e17009f30dd34cb0d85c987afd21c41a74352d1719be33380885", "sha256:b7ffe927ee6531c78f81aa17e684e2ff617daeba7f189f911065b2ea2d526dec",
"sha256:ce8613beaffc7c14f091497346ef117c1798c202b01153a8cc7b8e2ebaaf41c0", "sha256:b8cac287fafc4ad485b8a9b67d0ee80c66bf3574f655d3b97ef2e1082360faf1",
"sha256:d2a27aca5597c8a71abbe10209184e1a8e91c1fd470b5070a2ea60cafec35bcd", "sha256:ba334e6e4b1d92442b75ddacc615c5476d4ad55cc29b15d590cc6b86efa487e2",
"sha256:dad9c385ba8ee025bb0d856714f71d7840020fe176ae0229de618f14dae7a6e2", "sha256:ba3e4a42397c25b7ff88cdec6e2a16c2be18720f317506ee25210f6d31925f9c",
"sha256:db4b65b02f59035037fde0998974d84244a64c3265bdef32a827ab9b63d61b18", "sha256:c41fb5e6a5fe9ebcd58ca3abfeb51dffb5d83d6775405305bfa8715b76521922",
"sha256:e09469a2cec88fb7b078e16d4adec594414397e8879a4341c6ace96013463d5b", "sha256:cd2030f6650c089aeb304cf093f3244d34745ce0cfcc39f20c6fbfe030102e2a",
"sha256:e53dc41cda40b248ebc40b83b31516487f7db95ab8ceac1f042626bc43a2f992", "sha256:cd65d75953847815962c84a4654a84850b2bb4aed3f26fadcc1c13892e1e29f6",
"sha256:f1e85a178384bf19e36779d91ff35c7617c885da487d689b05c1366f9933ad74", "sha256:e4985a790f921508f36f81831817cbc03b102d643b5fcb81cd33df3fa291a1a1",
"sha256:f47be41843200f7faec0683ad751e5ef11b9a56a220d57f300376cd8aba81660", "sha256:e807b3188f9eb0eaa7bbb579b462c5ace579f1cedb28107ce8b48a9f7ad3679e",
"sha256:fb0cef872d8193e487fc6bdb08559c3aa41b659a7d9be48b2e10747f47863925", "sha256:f12764b8fffc7a123f641d7d049d382b73f96a34117e0b637b80643169cec8ac",
"sha256:ffc73996c4fca3d2b6c1c8c12bfd3ad00def8621da24f547626bf06441400449" "sha256:f8837fe1d6ac4a8052a9a8ddab256bc006242696f03368a4009be7ee3075cdb7"
], ],
"index": "pypi",
"markers": "python_version >= '3.7'", "markers": "python_version >= '3.7'",
"version": "==42.0.4" "version": "==42.0.5"
}, },
"dateparser": { "dateparser": {
"hashes": [ "hashes": [
@ -463,12 +462,12 @@
}, },
"django": { "django": {
"hashes": [ "hashes": [
"sha256:a2d4c4d4ea0b6f0895acde632071aff6400bfc331228fc978b05452a0ff3e9f1", "sha256:6e6ff3db2d8dd0c986b4eec8554c8e4f919b5c1ff62a5b4390c17aff2ed6e5c4",
"sha256:b1260ed381b10a11753c73444408e19869f3241fc45c985cd55a30177c789d13" "sha256:ddc24a0a8280a0430baa37aff11f28574720af05888c62b7cfe71d219f4599d3"
], ],
"index": "pypi", "index": "pypi",
"markers": "python_version >= '3.8'", "markers": "python_version >= '3.8'",
"version": "==4.2.10" "version": "==4.2.11"
}, },
"django-allauth": { "django-allauth": {
"hashes": [ "hashes": [
@ -755,11 +754,11 @@
}, },
"httpcore": { "httpcore": {
"hashes": [ "hashes": [
"sha256:096cc05bca73b8e459a1fc3dcf585148f63e534eae4339559c9b8a8d6399acc7", "sha256:ac418c1db41bade2ad53ae2f3834a3a0f5ae76b56cf5aa497d2d033384fc7d73",
"sha256:9fc092e4799b26174648e54b74ed5f683132a464e95643b226e00c2ed2fa6535" "sha256:cb2839ccfcba0d2d3c1131d3c3e26dfc327326fbe7a5dc0dbfe9f6c9151bb022"
], ],
"markers": "python_version >= '3.8'", "markers": "python_version >= '3.8'",
"version": "==1.0.2" "version": "==1.0.4"
}, },
"httptools": { "httptools": {
"hashes": [ "hashes": [
@ -807,11 +806,11 @@
"http2" "http2"
], ],
"hashes": [ "hashes": [
"sha256:451b55c30d5185ea6b23c2c793abf9bb237d2a7dfb901ced6ff69ad37ec1dfaf", "sha256:71d5465162c13681bff01ad59b2cc68dd838ea1f10e51574bac27103f00c91a5",
"sha256:8915f5a3627c4d47b73e8202457cb28f1266982d1159bd5779d86a80c0eab1cd" "sha256:a0cb88a46f32dc874e04ee956e4c2764aba2aa228f650b06788ba6bda2962ab5"
], ],
"markers": "python_version >= '3.9'", "markers": "python_version >= '3.9'",
"version": "==0.26.0" "version": "==0.27.0"
}, },
"humanize": { "humanize": {
"hashes": [ "hashes": [
@ -1007,65 +1006,66 @@
}, },
"msgpack": { "msgpack": {
"hashes": [ "hashes": [
"sha256:04ad6069c86e531682f9e1e71b71c1c3937d6014a7c3e9edd2aa81ad58842862", "sha256:00e073efcba9ea99db5acef3959efa45b52bc67b61b00823d2a1a6944bf45982",
"sha256:0bfdd914e55e0d2c9e1526de210f6fe8ffe9705f2b1dfcc4aecc92a4cb4b533d", "sha256:0726c282d188e204281ebd8de31724b7d749adebc086873a59efb8cf7ae27df3",
"sha256:1dc93e8e4653bdb5910aed79f11e165c85732067614f180f70534f056da97db3", "sha256:0ceea77719d45c839fd73abcb190b8390412a890df2f83fb8cf49b2a4b5c2f40",
"sha256:1e2d69948e4132813b8d1131f29f9101bc2c915f26089a6d632001a5c1349672", "sha256:114be227f5213ef8b215c22dde19532f5da9652e56e8ce969bf0a26d7c419fee",
"sha256:235a31ec7db685f5c82233bddf9858748b89b8119bf4538d514536c485c15fe0", "sha256:13577ec9e247f8741c84d06b9ece5f654920d8365a4b636ce0e44f15e07ec693",
"sha256:27dcd6f46a21c18fa5e5deed92a43d4554e3df8d8ca5a47bf0615d6a5f39dbc9", "sha256:1876b0b653a808fcd50123b953af170c535027bf1d053b59790eebb0aeb38950",
"sha256:28efb066cde83c479dfe5a48141a53bc7e5f13f785b92ddde336c716663039ee", "sha256:1ab0bbcd4d1f7b6991ee7c753655b481c50084294218de69365f8f1970d4c151",
"sha256:3476fae43db72bd11f29a5147ae2f3cb22e2f1a91d575ef130d2bf49afd21c46", "sha256:1cce488457370ffd1f953846f82323cb6b2ad2190987cd4d70b2713e17268d24",
"sha256:36e17c4592231a7dbd2ed09027823ab295d2791b3b1efb2aee874b10548b7524", "sha256:24f727df1e20b9876fa6e95f840a2a2651e34c0ad147676356f4bf5fbb0206ca",
"sha256:384d779f0d6f1b110eae74cb0659d9aa6ff35aaf547b3955abf2ab4c901c4819", "sha256:26ee97a8261e6e35885c2ecd2fd4a6d38252246f94a2aec23665a4e66d066305",
"sha256:38949d30b11ae5f95c3c91917ee7a6b239f5ec276f271f28638dec9156f82cfc", "sha256:3528807cbbb7f315bb81959d5961855e7ba52aa60a3097151cb21956fbc7502b",
"sha256:3967e4ad1aa9da62fd53e346ed17d7b2e922cba5ab93bdd46febcac39be636fc", "sha256:374a8e88ddab84b9ada695d255679fb99c53513c0a51778796fcf0944d6c789c",
"sha256:3e7bf4442b310ff154b7bb9d81eb2c016b7d597e364f97d72b1acc3817a0fdc1", "sha256:376081f471a2ef24828b83a641a02c575d6103a3ad7fd7dade5486cad10ea659",
"sha256:3f0c8c6dfa6605ab8ff0611995ee30d4f9fcff89966cf562733b4008a3d60d82", "sha256:3923a1778f7e5ef31865893fdca12a8d7dc03a44b33e2a5f3295416314c09f5d",
"sha256:484ae3240666ad34cfa31eea7b8c6cd2f1fdaae21d73ce2974211df099a95d81", "sha256:4916727e31c28be8beaf11cf117d6f6f188dcc36daae4e851fee88646f5b6b18",
"sha256:4a7b4f35de6a304b5533c238bee86b670b75b03d31b7797929caa7a624b5dda6", "sha256:493c5c5e44b06d6c9268ce21b302c9ca055c1fd3484c25ba41d34476c76ee746",
"sha256:4cb14ce54d9b857be9591ac364cb08dc2d6a5c4318c1182cb1d02274029d590d", "sha256:505fe3d03856ac7d215dbe005414bc28505d26f0c128906037e66d98c4e95868",
"sha256:4e71bc4416de195d6e9b4ee93ad3f2f6b2ce11d042b4d7a7ee00bbe0358bd0c2", "sha256:5845fdf5e5d5b78a49b826fcdc0eb2e2aa7191980e3d2cfd2a30303a74f212e2",
"sha256:52700dc63a4676669b341ba33520f4d6e43d3ca58d422e22ba66d1736b0a6e4c", "sha256:5c330eace3dd100bdb54b5653b966de7f51c26ec4a7d4e87132d9b4f738220ba",
"sha256:572efc93db7a4d27e404501975ca6d2d9775705c2d922390d878fcf768d92c87", "sha256:5dbf059fb4b7c240c873c1245ee112505be27497e90f7c6591261c7d3c3a8228",
"sha256:576eb384292b139821c41995523654ad82d1916da6a60cff129c715a6223ea84", "sha256:5e390971d082dba073c05dbd56322427d3280b7cc8b53484c9377adfbae67dc2",
"sha256:5b0bf0effb196ed76b7ad883848143427a73c355ae8e569fa538365064188b8e", "sha256:5fbb160554e319f7b22ecf530a80a3ff496d38e8e07ae763b9e82fadfe96f273",
"sha256:5b6ccc0c85916998d788b295765ea0e9cb9aac7e4a8ed71d12e7d8ac31c23c95", "sha256:64d0fcd436c5683fdd7c907eeae5e2cbb5eb872fafbc03a43609d7941840995c",
"sha256:5ed82f5a7af3697b1c4786053736f24a0efd0a1b8a130d4c7bfee4b9ded0f08f", "sha256:69284049d07fce531c17404fcba2bb1df472bc2dcdac642ae71a2d079d950653",
"sha256:6d4c80667de2e36970ebf74f42d1088cc9ee7ef5f4e8c35eee1b40eafd33ca5b", "sha256:6a0e76621f6e1f908ae52860bdcb58e1ca85231a9b0545e64509c931dd34275a",
"sha256:730076207cb816138cf1af7f7237b208340a2c5e749707457d70705715c93b93", "sha256:73ee792784d48aa338bba28063e19a27e8d989344f34aad14ea6e1b9bd83f596",
"sha256:7687e22a31e976a0e7fc99c2f4d11ca45eff652a81eb8c8085e9609298916dcf", "sha256:74398a4cf19de42e1498368c36eed45d9528f5fd0155241e82c4082b7e16cffd",
"sha256:822ea70dc4018c7e6223f13affd1c5c30c0f5c12ac1f96cd8e9949acddb48a61", "sha256:7938111ed1358f536daf311be244f34df7bf3cdedb3ed883787aca97778b28d8",
"sha256:84b0daf226913133f899ea9b30618722d45feffa67e4fe867b0b5ae83a34060c", "sha256:82d92c773fbc6942a7a8b520d22c11cfc8fd83bba86116bfcf962c2f5c2ecdaa",
"sha256:85765fdf4b27eb5086f05ac0491090fc76f4f2b28e09d9350c31aac25a5aaff8", "sha256:83b5c044f3eff2a6534768ccfd50425939e7a8b5cf9a7261c385de1e20dcfc85",
"sha256:8dd178c4c80706546702c59529ffc005681bd6dc2ea234c450661b205445a34d", "sha256:8db8e423192303ed77cff4dce3a4b88dbfaf43979d280181558af5e2c3c71afc",
"sha256:8f5b234f567cf76ee489502ceb7165c2a5cecec081db2b37e35332b537f8157c", "sha256:9517004e21664f2b5a5fd6333b0731b9cf0817403a941b393d89a2f1dc2bd836",
"sha256:98bbd754a422a0b123c66a4c341de0474cad4a5c10c164ceed6ea090f3563db4", "sha256:95c02b0e27e706e48d0e5426d1710ca78e0f0628d6e89d5b5a5b91a5f12274f3",
"sha256:993584fc821c58d5993521bfdcd31a4adf025c7d745bbd4d12ccfecf695af5ba", "sha256:99881222f4a8c2f641f25703963a5cefb076adffd959e0558dc9f803a52d6a58",
"sha256:a40821a89dc373d6427e2b44b572efc36a2778d3f543299e2f24eb1a5de65415", "sha256:9ee32dcb8e531adae1f1ca568822e9b3a738369b3b686d1477cbc643c4a9c128",
"sha256:b291f0ee7961a597cbbcc77709374087fa2a9afe7bdb6a40dbbd9b127e79afee", "sha256:a22e47578b30a3e199ab067a4d43d790249b3c0587d9a771921f86250c8435db",
"sha256:b573a43ef7c368ba4ea06050a957c2a7550f729c31f11dd616d2ac4aba99888d", "sha256:b5505774ea2a73a86ea176e8a9a4a7c8bf5d521050f0f6f8426afe798689243f",
"sha256:b610ff0f24e9f11c9ae653c67ff8cc03c075131401b3e5ef4b82570d1728f8a9", "sha256:bd739c9251d01e0279ce729e37b39d49a08c0420d3fee7f2a4968c0576678f77",
"sha256:bdf38ba2d393c7911ae989c3bbba510ebbcdf4ecbdbfec36272abe350c454075", "sha256:d16a786905034e7e34098634b184a7d81f91d4c3d246edc6bd7aefb2fd8ea6ad",
"sha256:bfef2bb6ef068827bbd021017a107194956918ab43ce4d6dc945ffa13efbc25f", "sha256:d3420522057ebab1728b21ad473aa950026d07cb09da41103f8e597dfbfaeb13",
"sha256:cab3db8bab4b7e635c1c97270d7a4b2a90c070b33cbc00c99ef3f9be03d3e1f7", "sha256:d56fd9f1f1cdc8227d7b7918f55091349741904d9520c65f0139a9755952c9e8",
"sha256:cb70766519500281815dfd7a87d3a178acf7ce95390544b8c90587d76b227681", "sha256:d661dc4785affa9d0edfdd1e59ec056a58b3dbb9f196fa43587f3ddac654ac7b",
"sha256:cca1b62fe70d761a282496b96a5e51c44c213e410a964bdffe0928e611368329", "sha256:dfe1f0f0ed5785c187144c46a292b8c34c1295c01da12e10ccddfc16def4448a",
"sha256:ccf9a39706b604d884d2cb1e27fe973bc55f2890c52f38df742bc1d79ab9f5e1", "sha256:e1dd7839443592d00e96db831eddb4111a2a81a46b028f0facd60a09ebbdd543",
"sha256:dc43f1ec66eb8440567186ae2f8c447d91e0372d793dfe8c222aec857b81a8cf", "sha256:e2872993e209f7ed04d963e4b4fbae72d034844ec66bc4ca403329db2074377b",
"sha256:dd632777ff3beaaf629f1ab4396caf7ba0bdd075d948a69460d13d44357aca4c", "sha256:e2f879ab92ce502a1e65fce390eab619774dda6a6ff719718069ac94084098ce",
"sha256:e45ae4927759289c30ccba8d9fdce62bb414977ba158286b5ddaf8df2cddb5c5", "sha256:e3aa7e51d738e0ec0afbed661261513b38b3014754c9459508399baf14ae0c9d",
"sha256:e50ebce52f41370707f1e21a59514e3375e3edd6e1832f5e5235237db933c98b", "sha256:e532dbd6ddfe13946de050d7474e3f5fb6ec774fbb1a188aaf469b08cf04189a",
"sha256:ebbbba226f0a108a7366bf4b59bf0f30a12fd5e75100c630267d94d7f0ad20e5", "sha256:e6b7842518a63a9f17107eb176320960ec095a8ee3b4420b5f688e24bf50c53c",
"sha256:ec79ff6159dffcc30853b2ad612ed572af86c92b5168aa3fc01a67b0fa40665e", "sha256:e75753aeda0ddc4c28dce4c32ba2f6ec30b1b02f6c0b14e547841ba5b24f753f",
"sha256:f0936e08e0003f66bfd97e74ee530427707297b0d0361247e9b4f59ab78ddc8b", "sha256:eadb9f826c138e6cf3c49d6f8de88225a3c0ab181a9b4ba792e006e5292d150e",
"sha256:f26a07a6e877c76a88e3cecac8531908d980d3d5067ff69213653649ec0f60ad", "sha256:ed59dd52075f8fc91da6053b12e8c89e37aa043f8986efd89e61fae69dc1b011",
"sha256:f64e376cd20d3f030190e8c32e1c64582eba56ac6dc7d5b0b49a9d44021b52fd", "sha256:ef254a06bcea461e65ff0373d8a0dd1ed3aa004af48839f002a0c994a6f72d04",
"sha256:f6ffbc252eb0d229aeb2f9ad051200668fc3a9aaa8994e49f0cb2ffe2b7867e7", "sha256:f3709997b228685fe53e8c433e2df9f0cdb5f4542bd5114ed17ac3c0129b0480",
"sha256:f9a7c509542db4eceed3dcf21ee5267ab565a83555c9b88a8109dcecc4709002", "sha256:f51bab98d52739c50c56658cc303f190785f9a2cd97b823357e7aeae54c8f68a",
"sha256:ff1d0899f104f3921d94579a5638847f783c9b04f2d5f229392ca77fba5b82fc" "sha256:f9904e24646570539a8950400602d66d2b2c492b9010ea7e965025cb71d0c86d",
"sha256:f9af38a89b6a5c04b7d18c492c8ccf2aee7048aff1ce8437c4683bb5a1df893d"
], ],
"markers": "python_version >= '3.8'", "markers": "python_version >= '3.8'",
"version": "==1.0.7" "version": "==1.0.8"
}, },
"mysqlclient": { "mysqlclient": {
"hashes": [ "hashes": [
@ -1186,41 +1186,44 @@
}, },
"pikepdf": { "pikepdf": {
"hashes": [ "hashes": [
"sha256:0586b9aec4f78a4f6ab2497b47639744ce47b7ad9b96e149194b7ada2d34b848", "sha256:0cad56309f0ca9702682be2bb2403e34d02edbb08a669c5a655622d40bb8ca0a",
"sha256:0718f822dd75574e8c015983d870d8336fe7e62d20f1f726e86fc3156d62c921", "sha256:0f342b02f03bc18e0fd24be90be8d54801d1cff8178be214620d8220e6a17e59",
"sha256:0c7b1404f9c7714377c3df7c756cff03ac237b2bf3273c1193d0a1849e1b83f0", "sha256:157ecf18c43a8448bfbe4736d3e337f270e7deabf024497cbd087cccf06daf8f",
"sha256:119c18de07040cbbdf9f06a3a06fe89c8f321d3386fb96b2f68455bb2f7cab46", "sha256:22ce91e8fbb3ab7eb066a560db407215abe1f486a07843ff8e84e1585236aa85",
"sha256:19867ff1c4db1da7a74aad0fc9455a6ca1b7a45ee460c1e7fce1dd1962c0251d", "sha256:26378e4a9d61bf00d839b637957e16d187f1207c23fa2ed1e2873f6d2d0e9d58",
"sha256:238cce9d9bbda606eb7050096251753b32f84c758633212d62ed5f1ecf8f5091", "sha256:2a4c22fe09b39c58fc0669794e4ea27fabe1470db2fdffd62ed9fbedf9bb4ad0",
"sha256:32b033345d0979e8a0dfd7c3af76013b3a3632da50d419545d012535f0cfd3e1", "sha256:2bd4435bfc757c515dc6932900d5d2cce7d911d034eff797440c3d78fa36fe46",
"sha256:4802e48046a059b26949b16c53adb9ce8c0137a265cb8b1083957a3fadc0418b", "sha256:32ad5105e84a8b5798bb41a05fb4dee033f10e31a074d6b1d095a5ca5985d05b",
"sha256:480c69fabe958b56843dc38953c8568a4da2cc819d88b3b1080a470c114e622a", "sha256:32c4ba17e4792d2505ae21bd27d99b728eba2c6ad16a1a9cfc0b77b52f9b413d",
"sha256:485760bb22bcf25f338ab7dc003e6b5f522783f5f00bab0079ea3383152b2d98", "sha256:3344d99381ea75cb6b5cff5a7c6544f5d3bfac6ea784b4c493467b3577449ad1",
"sha256:54eeaeaa4bdca7320511f00f403ea1ae33ef2e006fd5b5c2b92d92ab7eb77641", "sha256:3bbd79c7cd6630361d83e75132aeaf3a64ceb837f82870bafdc210a31e3d917a",
"sha256:5f09c4263bfcad2ba9836b578c42218d6d8cf751331f5cc9639b68d60ab7a05d", "sha256:3c24e413b7e9cc16d219c7892fed067894fc296c4408222f2fe015d04256bc10",
"sha256:711064313c00a5dccc5306877844f4e12b33355a9f496d870158d7f906330fd2", "sha256:44cb27be26883d604193c49219185d2488cb2c270e61f785091bcebd887785d3",
"sha256:7209d3cb07d238ef81d438644363a19974697ab6c0d3360292c3c1cb4f2915e6", "sha256:4c70b6f864f8b2a8cc248c395e7420a338fffd39a5fbd72f4497edc80958100e",
"sha256:73fdaa359262feda7f62aa87bfb97c66f9a30d80bcb592d2c4c5e7714f2ab30e", "sha256:55e0c6f5fdd2cb5ca9dc0ada5064a02404bc03ff504b70a0686aedd74ee41d41",
"sha256:744903f2a34f8feeb781e3e5ca86b3ea60aae92246c9085392666eaa878ca7fc", "sha256:61fd4c2204836ae04dbc1b1439a47083fe4b31c6e29622cc5b84f57426dd4a57",
"sha256:75de10328d6558e7821aadabb63cadbb40eec3cdc407b01ef990760343ad4305", "sha256:6504db23601710ab7e80b5ade84d97440532c156ce0f8bdbfafcbb0a806018df",
"sha256:78f0d79548e56636deb4749fb3fa73458dcdf86e634a1ce1b6e5155f97c4155f", "sha256:65d89d269c4e43e00d4de1db7313cb6813a0c88ce89a4b60d84c414cb8cd17d2",
"sha256:7fd20e259c2192ca1b718d8f78bbeff1b37340b6a2c9764068c2112449895d85", "sha256:6ac4d03a0f7f7b982169af828ab702914a5f6fd947ead1cb400c9091c384c2c2",
"sha256:8bc82d766f09fafd986d9410c7d53345b3e2963fe3253a9ba09b50812b60e9c4", "sha256:76cc1941d0df4f70e41db5548d557be9131161bd29833b86158e7ba725e592c1",
"sha256:93ba53c05d511a5e8a1d5ccf00b71a3c89e9285ec92e8f9a86995b022e667346", "sha256:80eb3fa37bc18d8481bc41840cfb7dcf2aa588d648205cc4b539796d7bd5a543",
"sha256:98c04283649b09c8c8d5f18389787dfe48224a3b99758d984ace9c1f1940dd33", "sha256:88f354c4495dff95f99af284d00af4aa12060bc5db334946deb969ee99a4f9a6",
"sha256:9b2f8f518d7d4ee374deccc27cb65494c33cbc600885253e4454ab31dc466a04", "sha256:8cc9bbcd13d99a3cd59a58c4f40f601124e62ada32914be89d4aa96b36475779",
"sha256:9f2cff2ed901b5dd6ea704a24e88dfb9e3303019473ffc224d4d72a294e7c43b", "sha256:99e99d638466258e97de4cac07ab7f624fbf9b06b52dda23829e2aea07e449f3",
"sha256:b2d9957e188785a5913deedf6f12dc7eafb5764a3cc3996e044f0e9044b28889", "sha256:9d3741bc7f325a445045894379a6450535eb906ccdb2875671318832a98f4792",
"sha256:b879e240b4d4b34822a0390fe4f30e6388d0f776e12376f043dc102fe11e9499", "sha256:a3aab6e35819a8242ee81c4e62d1b39cfa085ace533ab921f45865fb02bed3c8",
"sha256:b8a91610cf07d29ef06992cdfae902fecef3712100f25796c553e1e3a0553a33", "sha256:a3e1454a8346a98b8296a7e91cf23cb8f0947bcc1ff50ec6c086ec31225cfb46",
"sha256:c1e13a4f4c7a596ad4157b93453de4dfcb7abc14c58ce9ee110b0e2ee7a0813e", "sha256:a54f2e40054ae8d6931552b41f721909b6c2efbc9f04e09ba549f65374919d57",
"sha256:c60e8b28b9e96137adc79eade42a764ff508291270e99ac84290808a85a12d94", "sha256:bde6a1ad51b82edd2e627e50a83caf3b8e280cfdfa4f35ffc740eaad76f66772",
"sha256:d9ee83f20559c63413fed0059ea159a11116af098f976b8480e82018d954bba9", "sha256:bf07f10682d1636a55230c01e1a7f8d6ab3ad189df1341fd5554fc978c219f0e",
"sha256:ea368bb11c1b57b773f349e8a1283617a66227a628f94023bb4a16ed53a1adf3", "sha256:c40f5ab91151fe29020d0ad9ceabe194fea35276276c6ee7ee73038d55b2af77",
"sha256:fc8dcbdb7ba95ef1e8318eb2ebd2869d53e9e9f8b81d9d7b7b5f9acbc4e6557b" "sha256:c8a21a0f96e0071651de90bf5c751513c88b6d77bc5a097a3eefa3ad5283d5ee",
"sha256:e83d4c4d1a537a77537db471f718a18901e58830638878e329848f2c796717af",
"sha256:ee3f79165fea8e443609925b457fc1eec438e7cac317a57e5c1d7409e2db92a3",
"sha256:f933dc6343c8a2aa01a8ed61f9961428e1958ada13c828418010c0b3e763663c"
], ],
"markers": "python_version >= '3.8'", "markers": "python_version >= '3.8'",
"version": "==8.12.0" "version": "==8.13.0"
}, },
"pillow": { "pillow": {
"hashes": [ "hashes": [
@ -1314,11 +1317,11 @@
}, },
"prometheus-client": { "prometheus-client": {
"hashes": [ "hashes": [
"sha256:4585b0d1223148c27a225b10dbec5ae9bc4c81a99a3fa80774fa6209935324e1", "sha256:287629d00b147a32dcb2be0b9df905da599b2d82f80377083ec8463309a4bb89",
"sha256:c88b1e6ecf6b41cd8fb5731c7ae919bf66df6ec6fafa555cd6c0e16ca169ae92" "sha256:cde524a85bce83ca359cc837f28b8c0db5cac7aa653a588fd7e84ba061c329e7"
], ],
"markers": "python_version >= '3.8'", "markers": "python_version >= '3.8'",
"version": "==0.19.0" "version": "==0.20.0"
}, },
"prompt-toolkit": { "prompt-toolkit": {
"hashes": [ "hashes": [
@ -1376,12 +1379,12 @@
}, },
"python-dateutil": { "python-dateutil": {
"hashes": [ "hashes": [
"sha256:0123cacc1627ae19ddf3c27a5de5bd67ee4586fbdd6440d9748f8abb483d3e86", "sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3",
"sha256:961d03dc3453ebbc59dbdea9e4e11c5651520a876d0f4db161e8674aae935da9" "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427"
], ],
"index": "pypi", "index": "pypi",
"markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'",
"version": "==2.8.2" "version": "==2.9.0.post0"
}, },
"python-dotenv": { "python-dotenv": {
"hashes": [ "hashes": [
@ -1402,12 +1405,12 @@
}, },
"python-ipware": { "python-ipware": {
"hashes": [ "hashes": [
"sha256:1992920ef553165dfa35e6ea5a90762f64f3b943cc22ee6b4ec02e2c86d31178", "sha256:3a94fd073b93e12b13617e291f13eda3495d3ba68b580e3e30174ea84ac63041",
"sha256:9ba4805152ebb85ad5b53797185cd1ce6231e1db60155834f326c8cd61e8af34" "sha256:86e30cc3af62cec42284dedd49c8a14e436e73c96433c8645ec0b476ff4ad7ec"
], ],
"index": "pypi", "index": "pypi",
"markers": "python_version >= '3.7'", "markers": "python_version >= '3.7'",
"version": "==2.0.1" "version": "==2.0.2"
}, },
"python-magic": { "python-magic": {
"hashes": [ "hashes": [
@ -1599,11 +1602,11 @@
"hiredis" "hiredis"
], ],
"hashes": [ "hashes": [
"sha256:0dab495cd5753069d3bc650a0dde8a8f9edde16fc5691b689a566eda58100d0f", "sha256:3f82cc80d350e93042c8e6e7a5d0596e4dd68715babffba79492733e1f367037",
"sha256:ed4802971884ae19d640775ba3b03aa2e7bd5e8fb8dfaed2decce4d0fc48391f" "sha256:4caa8e1fcb6f3c0ef28dba99535101d80934b7d4cd541bbb47f4a3826ee472d1"
], ],
"markers": "python_version >= '3.7'", "markers": "python_version >= '3.7'",
"version": "==5.0.1" "version": "==5.0.2"
}, },
"regex": { "regex": {
"hashes": [ "hashes": [
@ -1706,11 +1709,11 @@
}, },
"reportlab": { "reportlab": {
"hashes": [ "hashes": [
"sha256:c9656216321897486e323be138f7aea67851cedc116b8cc35f8ec7f8cc763538", "sha256:28a40d5000afbd8ccae15a47f7abe2841768461354bede1a9d42841132997c98",
"sha256:f32bff66a0fda234202e1e33eaf77f25008871a61cb01cd91584a521a04c0047" "sha256:3a99faf412691159c068b3ff01c15307ce2fd2cf6b860199434874e002040a84"
], ],
"markers": "python_version >= '3.7' and python_version < '4'", "markers": "python_version >= '3.7' and python_version < '4'",
"version": "==4.0.9" "version": "==4.1.0"
}, },
"requests": { "requests": {
"hashes": [ "hashes": [
@ -1730,11 +1733,11 @@
}, },
"rich": { "rich": {
"hashes": [ "hashes": [
"sha256:5cb5123b5cf9ee70584244246816e9114227e0b98ad9176eede6ad54bf5403fa", "sha256:4edbae314f59eb482f54e9e30bf00d33350aaa94f4bfcd4e9e3110e64d0d7222",
"sha256:6da14c108c4866ee9520bbffa71f6fe3962e193b7da68720583850cd4548e235" "sha256:9be308cb1fe2f1f57d67ce99e95af38a1e2bc71ad9813b0e247cf7ffbcc3a432"
], ],
"markers": "python_full_version >= '3.7.0'", "markers": "python_full_version >= '3.7.0'",
"version": "==13.7.0" "version": "==13.7.1"
}, },
"scikit-learn": { "scikit-learn": {
"hashes": [ "hashes": [
@ -1900,11 +1903,11 @@
}, },
"sniffio": { "sniffio": {
"hashes": [ "hashes": [
"sha256:e60305c5e5d314f5389259b7f22aaa33d8f7dee49763119234af3755c55b9101", "sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2",
"sha256:eecefdce1e5bbfb7ad2eeaabf7c1eeb404d7757c379bd1f7e5cce9d8bf425384" "sha256:f4324edc670a0f49750a81b895f35c3adb843cca46f0530f79fc1babb23789dc"
], ],
"markers": "python_version >= '3.7'", "markers": "python_version >= '3.7'",
"version": "==1.3.0" "version": "==1.3.1"
}, },
"sqlparse": { "sqlparse": {
"hashes": [ "hashes": [
@ -1959,19 +1962,19 @@
}, },
"typing-extensions": { "typing-extensions": {
"hashes": [ "hashes": [
"sha256:23478f88c37f27d76ac8aee6c905017a143b0b1b886c3c9f66bc2fd94f9f5783", "sha256:69b1a937c3a517342112fb4c6df7e72fc39a38e7891a5730ed4985b5214b5475",
"sha256:af72aea155e91adfc61c3ae9e0e342dbc0cba726d6cba4b6c72c1f34e47291cd" "sha256:b0abd7c89e8fb96f98db18d86106ff1d90ab692004eb746cf6eda2682f91b3cb"
], ],
"markers": "python_version < '3.11'", "markers": "python_version < '3.11'",
"version": "==4.9.0" "version": "==4.10.0"
}, },
"tzdata": { "tzdata": {
"hashes": [ "hashes": [
"sha256:aa3ace4329eeacda5b7beb7ea08ece826c28d761cda36e747cfbf97996d39bf3", "sha256:2674120f8d891909751c38abcdfd386ac0a5a1127954fbc332af6b5ceae07efd",
"sha256:dd54c94f294765522c77399649b4fefd95522479a664a0cec87f41bebc6148c9" "sha256:9068bc196136463f5245e51efda838afa15aaeca9903f49050dfa2679db4d252"
], ],
"markers": "python_version >= '2'", "markers": "python_version >= '2'",
"version": "==2023.4" "version": "==2024.1"
}, },
"tzlocal": { "tzlocal": {
"hashes": [ "hashes": [
@ -1983,11 +1986,11 @@
}, },
"urllib3": { "urllib3": {
"hashes": [ "hashes": [
"sha256:051d961ad0c62a94e50ecf1af379c3aba230c66c710493493560c0c223c49f20", "sha256:450b20ec296a467077128bff42b73080516e71b56ff59a60a02bef2232c4fa9d",
"sha256:ce3711610ddce217e6d113a2732fafad960a03fd0318c91faa79481e35c11224" "sha256:d0570876c61ab9e520d776c38acbbb5b05a776d3f9ff98a5c8fd5162a444cf19"
], ],
"markers": "python_version >= '3.8'", "markers": "python_version >= '3.8'",
"version": "==2.2.0" "version": "==2.2.1"
}, },
"uvicorn": { "uvicorn": {
"extras": [ "extras": [
@ -2705,61 +2708,61 @@
"toml" "toml"
], ],
"hashes": [ "hashes": [
"sha256:0193657651f5399d433c92f8ae264aff31fc1d066deee4b831549526433f3f61", "sha256:0209a6369ccce576b43bb227dc8322d8ef9e323d089c6f3f26a597b09cb4d2aa",
"sha256:02f2edb575d62172aa28fe00efe821ae31f25dc3d589055b3fb64d51e52e4ab1", "sha256:062b0a75d9261e2f9c6d071753f7eef0fc9caf3a2c82d36d76667ba7b6470003",
"sha256:0491275c3b9971cdbd28a4595c2cb5838f08036bca31765bad5e17edf900b2c7", "sha256:0842571634f39016a6c03e9d4aba502be652a6e4455fadb73cd3a3a49173e38f",
"sha256:077d366e724f24fc02dbfe9d946534357fda71af9764ff99d73c3c596001bbd7", "sha256:16bae383a9cc5abab9bb05c10a3e5a52e0a788325dc9ba8499e821885928968c",
"sha256:10e88e7f41e6197ea0429ae18f21ff521d4f4490aa33048f6c6f94c6045a6a75", "sha256:18c7320695c949de11a351742ee001849912fd57e62a706d83dfc1581897fa2e",
"sha256:18e961aa13b6d47f758cc5879383d27b5b3f3dcd9ce8cdbfdc2571fe86feb4dd", "sha256:18d90523ce7553dd0b7e23cbb28865db23cddfd683a38fb224115f7826de78d0",
"sha256:1a78b656a4d12b0490ca72651fe4d9f5e07e3c6461063a9b6265ee45eb2bdd35", "sha256:1bf25fbca0c8d121a3e92a2a0555c7e5bc981aee5c3fdaf4bb7809f410f696b9",
"sha256:1ed4b95480952b1a26d863e546fa5094564aa0065e1e5f0d4d0041f293251d04", "sha256:276f6077a5c61447a48d133ed13e759c09e62aff0dc84274a68dc18660104d52",
"sha256:23b27b8a698e749b61809fb637eb98ebf0e505710ec46a8aa6f1be7dc0dc43a6", "sha256:280459f0a03cecbe8800786cdc23067a8fc64c0bd51dc614008d9c36e1659d7e",
"sha256:23f5881362dcb0e1a92b84b3c2809bdc90db892332daab81ad8f642d8ed55042", "sha256:28ca2098939eabab044ad68850aac8f8db6bf0b29bc7f2887d05889b17346454",
"sha256:32a8d985462e37cfdab611a6f95b09d7c091d07668fdc26e47a725ee575fe166", "sha256:2c854ce44e1ee31bda4e318af1dbcfc929026d12c5ed030095ad98197eeeaed0",
"sha256:3468cc8720402af37b6c6e7e2a9cdb9f6c16c728638a2ebc768ba1ef6f26c3a1", "sha256:35eb581efdacf7b7422af677b92170da4ef34500467381e805944a3201df2079",
"sha256:379d4c7abad5afbe9d88cc31ea8ca262296480a86af945b08214eb1a556a3e4d", "sha256:37389611ba54fd6d278fde86eb2c013c8e50232e38f5c68235d09d0a3f8aa352",
"sha256:3cacfaefe6089d477264001f90f55b7881ba615953414999c46cc9713ff93c8c", "sha256:3b253094dbe1b431d3a4ac2f053b6d7ede2664ac559705a704f621742e034f1f",
"sha256:3e3424c554391dc9ef4a92ad28665756566a28fecf47308f91841f6c49288e66", "sha256:3b2eccb883368f9e972e216c7b4c7c06cabda925b5f06dde0650281cb7666a30",
"sha256:46342fed0fff72efcda77040b14728049200cbba1279e0bf1188f1f2078c1d70", "sha256:451f433ad901b3bb00184d83fd83d135fb682d780b38af7944c9faeecb1e0bfe",
"sha256:536d609c6963c50055bab766d9951b6c394759190d03311f3e9fcf194ca909e1", "sha256:489763b2d037b164846ebac0cbd368b8a4ca56385c4090807ff9fad817de4113",
"sha256:5d6850e6e36e332d5511a48a251790ddc545e16e8beaf046c03985c69ccb2676", "sha256:4af154d617c875b52651dd8dd17a31270c495082f3d55f6128e7629658d63765",
"sha256:6008adeca04a445ea6ef31b2cbaf1d01d02986047606f7da266629afee982630", "sha256:506edb1dd49e13a2d4cac6a5173317b82a23c9d6e8df63efb4f0380de0fbccbc",
"sha256:64e723ca82a84053dd7bfcc986bdb34af8d9da83c521c19d6b472bc6880e191a", "sha256:6679060424faa9c11808598504c3ab472de4531c571ab2befa32f4971835788e",
"sha256:6b00e21f86598b6330f0019b40fb397e705135040dbedc2ca9a93c7441178e74", "sha256:69b9f6f66c0af29642e73a520b6fed25ff9fd69a25975ebe6acb297234eda501",
"sha256:6d224f0c4c9c98290a6990259073f496fcec1b5cc613eecbd22786d398ded3ad", "sha256:6c00cdc8fa4e50e1cc1f941a7f2e3e0f26cb2a1233c9696f26963ff58445bac7",
"sha256:6dceb61d40cbfcf45f51e59933c784a50846dc03211054bd76b421a713dcdf19", "sha256:6c0cdedd3500e0511eac1517bf560149764b7d8e65cb800d8bf1c63ebf39edd2",
"sha256:7ac8f8eb153724f84885a1374999b7e45734bf93a87d8df1e7ce2146860edef6", "sha256:708a3369dcf055c00ddeeaa2b20f0dd1ce664eeabde6623e516c5228b753654f",
"sha256:85ccc5fa54c2ed64bd91ed3b4a627b9cce04646a659512a051fa82a92c04a448", "sha256:718187eeb9849fc6cc23e0d9b092bc2348821c5e1a901c9f8975df0bc785bfd4",
"sha256:869b5046d41abfea3e381dd143407b0d29b8282a904a19cb908fa24d090cc018", "sha256:767b35c3a246bcb55b8044fd3a43b8cd553dd1f9f2c1eeb87a302b1f8daa0524",
"sha256:8bdb0285a0202888d19ec6b6d23d5990410decb932b709f2b0dfe216d031d218", "sha256:77fbfc5720cceac9c200054b9fab50cb2a7d79660609200ab83f5db96162d20c",
"sha256:8dfc5e195bbef80aabd81596ef52a1277ee7143fe419efc3c4d8ba2754671756", "sha256:7cbde573904625509a3f37b6fecea974e363460b556a627c60dc2f47e2fffa51",
"sha256:8e738a492b6221f8dcf281b67129510835461132b03024830ac0e554311a5c54", "sha256:8249b1c7334be8f8c3abcaaa996e1e4927b0e5a23b65f5bf6cfe3180d8ca7840",
"sha256:918440dea04521f499721c039863ef95433314b1db00ff826a02580c1f503e45", "sha256:8580b827d4746d47294c0e0b92854c85a92c2227927433998f0d3320ae8a71b6",
"sha256:9641e21670c68c7e57d2053ddf6c443e4f0a6e18e547e86af3fad0795414a628", "sha256:8640f1fde5e1b8e3439fe482cdc2b0bb6c329f4bb161927c28d2e8879c6029ee",
"sha256:9d2f9d4cc2a53b38cabc2d6d80f7f9b7e3da26b2f53d48f05876fef7956b6968", "sha256:9a9babb9466fe1da12417a4aed923e90124a534736de6201794a3aea9d98484e",
"sha256:a07f61fc452c43cd5328b392e52555f7d1952400a1ad09086c4a8addccbd138d", "sha256:a78ed23b08e8ab524551f52953a8a05d61c3a760781762aac49f8de6eede8c45",
"sha256:a3277f5fa7483c927fe3a7b017b39351610265308f5267ac6d4c2b64cc1d8d25", "sha256:abbbd8093c5229c72d4c2926afaee0e6e3140de69d5dcd918b2921f2f0c8baba",
"sha256:a4a3907011d39dbc3e37bdc5df0a8c93853c369039b59efa33a7b6669de04c60", "sha256:ae7f19afe0cce50039e2c782bff379c7e347cba335429678450b8fe81c4ef96d",
"sha256:aeb2c2688ed93b027eb0d26aa188ada34acb22dceea256d76390eea135083950", "sha256:b3ec74cfef2d985e145baae90d9b1b32f85e1741b04cd967aaf9cfa84c1334f3",
"sha256:b094116f0b6155e36a304ff912f89bbb5067157aff5f94060ff20bbabdc8da06", "sha256:b51bfc348925e92a9bd9b2e48dad13431b57011fd1038f08316e6bf1df107d10",
"sha256:b8ffb498a83d7e0305968289441914154fb0ef5d8b3157df02a90c6695978295", "sha256:b9a4a8dd3dcf4cbd3165737358e4d7dfbd9d59902ad11e3b15eebb6393b0446e",
"sha256:b9bb62fac84d5f2ff523304e59e5c439955fb3b7f44e3d7b2085184db74d733b", "sha256:ba3a8aaed13770e970b3df46980cb068d1c24af1a1968b7818b69af8c4347efb",
"sha256:c61f66d93d712f6e03369b6a7769233bfda880b12f417eefdd4f16d1deb2fc4c", "sha256:c0524de3ff096e15fcbfe8f056fdb4ea0bf497d584454f344d59fce069d3e6e9",
"sha256:ca6e61dc52f601d1d224526360cdeab0d0712ec104a2ce6cc5ccef6ed9a233bc", "sha256:c0a120238dd71c68484f02562f6d446d736adcc6ca0993712289b102705a9a3a",
"sha256:ca7b26a5e456a843b9b6683eada193fc1f65c761b3a473941efe5a291f604c74", "sha256:cbbe5e739d45a52f3200a771c6d2c7acf89eb2524890a4a3aa1a7fa0695d2a47",
"sha256:d12c923757de24e4e2110cf8832d83a886a4cf215c6e61ed506006872b43a6d1", "sha256:ce8c50520f57ec57aa21a63ea4f325c7b657386b3f02ccaedeccf9ebe27686e1",
"sha256:d17bbc946f52ca67adf72a5ee783cd7cd3477f8f8796f59b4974a9b59cacc9ee", "sha256:cf30900aa1ba595312ae41978b95e256e419d8a823af79ce670835409fc02ad3",
"sha256:dfd1e1b9f0898817babf840b77ce9fe655ecbe8b1b327983df485b30df8cc011", "sha256:d25b937a5d9ffa857d41be042b4238dd61db888533b53bc76dc082cb5a15e914",
"sha256:e0860a348bf7004c812c8368d1fc7f77fe8e4c095d661a579196a9533778e156", "sha256:d6cdecaedea1ea9e033d8adf6a0ab11107b49571bbb9737175444cea6eb72328",
"sha256:f2f5968608b1fe2a1d00d01ad1017ee27efd99b3437e08b83ded9b7af3f6f766", "sha256:dec9de46a33cf2dd87a5254af095a409ea3bf952d85ad339751e7de6d962cde6",
"sha256:f3771b23bb3675a06f5d885c3630b1d01ea6cac9e84a01aaf5508706dba546c5", "sha256:ebe7c9e67a2d15fa97b77ea6571ce5e1e1f6b0db71d1d5e96f8d2bf134303c1d",
"sha256:f68ef3660677e6624c8cace943e4765545f8191313a07288a53d3da188bd8581", "sha256:ee866acc0861caebb4f2ab79f0b94dbfbdbfadc19f82e6e9c93930f74e11d7a0",
"sha256:f86f368e1c7ce897bf2457b9eb61169a44e2ef797099fb5728482b8d69f3f016", "sha256:f6a09b360d67e589236a44f0c39218a8efba2593b6abdccc300a8862cffc2f94",
"sha256:f90515974b39f4dea2f27c0959688621b46d96d5a626cf9c53dbc653a895c05c", "sha256:fcc66e222cf4c719fe7722a403888b1f5e1682d1679bd780e2b26c18bb648cdc",
"sha256:fe558371c1bdf3b8fa03e097c523fb9645b8730399c14fe7721ee9c9e2a545d3" "sha256:fd6545d97c98a192c5ac995d21c894b581f1fd14cf389be90724d21808b657e2"
], ],
"markers": "python_version >= '3.8'", "markers": "python_version >= '3.8'",
"version": "==7.4.1" "version": "==7.4.3"
}, },
"cryptography": { "cryptography": {
"hashes": [ "hashes": [
@ -2842,17 +2845,18 @@
}, },
"faker": { "faker": {
"hashes": [ "hashes": [
"sha256:60e89e5c0b584e285a7db05eceba35011a241954afdab2853cb246c8a56700a2", "sha256:2456d674f40bd51eb3acbf85221277027822e529a90cc826453d9a25dff932b1",
"sha256:b7f76bb1b2ac4cdc54442d955e36e477c387000f31ce46887fb9722a041be60b" "sha256:ea6f784c40730de0f77067e49e78cdd590efb00bec3d33f577492262206c17fc"
], ],
"markers": "python_version >= '3.8'", "markers": "python_version >= '3.8'",
"version": "==23.1.0" "version": "==24.0.0"
}, },
"filelock": { "filelock": {
"hashes": [ "hashes": [
"sha256:521f5f56c50f8426f5e03ad3b281b490a87ef15bc6c526f168290f0c7148d44e", "sha256:521f5f56c50f8426f5e03ad3b281b490a87ef15bc6c526f168290f0c7148d44e",
"sha256:57dbda9b35157b05fb3e58ee91448612eb674172fab98ee235ccb0b5bee19a1c" "sha256:57dbda9b35157b05fb3e58ee91448612eb674172fab98ee235ccb0b5bee19a1c"
], ],
"index": "pypi",
"markers": "python_version >= '3.8'", "markers": "python_version >= '3.8'",
"version": "==3.13.1" "version": "==3.13.1"
}, },
@ -2887,7 +2891,7 @@
"sha256:71d5465162c13681bff01ad59b2cc68dd838ea1f10e51574bac27103f00c91a5", "sha256:71d5465162c13681bff01ad59b2cc68dd838ea1f10e51574bac27103f00c91a5",
"sha256:a0cb88a46f32dc874e04ee956e4c2764aba2aa228f650b06788ba6bda2962ab5" "sha256:a0cb88a46f32dc874e04ee956e4c2764aba2aa228f650b06788ba6bda2962ab5"
], ],
"markers": "python_version >= '3.8'", "markers": "python_version >= '3.9'",
"version": "==0.27.0" "version": "==0.27.0"
}, },
"hyperlink": { "hyperlink": {
@ -3052,12 +3056,12 @@
}, },
"mkdocs-material": { "mkdocs-material": {
"hashes": [ "hashes": [
"sha256:788ee0f3e036dca2dc20298d65e480297d348a44c9d7b2ee05c5262983e66072", "sha256:5f69cef6a8aaa4050b812f72b1094fda3d079b9a51cf27a247244c03ec455e97",
"sha256:7af7f8af0dea16175558f3fb9245d26c83a17199baa5f157755e63d7437bf971" "sha256:d6f0c269f015e48c76291cdc79efb70f7b33bbbf42d649cfe475522ebee61b1f"
], ],
"index": "pypi", "index": "pypi",
"markers": "python_version >= '3.8'", "markers": "python_version >= '3.8'",
"version": "==9.5.11" "version": "==9.5.12"
}, },
"mkdocs-material-extensions": { "mkdocs-material-extensions": {
"hashes": [ "hashes": [
@ -3365,11 +3369,12 @@
}, },
"python-dateutil": { "python-dateutil": {
"hashes": [ "hashes": [
"sha256:0123cacc1627ae19ddf3c27a5de5bd67ee4586fbdd6440d9748f8abb483d3e86", "sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3",
"sha256:961d03dc3453ebbc59dbdea9e4e11c5651520a876d0f4db161e8674aae935da9" "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427"
], ],
"index": "pypi",
"markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'",
"version": "==2.8.2" "version": "==2.9.0.post0"
}, },
"pywavelets": { "pywavelets": {
"hashes": [ "hashes": [
@ -3456,7 +3461,6 @@
"sha256:fd1592b3fdf65fff2ad0004b5e363300ef59ced41c2e6b3a99d4089fa8c5435d", "sha256:fd1592b3fdf65fff2ad0004b5e363300ef59ced41c2e6b3a99d4089fa8c5435d",
"sha256:fd66fc5d0da6d9815ba2cebeb4205f95818ff4b79c3ebe268e75d961704af52f" "sha256:fd66fc5d0da6d9815ba2cebeb4205f95818ff4b79c3ebe268e75d961704af52f"
], ],
"markers": "python_version >= '3.6'",
"version": "==6.0.1" "version": "==6.0.1"
}, },
"pyyaml-env-tag": { "pyyaml-env-tag": {
@ -3576,27 +3580,27 @@
}, },
"ruff": { "ruff": {
"hashes": [ "hashes": [
"sha256:0a9efb032855ffb3c21f6405751d5e147b0c6b631e3ca3f6b20f917572b97eb6", "sha256:0886184ba2618d815067cf43e005388967b67ab9c80df52b32ec1152ab49f53a",
"sha256:0c126da55c38dd917621552ab430213bdb3273bb10ddb67bc4b761989210eb6e", "sha256:128265876c1d703e5f5e5a4543bd8be47c73a9ba223fd3989d4aa87dd06f312f",
"sha256:1695700d1e25a99d28f7a1636d85bafcc5030bba9d0578c0781ba1790dbcf51c", "sha256:19eacceb4c9406f6c41af806418a26fdb23120dfe53583df76d1401c92b7c14b",
"sha256:1ec49be4fe6ddac0503833f3ed8930528e26d1e60ad35c2446da372d16651ce9", "sha256:23dbb808e2f1d68eeadd5f655485e235c102ac6f12ad31505804edced2a5ae77",
"sha256:3b65494f7e4bed2e74110dac1f0d17dc8e1f42faaa784e7c58a98e335ec83d7e", "sha256:2f7dbba46e2827dfcb0f0cc55fba8e96ba7c8700e0a866eb8cef7d1d66c25dcb",
"sha256:5e1439c8f407e4f356470e54cdecdca1bd5439a0673792dbe34a2b0a551a2fe3", "sha256:3ef655c51f41d5fa879f98e40c90072b567c666a7114fa2d9fe004dffba00932",
"sha256:5e22676a5b875bd72acd3d11d5fa9075d3a5f53b877fe7b4793e4673499318ba", "sha256:5da894a29ec018a8293d3d17c797e73b374773943e8369cfc50495573d396933",
"sha256:6a61ea0ff048e06de273b2e45bd72629f470f5da8f71daf09fe481278b175001", "sha256:755c22536d7f1889be25f2baf6fedd019d0c51d079e8417d4441159f3bcd30c2",
"sha256:940de32dc8853eba0f67f7198b3e79bc6ba95c2edbfdfac2144c8235114d6726", "sha256:7deb528029bacf845bdbb3dbb2927d8ef9b4356a5e731b10eef171e3f0a85944",
"sha256:b0c232af3d0bd8f521806223723456ffebf8e323bd1e4e82b0befb20ba18388e", "sha256:9343690f95710f8cf251bee1013bf43030072b9f8d012fbed6ad702ef70d360a",
"sha256:c9d15fc41e6054bfc7200478720570078f0b41c9ae4f010bcc16bd6f4d1aacdd", "sha256:a1f3ed501a42f60f4dedb7805fa8d4534e78b4e196f536bac926f805f0743d49",
"sha256:cc9a91ae137d687f43a44c900e5d95e9617cb37d4c989e462980ba27039d239d", "sha256:b08b356d06a792e49a12074b62222f9d4ea2a11dca9da9f68163b28c71bf1dd4",
"sha256:d450b7fbff85913f866a5384d8912710936e2b96da74541c82c1b458472ddb39", "sha256:cc30a9053ff2f1ffb505a585797c23434d5f6c838bacfe206c0e6cf38c921a1e",
"sha256:d920499b576f6c68295bc04e7b17b6544d9d05f196bb3aac4358792ef6f34325", "sha256:d0d3d7ef3d4f06433d592e5f7d813314a34601e6c5be8481cccb7fa760aa243e",
"sha256:e62ed7f36b3068a30ba39193a14274cd706bc486fad521276458022f7bccb31d", "sha256:dd73fe7f4c28d317855da6a7bc4aa29a1500320818dd8f27df95f70a01b8171f",
"sha256:ecd46e3106850a5c26aee114e562c329f9a1fbe9e4821b008c4404f64ff9ce73", "sha256:e1e0d4381ca88fb2b73ea0766008e703f33f460295de658f5467f6f229658c19",
"sha256:f63d96494eeec2fc70d909393bcd76c69f35334cdbd9e20d089fb3f0640216ca" "sha256:e3a4a6d46aef0a84b74fcd201a4401ea9a6cd85614f6a9435f2d33dd8cefbf83"
], ],
"index": "pypi", "index": "pypi",
"markers": "python_version >= '3.7'", "markers": "python_version >= '3.7'",
"version": "==0.2.2" "version": "==0.3.0"
}, },
"scipy": { "scipy": {
"hashes": [ "hashes": [
@ -3681,11 +3685,11 @@
"tls" "tls"
], ],
"hashes": [ "hashes": [
"sha256:4ae8bce12999a35f7fe6443e7f1893e6fe09588c8d2bed9c35cdce8ff2d5b444", "sha256:039f2e6a49ab5108abd94de187fa92377abe5985c7a72d68d0ad266ba19eae63",
"sha256:987847a0790a2c597197613686e2784fd54167df3a55d0fb17c8412305d76ce5" "sha256:6b38b6ece7296b5e122c9eb17da2eeab3d98a198f50ca9efd00fb03e5b4fd4ae"
], ],
"markers": "python_full_version >= '3.8.0'", "markers": "python_full_version >= '3.8.0'",
"version": "==23.10.0" "version": "==24.3.0"
}, },
"txaio": { "txaio": {
"hashes": [ "hashes": [
@ -3751,6 +3755,7 @@
"sha256:f970663fa4f7e80401a7b0cbeec00fa801bf0287d93d48368fc3e6fa32716245", "sha256:f970663fa4f7e80401a7b0cbeec00fa801bf0287d93d48368fc3e6fa32716245",
"sha256:f9b2fdca47dc855516b2d66eef3c39f2672cbf7e7a42e7e67ad2cbfcd6ba107d" "sha256:f9b2fdca47dc855516b2d66eef3c39f2672cbf7e7a42e7e67ad2cbfcd6ba107d"
], ],
"index": "pypi",
"markers": "python_version >= '3.8'", "markers": "python_version >= '3.8'",
"version": "==4.0.0" "version": "==4.0.0"
}, },
@ -3987,50 +3992,50 @@
}, },
"cryptography": { "cryptography": {
"hashes": [ "hashes": [
"sha256:087887e55e0b9c8724cf05361357875adb5c20dec27e5816b653492980d20380", "sha256:0270572b8bd2c833c3981724b8ee9747b3ec96f699a9665470018594301439ee",
"sha256:09a77e5b2e8ca732a19a90c5bca2d124621a1edb5438c5daa2d2738bfeb02589", "sha256:111a0d8553afcf8eb02a4fea6ca4f59d48ddb34497aa8706a6cf536f1a5ec576",
"sha256:130c0f77022b2b9c99d8cebcdd834d81705f61c68e91ddd614ce74c657f8b3ea", "sha256:16a48c23a62a2f4a285699dba2e4ff2d1cff3115b9df052cdd976a18856d8e3d",
"sha256:141e2aa5ba100d3788c0ad7919b288f89d1fe015878b9659b307c9ef867d3a65", "sha256:1b95b98b0d2af784078fa69f637135e3c317091b615cd0905f8b8a087e86fa30",
"sha256:28cb2c41f131a5758d6ba6a0504150d644054fd9f3203a1e8e8d7ac3aea7f73a", "sha256:1f71c10d1e88467126f0efd484bd44bca5e14c664ec2ede64c32f20875c0d413",
"sha256:2f9f14185962e6a04ab32d1abe34eae8a9001569ee4edb64d2304bf0d65c53f3", "sha256:2424ff4c4ac7f6b8177b53c17ed5d8fa74ae5955656867f5a8affaca36a27abb",
"sha256:320948ab49883557a256eab46149df79435a22d2fefd6a66fe6946f1b9d9d008", "sha256:2bce03af1ce5a5567ab89bd90d11e7bbdff56b8af3acbbec1faded8f44cb06da",
"sha256:36d4b7c4be6411f58f60d9ce555a73df8406d484ba12a63549c88bd64f7967f1", "sha256:329906dcc7b20ff3cad13c069a78124ed8247adcac44b10bea1130e36caae0b4",
"sha256:3b15c678f27d66d247132cbf13df2f75255627bcc9b6a570f7d2fd08e8c081d2", "sha256:37dd623507659e08be98eec89323469e8c7b4c1407c85112634ae3dbdb926fdd",
"sha256:3dbd37e14ce795b4af61b89b037d4bc157f2cb23e676fa16932185a04dfbf635", "sha256:3eaafe47ec0d0ffcc9349e1708be2aaea4c6dd4978d76bf6eb0cb2c13636c6fc",
"sha256:4383b47f45b14459cab66048d384614019965ba6c1a1a141f11b5a551cace1b2", "sha256:5e6275c09d2badf57aea3afa80d975444f4be8d3bc58f7f80d2a484c6f9485c8",
"sha256:44c95c0e96b3cb628e8452ec060413a49002a247b2b9938989e23a2c8291fc90", "sha256:6fe07eec95dfd477eb9530aef5bead34fec819b3aaf6c5bd6d20565da607bfe1",
"sha256:4b063d3413f853e056161eb0c7724822a9740ad3caa24b8424d776cebf98e7ee", "sha256:7367d7b2eca6513681127ebad53b2582911d1736dc2ffc19f2c3ae49997496bc",
"sha256:52ed9ebf8ac602385126c9a2fe951db36f2cb0c2538d22971487f89d0de4065a", "sha256:7cde5f38e614f55e28d831754e8a3bacf9ace5d1566235e39d91b35502d6936e",
"sha256:55d1580e2d7e17f45d19d3b12098e352f3a37fe86d380bf45846ef257054b242", "sha256:9481ffe3cf013b71b2428b905c4f7a9a4f76ec03065b05ff499bb5682a8d9ad8",
"sha256:5ef9bc3d046ce83c4bbf4c25e1e0547b9c441c01d30922d812e887dc5f125c12", "sha256:98d8dc6d012b82287f2c3d26ce1d2dd130ec200c8679b6213b3c73c08b2b7940",
"sha256:5fa82a26f92871eca593b53359c12ad7949772462f887c35edaf36f87953c0e2", "sha256:a011a644f6d7d03736214d38832e030d8268bcff4a41f728e6030325fea3e400",
"sha256:61321672b3ac7aade25c40449ccedbc6db72c7f5f0fdf34def5e2f8b51ca530d", "sha256:a2913c5375154b6ef2e91c10b5720ea6e21007412f6437504ffea2109b5a33d7",
"sha256:701171f825dcab90969596ce2af253143b93b08f1a716d4b2a9d2db5084ef7be", "sha256:a30596bae9403a342c978fb47d9b0ee277699fa53bbafad14706af51fe543d16",
"sha256:841ec8af7a8491ac76ec5a9522226e287187a3107e12b7d686ad354bb78facee", "sha256:b03c2ae5d2f0fc05f9a2c0c997e1bc18c8229f392234e8a0194f202169ccd278",
"sha256:8a06641fb07d4e8f6c7dda4fc3f8871d327803ab6542e33831c7ccfdcb4d0ad6", "sha256:b6cd2203306b63e41acdf39aa93b86fb566049aeb6dc489b70e34bcd07adca74",
"sha256:8e88bb9eafbf6a4014d55fb222e7360eef53e613215085e65a13290577394529", "sha256:b7ffe927ee6531c78f81aa17e684e2ff617daeba7f189f911065b2ea2d526dec",
"sha256:a00aee5d1b6c20620161984f8ab2ab69134466c51f58c052c11b076715e72929", "sha256:b8cac287fafc4ad485b8a9b67d0ee80c66bf3574f655d3b97ef2e1082360faf1",
"sha256:a047682d324ba56e61b7ea7c7299d51e61fd3bca7dad2ccc39b72bd0118d60a1", "sha256:ba334e6e4b1d92442b75ddacc615c5476d4ad55cc29b15d590cc6b86efa487e2",
"sha256:a7ef8dd0bf2e1d0a27042b231a3baac6883cdd5557036f5e8df7139255feaac6", "sha256:ba3e4a42397c25b7ff88cdec6e2a16c2be18720f317506ee25210f6d31925f9c",
"sha256:ad28cff53f60d99a928dfcf1e861e0b2ceb2bc1f08a074fdd601b314e1cc9e0a", "sha256:c41fb5e6a5fe9ebcd58ca3abfeb51dffb5d83d6775405305bfa8715b76521922",
"sha256:b9097a208875fc7bbeb1286d0125d90bdfed961f61f214d3f5be62cd4ed8a446", "sha256:cd2030f6650c089aeb304cf093f3244d34745ce0cfcc39f20c6fbfe030102e2a",
"sha256:b97fe7d7991c25e6a31e5d5e795986b18fbbb3107b873d5f3ae6dc9a103278e9", "sha256:cd65d75953847815962c84a4654a84850b2bb4aed3f26fadcc1c13892e1e29f6",
"sha256:e0ec52ba3c7f1b7d813cd52649a5b3ef1fc0d433219dc8c93827c57eab6cf888", "sha256:e4985a790f921508f36f81831817cbc03b102d643b5fcb81cd33df3fa291a1a1",
"sha256:ea2c3ffb662fec8bbbfce5602e2c159ff097a4631d96235fcf0fb00e59e3ece4", "sha256:e807b3188f9eb0eaa7bbb579b462c5ace579f1cedb28107ce8b48a9f7ad3679e",
"sha256:fa3dec4ba8fb6e662770b74f62f1a0c7d4e37e25b58b2bf2c1be4c95372b4a33", "sha256:f12764b8fffc7a123f641d7d049d382b73f96a34117e0b637b80643169cec8ac",
"sha256:fbeb725c9dc799a574518109336acccaf1303c30d45c075c665c0793c2f79a7f" "sha256:f8837fe1d6ac4a8052a9a8ddab256bc006242696f03368a4009be7ee3075cdb7"
], ],
"markers": "python_version >= '3.7'", "markers": "python_version >= '3.7'",
"version": "==42.0.2" "version": "==42.0.5"
}, },
"django": { "django": {
"hashes": [ "hashes": [
"sha256:a2d4c4d4ea0b6f0895acde632071aff6400bfc331228fc978b05452a0ff3e9f1", "sha256:6e6ff3db2d8dd0c986b4eec8554c8e4f919b5c1ff62a5b4390c17aff2ed6e5c4",
"sha256:b1260ed381b10a11753c73444408e19869f3241fc45c985cd55a30177c789d13" "sha256:ddc24a0a8280a0430baa37aff11f28574720af05888c62b7cfe71d219f4599d3"
], ],
"index": "pypi", "index": "pypi",
"markers": "python_version >= '3.8'", "markers": "python_version >= '3.8'",
"version": "==4.2.10" "version": "==4.2.11"
}, },
"django-filter-stubs": { "django-filter-stubs": {
"hashes": [ "hashes": [
@ -4153,12 +4158,12 @@
}, },
"types-bleach": { "types-bleach": {
"hashes": [ "hashes": [
"sha256:1e43c437e734a90efe4f40ebfe831057599568d3b275939ffbd6094848a18a27", "sha256:1299f06b5ef80e2d4f25fac11f613033c9bc35bad116413cb320d0c0c1188466",
"sha256:f83f80e0709f13d809a9c79b958a1089df9b99e68059287beb196e38967e4ddf" "sha256:78a1c39484c8949030a0931076eeb69b03a0e08a4fafec5b3764e142929859d4"
], ],
"index": "pypi", "index": "pypi",
"markers": "python_version >= '3.7'", "markers": "python_version >= '3.8'",
"version": "==6.1.0.1" "version": "==6.1.0.20240222"
}, },
"types-colorama": { "types-colorama": {
"hashes": [ "hashes": [
@ -4180,11 +4185,19 @@
}, },
"types-docutils": { "types-docutils": {
"hashes": [ "hashes": [
"sha256:79d3bcef235f7c81a63f4f3dcf1d0b138985079bb32d02f5a7d266e1f9f361ba", "sha256:c35ae35ca835a5aeead758df411cd46cfb7e7f19f2b223c413dae7e069d5b0be",
"sha256:ba4bfd4ff6dd19640ba7ab5d93900393a65897880f3650997964a943f4e79a6b" "sha256:ef02f9d05f2b61500638b1358cdf3fbf975cc5dedaa825a2eb5ea71b7318a760"
], ],
"markers": "python_version >= '3.8'", "markers": "python_version >= '3.8'",
"version": "==0.20.0.20240201" "version": "==0.20.0.20240304"
},
"types-html5lib": {
"hashes": [
"sha256:22736b7299e605ec4ba539d48691e905fd0c61c3ea610acc59922232dc84cede",
"sha256:af5de0125cb0fe5667543b158db83849b22e25c0e36c9149836b095548bf1020"
],
"markers": "python_version >= '3.8'",
"version": "==1.1.11.20240228"
}, },
"types-markdown": { "types-markdown": {
"hashes": [ "hashes": [
@ -4197,21 +4210,21 @@
}, },
"types-pillow": { "types-pillow": {
"hashes": [ "hashes": [
"sha256:abc339ae28af5916146a7729261480d68ac902cd4ff57e0bdd402eee7962644d", "sha256:062c5a0f20301a30f2df4db583f15b3c2a1283a12518d1f9d81396154e12c1af",
"sha256:f0de5107ff8362ffdbbd53ec896202ac905e6ab22ae784b46bcdad160ea143b9" "sha256:4800b61bf7eabdae2f1b17ade0d080709ed33e9f26a2e900e470e8b56ebe2387"
], ],
"index": "pypi", "index": "pypi",
"markers": "python_version >= '3.8'", "markers": "python_version >= '3.8'",
"version": "==10.2.0.20240206" "version": "==10.2.0.20240213"
}, },
"types-psycopg2": { "types-psycopg2": {
"hashes": [ "hashes": [
"sha256:75a92735f62ba36397409ae4758f0241cbc4a9bb30f1f95d3b4a660fea7e7711", "sha256:3084cd807038a62c80fb5be78b41d855b48a060316101ea59fd85c302efb57d4",
"sha256:b05cf5d7ce0bd460319f6399fb358307e5af109bfcb6ed7b3d63c9ebec6b9be6" "sha256:cac96264e063cbce28dee337a973d39e6df4ca671252343cb4f8e5ef6db5e67d"
], ],
"index": "pypi", "index": "pypi",
"markers": "python_version >= '3.8'", "markers": "python_version >= '3.8'",
"version": "==2.9.21.20240201" "version": "==2.9.21.20240218"
}, },
"types-pygments": { "types-pygments": {
"hashes": [ "hashes": [
@ -4224,11 +4237,11 @@
}, },
"types-pyopenssl": { "types-pyopenssl": {
"hashes": [ "hashes": [
"sha256:24a255458b5b8a7fca8139cf56f2a8ad5a4f1a5f711b73a5bb9cb50dc688fab5", "sha256:a472cf877a873549175e81972f153f44e975302a3cf17381eb5f3d41ccfb75a4",
"sha256:c812e5c1c35249f75ef5935708b2a997d62abf9745be222e5f94b9595472ab25" "sha256:cd990717d8aa3743ef0e73e0f462e64b54d90c304249232d48fece4f0f7c3c6a"
], ],
"markers": "python_version >= '3.8'", "markers": "python_version >= '3.8'",
"version": "==24.0.0.20240130" "version": "==24.0.0.20240228"
}, },
"types-python-dateutil": { "types-python-dateutil": {
"hashes": [ "hashes": [
@ -4256,29 +4269,29 @@
}, },
"types-redis": { "types-redis": {
"hashes": [ "hashes": [
"sha256:2b2fa3a78f84559616242d23f86de5f4130dfd6c3b83fb2d8ce3329e503f756e", "sha256:5103d7e690e5c74c974a161317b2d59ac2303cf8bef24175b04c2a4c3486cb39",
"sha256:912de6507b631934bd225cdac310b04a58def94391003ba83939e5a10e99568d" "sha256:dc9c45a068240e33a04302aec5655cf41e80f91eecffccbb2df215b2f6fc375d"
], ],
"index": "pypi", "index": "pypi",
"markers": "python_version >= '3.8'", "markers": "python_version >= '3.8'",
"version": "==4.6.0.20240106" "version": "==4.6.0.20240218"
}, },
"types-requests": { "types-requests": {
"hashes": [ "hashes": [
"sha256:03a28ce1d7cd54199148e043b2079cdded22d6795d19a2c2a6791a4b2b5e2eb5", "sha256:a82807ec6ddce8f00fe0e949da6d6bc1fbf1715420218a9640d695f70a9e5a9b",
"sha256:9592a9a4cb92d6d75d9b491a41477272b710e021011a2a3061157e2fb1f1a5d1" "sha256:f1721dba8385958f504a5386240b92de4734e047a08a40751c1654d1ac3349c5"
], ],
"markers": "python_version >= '3.8'", "markers": "python_version >= '3.8'",
"version": "==2.31.0.20240125" "version": "==2.31.0.20240218"
}, },
"types-setuptools": { "types-setuptools": {
"hashes": [ "hashes": [
"sha256:00835f959ff24ebc32c55da8df9d46e8df25e3c4bfacb43e98b61fde51a4bc41", "sha256:99c1053920a6fa542b734c9ad61849c3993062f80963a4034771626528e192a0",
"sha256:22ad498cb585b22ce8c97ada1fccdf294a2e0dd7dc984a28535a84ea82f45b3f" "sha256:ed5462cf8470831d1bdbf300e1eeea876040643bfc40b785109a5857fa7d3c3f"
], ],
"index": "pypi", "index": "pypi",
"markers": "python_version >= '3.8'", "markers": "python_version >= '3.8'",
"version": "==69.0.0.20240125" "version": "==69.1.0.20240302"
}, },
"types-tqdm": { "types-tqdm": {
"hashes": [ "hashes": [
@ -4291,19 +4304,19 @@
}, },
"typing-extensions": { "typing-extensions": {
"hashes": [ "hashes": [
"sha256:23478f88c37f27d76ac8aee6c905017a143b0b1b886c3c9f66bc2fd94f9f5783", "sha256:69b1a937c3a517342112fb4c6df7e72fc39a38e7891a5730ed4985b5214b5475",
"sha256:af72aea155e91adfc61c3ae9e0e342dbc0cba726d6cba4b6c72c1f34e47291cd" "sha256:b0abd7c89e8fb96f98db18d86106ff1d90ab692004eb746cf6eda2682f91b3cb"
], ],
"markers": "python_version < '3.11'", "markers": "python_version < '3.11'",
"version": "==4.9.0" "version": "==4.10.0"
}, },
"urllib3": { "urllib3": {
"hashes": [ "hashes": [
"sha256:051d961ad0c62a94e50ecf1af379c3aba230c66c710493493560c0c223c49f20", "sha256:450b20ec296a467077128bff42b73080516e71b56ff59a60a02bef2232c4fa9d",
"sha256:ce3711610ddce217e6d113a2732fafad960a03fd0318c91faa79481e35c11224" "sha256:d0570876c61ab9e520d776c38acbbb5b05a776d3f9ff98a5c8fd5162a444cf19"
], ],
"markers": "python_version >= '3.8'", "markers": "python_version >= '3.8'",
"version": "==2.2.0" "version": "==2.2.1"
} }
} }
} }

View File

@ -437,7 +437,7 @@ with Prometheus, as it exports metrics. For details on its capabilities,
refer to the [Flower](https://flower.readthedocs.io/en/latest/index.html) refer to the [Flower](https://flower.readthedocs.io/en/latest/index.html)
documentation. documentation.
Flower can be enabled with the setting [PAPERLESS_ENABLE_FLOWER](configuration/#PAPERLESS_ENABLE_FLOWER). Flower can be enabled with the setting [PAPERLESS_ENABLE_FLOWER](configuration.md#PAPERLESS_ENABLE_FLOWER).
To configure Flower further, create a `flowerconfig.py` and To configure Flower further, create a `flowerconfig.py` and
place it into the `src/paperless` directory. For a Docker place it into the `src/paperless` directory. For a Docker
installation, you can use volumes to accomplish this: installation, you can use volumes to accomplish this:

View File

@ -1,5 +1,98 @@
# Changelog # Changelog
## paperless-ngx 2.6.0
### Features
- Feature: Allow user to control PIL image pixel limit [@stumpylog](https://github.com/stumpylog) ([#5997](https://github.com/paperless-ngx/paperless-ngx/pull/5997))
- Feature: Allow a user to disable the pixel limit for OCR entirely [@stumpylog](https://github.com/stumpylog) ([#5996](https://github.com/paperless-ngx/paperless-ngx/pull/5996))
- Feature: workflow removal action [@shamoon](https://github.com/shamoon) ([#5928](https://github.com/paperless-ngx/paperless-ngx/pull/5928))
- Feature: system status [@shamoon](https://github.com/shamoon) ([#5743](https://github.com/paperless-ngx/paperless-ngx/pull/5743))
- Enhancement: better monetary field with currency code [@shamoon](https://github.com/shamoon) ([#5858](https://github.com/paperless-ngx/paperless-ngx/pull/5858))
- Enhancement: support disabling regular login [@shamoon](https://github.com/shamoon) ([#5816](https://github.com/paperless-ngx/paperless-ngx/pull/5816))
### Bug Fixes
- Fix: refactor base path settings, correct logout redirect [@shamoon](https://github.com/shamoon) ([#5976](https://github.com/paperless-ngx/paperless-ngx/pull/5976))
- Fix: always pass from UI, dont require in API [@shamoon](https://github.com/shamoon) ([#5962](https://github.com/paperless-ngx/paperless-ngx/pull/5962))
- Fix: Clear metadata cache when the filename(s) change [@stumpylog](https://github.com/stumpylog) ([#5957](https://github.com/paperless-ngx/paperless-ngx/pull/5957))
- Fix: include monetary, float and doc link values in search filters [@shamoon](https://github.com/shamoon) ([#5951](https://github.com/paperless-ngx/paperless-ngx/pull/5951))
- Fix: Better handling of a corrupted index [@stumpylog](https://github.com/stumpylog) ([#5950](https://github.com/paperless-ngx/paperless-ngx/pull/5950))
- Fix: Don't assume the location of scratch directory in Docker [@stumpylog](https://github.com/stumpylog) ([#5948](https://github.com/paperless-ngx/paperless-ngx/pull/5948))
- Fix: ensure document title always limited to 128 chars [@shamoon](https://github.com/shamoon) ([#5934](https://github.com/paperless-ngx/paperless-ngx/pull/5934))
- Fix: use for password reset emails, if set [@shamoon](https://github.com/shamoon) ([#5902](https://github.com/paperless-ngx/paperless-ngx/pull/5902))
- Fix: Correct docker compose check in install script [@ShanSanear](https://github.com/ShanSanear) ([#5917](https://github.com/paperless-ngx/paperless-ngx/pull/5917))
- Fix: respect global permissions for UI settings [@shamoon](https://github.com/shamoon) ([#5919](https://github.com/paperless-ngx/paperless-ngx/pull/5919))
- Fix: allow disable email verification during signup [@shamoon](https://github.com/shamoon) ([#5895](https://github.com/paperless-ngx/paperless-ngx/pull/5895))
- Fix: refactor accounts templates and create signup template [@shamoon](https://github.com/shamoon) ([#5899](https://github.com/paperless-ngx/paperless-ngx/pull/5899))
### Maintenance
- Chore(deps): Bump the actions group with 3 updates [@dependabot](https://github.com/dependabot) ([#5907](https://github.com/paperless-ngx/paperless-ngx/pull/5907))
- Chore: Ignores uvicorn updates in dependabot [@stumpylog](https://github.com/stumpylog) ([#5906](https://github.com/paperless-ngx/paperless-ngx/pull/5906))
### Dependencies
<details>
<summary>15 changes</summary>
- Chore(deps): Bump the small-changes group with 3 updates [@dependabot](https://github.com/dependabot) ([#6001](https://github.com/paperless-ngx/paperless-ngx/pull/6001))
- Chore(deps-dev): Bump the development group with 2 updates [@dependabot](https://github.com/dependabot) ([#5998](https://github.com/paperless-ngx/paperless-ngx/pull/5998))
- Chore(deps): Bump the django group with 1 update [@dependabot](https://github.com/dependabot) ([#6000](https://github.com/paperless-ngx/paperless-ngx/pull/6000))
- Chore(deps-dev): Bump [@<!---->playwright/test from 1.41.2 to 1.42.0 in /src-ui @dependabot](https://github.com/<!---->playwright/test from 1.41.2 to 1.42.0 in /src-ui @dependabot) ([#5964](https://github.com/paperless-ngx/paperless-ngx/pull/5964))
- Chore(deps-dev): Bump [@<!---->types/node from 20.11.20 to 20.11.24 in /src-ui @dependabot](https://github.com/<!---->types/node from 20.11.20 to 20.11.24 in /src-ui @dependabot) ([#5965](https://github.com/paperless-ngx/paperless-ngx/pull/5965))
- Chore(deps): Bump the frontend-angular-dependencies group in /src-ui with 11 updates [@dependabot](https://github.com/dependabot) ([#5963](https://github.com/paperless-ngx/paperless-ngx/pull/5963))
- Chore(deps-dev): Bump the frontend-eslint-dependencies group in /src-ui with 3 updates [@dependabot](https://github.com/dependabot) ([#5918](https://github.com/paperless-ngx/paperless-ngx/pull/5918))
- Chore(deps-dev): Bump [@<!---->types/node from 20.11.16 to 20.11.20 in /src-ui @dependabot](https://github.com/<!---->types/node from 20.11.16 to 20.11.20 in /src-ui @dependabot) ([#5912](https://github.com/paperless-ngx/paperless-ngx/pull/5912))
- Chore(deps): Bump zone.js from 0.14.3 to 0.14.4 in /src-ui [@dependabot](https://github.com/dependabot) ([#5913](https://github.com/paperless-ngx/paperless-ngx/pull/5913))
- Chore(deps): Bump bootstrap from 5.3.2 to 5.3.3 in /src-ui [@dependabot](https://github.com/dependabot) ([#5911](https://github.com/paperless-ngx/paperless-ngx/pull/5911))
- Chore(deps-dev): Bump typescript from 5.2.2 to 5.3.3 in /src-ui [@dependabot](https://github.com/dependabot) ([#5915](https://github.com/paperless-ngx/paperless-ngx/pull/5915))
- Chore(deps): Bump the frontend-angular-dependencies group in /src-ui with 15 updates [@dependabot](https://github.com/dependabot) ([#5908](https://github.com/paperless-ngx/paperless-ngx/pull/5908))
- Chore(deps): Bump the small-changes group with 4 updates [@dependabot](https://github.com/dependabot) ([#5916](https://github.com/paperless-ngx/paperless-ngx/pull/5916))
- Chore(deps-dev): Bump the development group with 4 updates [@dependabot](https://github.com/dependabot) ([#5914](https://github.com/paperless-ngx/paperless-ngx/pull/5914))
- Chore(deps): Bump the actions group with 3 updates [@dependabot](https://github.com/dependabot) ([#5907](https://github.com/paperless-ngx/paperless-ngx/pull/5907))
</details>
### All App Changes
<details>
<summary>33 changes</summary>
- Feature: Allow user to control PIL image pixel limit [@stumpylog](https://github.com/stumpylog) ([#5997](https://github.com/paperless-ngx/paperless-ngx/pull/5997))
- Enhancement: show ID when editing objects [@shamoon](https://github.com/shamoon) ([#6003](https://github.com/paperless-ngx/paperless-ngx/pull/6003))
- Feature: Allow a user to disable the pixel limit for OCR entirely [@stumpylog](https://github.com/stumpylog) ([#5996](https://github.com/paperless-ngx/paperless-ngx/pull/5996))
- Chore(deps): Bump the small-changes group with 3 updates [@dependabot](https://github.com/dependabot) ([#6001](https://github.com/paperless-ngx/paperless-ngx/pull/6001))
- Chore(deps-dev): Bump the development group with 2 updates [@dependabot](https://github.com/dependabot) ([#5998](https://github.com/paperless-ngx/paperless-ngx/pull/5998))
- Chore(deps): Bump the django group with 1 update [@dependabot](https://github.com/dependabot) ([#6000](https://github.com/paperless-ngx/paperless-ngx/pull/6000))
- Feature: workflow removal action [@shamoon](https://github.com/shamoon) ([#5928](https://github.com/paperless-ngx/paperless-ngx/pull/5928))
- Feature: system status [@shamoon](https://github.com/shamoon) ([#5743](https://github.com/paperless-ngx/paperless-ngx/pull/5743))
- Fix: refactor base path settings, correct logout redirect [@shamoon](https://github.com/shamoon) ([#5976](https://github.com/paperless-ngx/paperless-ngx/pull/5976))
- Chore(deps-dev): Bump [@<!---->playwright/test from 1.41.2 to 1.42.0 in /src-ui @dependabot](https://github.com/<!---->playwright/test from 1.41.2 to 1.42.0 in /src-ui @dependabot) ([#5964](https://github.com/paperless-ngx/paperless-ngx/pull/5964))
- Chore(deps-dev): Bump [@<!---->types/node from 20.11.20 to 20.11.24 in /src-ui @dependabot](https://github.com/<!---->types/node from 20.11.20 to 20.11.24 in /src-ui @dependabot) ([#5965](https://github.com/paperless-ngx/paperless-ngx/pull/5965))
- Chore(deps): Bump the frontend-angular-dependencies group in /src-ui with 11 updates [@dependabot](https://github.com/dependabot) ([#5963](https://github.com/paperless-ngx/paperless-ngx/pull/5963))
- Fix: always pass from UI, dont require in API [@shamoon](https://github.com/shamoon) ([#5962](https://github.com/paperless-ngx/paperless-ngx/pull/5962))
- Fix: Clear metadata cache when the filename(s) change [@stumpylog](https://github.com/stumpylog) ([#5957](https://github.com/paperless-ngx/paperless-ngx/pull/5957))
- Fix: include monetary, float and doc link values in search filters [@shamoon](https://github.com/shamoon) ([#5951](https://github.com/paperless-ngx/paperless-ngx/pull/5951))
- Fix: Better handling of a corrupted index [@stumpylog](https://github.com/stumpylog) ([#5950](https://github.com/paperless-ngx/paperless-ngx/pull/5950))
- Chore: Includes OCRMyPdf logging into the log file [@stumpylog](https://github.com/stumpylog) ([#5947](https://github.com/paperless-ngx/paperless-ngx/pull/5947))
- Fix: ensure document title always limited to 128 chars [@shamoon](https://github.com/shamoon) ([#5934](https://github.com/paperless-ngx/paperless-ngx/pull/5934))
- Enhancement: better monetary field with currency code [@shamoon](https://github.com/shamoon) ([#5858](https://github.com/paperless-ngx/paperless-ngx/pull/5858))
- Change: add Thumbs.db to default ignores [@DennisGaida](https://github.com/DennisGaida) ([#5924](https://github.com/paperless-ngx/paperless-ngx/pull/5924))
- Fix: use for password reset emails, if set [@shamoon](https://github.com/shamoon) ([#5902](https://github.com/paperless-ngx/paperless-ngx/pull/5902))
- Fix: respect global permissions for UI settings [@shamoon](https://github.com/shamoon) ([#5919](https://github.com/paperless-ngx/paperless-ngx/pull/5919))
- Chore(deps-dev): Bump the frontend-eslint-dependencies group in /src-ui with 3 updates [@dependabot](https://github.com/dependabot) ([#5918](https://github.com/paperless-ngx/paperless-ngx/pull/5918))
- Chore(deps-dev): Bump [@<!---->types/node from 20.11.16 to 20.11.20 in /src-ui @dependabot](https://github.com/<!---->types/node from 20.11.16 to 20.11.20 in /src-ui @dependabot) ([#5912](https://github.com/paperless-ngx/paperless-ngx/pull/5912))
- Chore(deps): Bump zone.js from 0.14.3 to 0.14.4 in /src-ui [@dependabot](https://github.com/dependabot) ([#5913](https://github.com/paperless-ngx/paperless-ngx/pull/5913))
- Chore(deps): Bump bootstrap from 5.3.2 to 5.3.3 in /src-ui [@dependabot](https://github.com/dependabot) ([#5911](https://github.com/paperless-ngx/paperless-ngx/pull/5911))
- Chore(deps-dev): Bump typescript from 5.2.2 to 5.3.3 in /src-ui [@dependabot](https://github.com/dependabot) ([#5915](https://github.com/paperless-ngx/paperless-ngx/pull/5915))
- Chore(deps): Bump the frontend-angular-dependencies group in /src-ui with 15 updates [@dependabot](https://github.com/dependabot) ([#5908](https://github.com/paperless-ngx/paperless-ngx/pull/5908))
- Fix: allow disable email verification during signup [@shamoon](https://github.com/shamoon) ([#5895](https://github.com/paperless-ngx/paperless-ngx/pull/5895))
- Fix: refactor accounts templates and create signup template [@shamoon](https://github.com/shamoon) ([#5899](https://github.com/paperless-ngx/paperless-ngx/pull/5899))
- Chore(deps): Bump the small-changes group with 4 updates [@dependabot](https://github.com/dependabot) ([#5916](https://github.com/paperless-ngx/paperless-ngx/pull/5916))
- Chore(deps-dev): Bump the development group with 4 updates [@dependabot](https://github.com/dependabot) ([#5914](https://github.com/paperless-ngx/paperless-ngx/pull/5914))
- Enhancement: support disabling regular login [@shamoon](https://github.com/shamoon) ([#5816](https://github.com/paperless-ngx/paperless-ngx/pull/5816))
</details>
## paperless-ngx 2.5.4 ## paperless-ngx 2.5.4
### Bug Fixes ### Bug Fixes

View File

@ -766,6 +766,8 @@ but could result in missing text content.
If unset, will default to the value determined by If unset, will default to the value determined by
[Pillow](https://pillow.readthedocs.io/en/stable/reference/Image.html#PIL.Image.MAX_IMAGE_PIXELS). [Pillow](https://pillow.readthedocs.io/en/stable/reference/Image.html#PIL.Image.MAX_IMAGE_PIXELS).
Setting this value to 0 will entirely disable the limit. See the below warning.
!!! note !!! note
Increasing this limit could cause Paperless to consume additional Increasing this limit could cause Paperless to consume additional
@ -775,7 +777,7 @@ but could result in missing text content.
!!! warning !!! warning
The limit is intended to prevent malicious files from consuming The limit is intended to prevent malicious files from consuming
system resources and causing crashes and other errors. Only increase system resources and causing crashes and other errors. Only change
this value if you are certain your documents are not malicious and this value if you are certain your documents are not malicious and
you need the text which was not OCRed you need the text which was not OCRed
@ -967,6 +969,20 @@ be used with caution!
Defaults to None, which does not add any additional apps. Defaults to None, which does not add any additional apps.
#### [`PAPERLESS_MAX_IMAGE_PIXELS=<number>`](#PAPERLESS_MAX_IMAGE_PIXELS) {#PAPERLESS_MAX_IMAGE_PIXELS}
: Configures the maximum size of an image PIL will allow to load without warning or error.
: If unset, will default to the value determined by
[Pillow](https://pillow.readthedocs.io/en/stable/reference/Image.html#PIL.Image.MAX_IMAGE_PIXELS).
Defaults to None, which does change the limit
!!! warning
This limit is designed to prevent denial of service from malicious files.
It should only be raised or disabled in certain circumstances and with great care.
## Document Consumption {#consume_config} ## Document Consumption {#consume_config}
#### [`PAPERLESS_CONSUMER_DELETE_DUPLICATES=<bool>`](#PAPERLESS_CONSUMER_DELETE_DUPLICATES) {#PAPERLESS_CONSUMER_DELETE_DUPLICATES} #### [`PAPERLESS_CONSUMER_DELETE_DUPLICATES=<bool>`](#PAPERLESS_CONSUMER_DELETE_DUPLICATES) {#PAPERLESS_CONSUMER_DELETE_DUPLICATES}

View File

@ -329,7 +329,7 @@ Workflows allow you to filter by:
### Workflow Actions ### Workflow Actions
There is currently one type of workflow action, "Assignment", which can assign: There are currently two types of workflow actions, "Assignment", which can assign:
- Title, see [title placeholders](usage.md#title-placeholders) below - Title, see [title placeholders](usage.md#title-placeholders) below
- Tags, correspondent, document type and storage path - Tags, correspondent, document type and storage path
@ -337,6 +337,13 @@ There is currently one type of workflow action, "Assignment", which can assign:
- View and / or edit permissions to users or groups - View and / or edit permissions to users or groups
- Custom fields. Note that no value for the field will be set - Custom fields. Note that no value for the field will be set
and "Removal" actions, which can remove either all of or specific sets of the following:
- Tags, correspondents, document types or storage paths
- Document owner
- View and / or edit permissions
- Custom fields
#### Title placeholders #### Title placeholders
Workflow titles can include placeholders but the available options differ depending on the type of Workflow titles can include placeholders but the available options differ depending on the type of

View File

@ -77,7 +77,9 @@
"scripts": [], "scripts": [],
"allowedCommonJsDependencies": [ "allowedCommonJsDependencies": [
"pdfjs-dist", "pdfjs-dist",
"pdfjs-dist/web/pdf_viewer" "pdfjs-dist/web/pdf_viewer",
"filesize",
"file-saver"
], ],
"vendorChunk": true, "vendorChunk": true,
"extractLicenses": false, "extractLicenses": false,

File diff suppressed because it is too large Load Diff

View File

@ -29,6 +29,7 @@
"ngx-color": "^9.0.0", "ngx-color": "^9.0.0",
"ngx-cookie-service": "^17.1.0", "ngx-cookie-service": "^17.1.0",
"ngx-file-drop": "^16.0.0", "ngx-file-drop": "^16.0.0",
"ngx-filesize": "^3.0.3",
"ngx-ui-tour-ng-bootstrap": "^14.0.2", "ngx-ui-tour-ng-bootstrap": "^14.0.2",
"pdfjs-dist": "^3.11.174", "pdfjs-dist": "^3.11.174",
"rxjs": "^7.8.1", "rxjs": "^7.8.1",
@ -9844,6 +9845,15 @@
"node": ">=10" "node": ">=10"
} }
}, },
"node_modules/filesize": {
"version": "9.0.11",
"resolved": "https://registry.npmjs.org/filesize/-/filesize-9.0.11.tgz",
"integrity": "sha512-gTAiTtI0STpKa5xesyTA9hA3LX4ga8sm2nWRcffEa1L/5vQwb4mj2MdzMkoHoGv4QzfDshQZuYscQSf8c4TKOA==",
"peer": true,
"engines": {
"node": ">= 0.4.0"
}
},
"node_modules/fill-range": { "node_modules/fill-range": {
"version": "7.0.1", "version": "7.0.1",
"resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.0.1.tgz", "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.0.1.tgz",
@ -14105,6 +14115,19 @@
"@angular/core": ">=14.0.0" "@angular/core": ">=14.0.0"
} }
}, },
"node_modules/ngx-filesize": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/ngx-filesize/-/ngx-filesize-3.0.3.tgz",
"integrity": "sha512-qqP2p4WbbF7R+NXC9NqRQdAfWfMAYJ2Ijf4ezRCq7j3tPY6ybSP9AZ3FY1U7/95n1hmOJ2U5oY+oFb7LhHQRBw==",
"dependencies": {
"tslib": "^2.3.0"
},
"peerDependencies": {
"@angular/common": ">= 14.2.0 < 18.0.0",
"@angular/core": ">= 14.2.0 < 18.0.0",
"filesize": ">= 6.0.0 < 10.0.0"
}
},
"node_modules/ngx-ui-tour-core": { "node_modules/ngx-ui-tour-core": {
"version": "12.0.1", "version": "12.0.1",
"resolved": "https://registry.npmjs.org/ngx-ui-tour-core/-/ngx-ui-tour-core-12.0.1.tgz", "resolved": "https://registry.npmjs.org/ngx-ui-tour-core/-/ngx-ui-tour-core-12.0.1.tgz",

View File

@ -31,6 +31,7 @@
"ngx-color": "^9.0.0", "ngx-color": "^9.0.0",
"ngx-cookie-service": "^17.1.0", "ngx-cookie-service": "^17.1.0",
"ngx-file-drop": "^16.0.0", "ngx-file-drop": "^16.0.0",
"ngx-filesize": "^3.0.3",
"ngx-ui-tour-ng-bootstrap": "^14.0.2", "ngx-ui-tour-ng-bootstrap": "^14.0.2",
"pdfjs-dist": "^3.11.174", "pdfjs-dist": "^3.11.174",
"rxjs": "^7.8.1", "rxjs": "^7.8.1",

View File

@ -114,7 +114,10 @@ import { FileComponent } from './components/common/input/file/file.component'
import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons' import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons'
import { ConfirmButtonComponent } from './components/common/confirm-button/confirm-button.component' import { ConfirmButtonComponent } from './components/common/confirm-button/confirm-button.component'
import { MonetaryComponent } from './components/common/input/monetary/monetary.component' import { MonetaryComponent } from './components/common/input/monetary/monetary.component'
import { SystemStatusDialogComponent } from './components/common/system-status-dialog/system-status-dialog.component'
import { NgxFilesizeModule } from 'ngx-filesize'
import { import {
airplane,
archive, archive,
arrowCounterclockwise, arrowCounterclockwise,
arrowDown, arrowDown,
@ -129,12 +132,14 @@ import {
boxes, boxes,
calendar, calendar,
calendarEvent, calendarEvent,
cardChecklist,
caretDown, caretDown,
caretUp, caretUp,
chatLeftText, chatLeftText,
check, check,
check2All, check2All,
checkAll, checkAll,
checkCircleFill,
checkLg, checkLg,
chevronDoubleLeft, chevronDoubleLeft,
chevronDoubleRight, chevronDoubleRight,
@ -148,7 +153,9 @@ import {
doorOpen, doorOpen,
download, download,
envelope, envelope,
exclamationCircleFill,
exclamationTriangle, exclamationTriangle,
exclamationTriangleFill,
eye, eye,
fileEarmark, fileEarmark,
fileEarmarkCheck, fileEarmarkCheck,
@ -200,6 +207,7 @@ import {
} from 'ngx-bootstrap-icons' } from 'ngx-bootstrap-icons'
const icons = { const icons = {
airplane,
archive, archive,
arrowCounterclockwise, arrowCounterclockwise,
arrowDown, arrowDown,
@ -214,12 +222,14 @@ const icons = {
boxes, boxes,
calendar, calendar,
calendarEvent, calendarEvent,
cardChecklist,
caretDown, caretDown,
caretUp, caretUp,
chatLeftText, chatLeftText,
check, check,
check2All, check2All,
checkAll, checkAll,
checkCircleFill,
checkLg, checkLg,
chevronDoubleLeft, chevronDoubleLeft,
chevronDoubleRight, chevronDoubleRight,
@ -233,7 +243,9 @@ const icons = {
doorOpen, doorOpen,
download, download,
envelope, envelope,
exclamationCircleFill,
exclamationTriangle, exclamationTriangle,
exclamationTriangleFill,
eye, eye,
fileEarmark, fileEarmark,
fileEarmarkCheck, fileEarmarkCheck,
@ -445,6 +457,7 @@ function initializeApp(settings: SettingsService) {
FileComponent, FileComponent,
ConfirmButtonComponent, ConfirmButtonComponent,
MonetaryComponent, MonetaryComponent,
SystemStatusDialogComponent,
], ],
imports: [ imports: [
BrowserModule, BrowserModule,
@ -459,6 +472,7 @@ function initializeApp(settings: SettingsService) {
TourNgBootstrapModule, TourNgBootstrapModule,
DragDropModule, DragDropModule,
NgxBootstrapIconsModule.pick(icons), NgxBootstrapIconsModule.pick(icons),
NgxFilesizeModule,
], ],
providers: [ providers: [
{ {

View File

@ -4,10 +4,31 @@
info="Options to customize appearance, notifications, saved views and more. Settings apply to the <strong>current user only</strong>." info="Options to customize appearance, notifications, saved views and more. Settings apply to the <strong>current user only</strong>."
i18n-info i18n-info
> >
<button class="btn btn-sm btn-outline-primary" (click)="tourService.start()"><ng-container i18n>Start tour</ng-container></button> <button class="btn btn-sm btn-outline-primary" (click)="tourService.start()">
<a *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.Admin }" class="btn btn-sm btn-primary ms-3" href="admin/" target="_blank"> <i-bs class="me-1" name="airplane"></i-bs>&nbsp;<ng-container i18n>Start tour</ng-container>
</button>
<button class="btn btn-sm btn-outline-primary position-relative ms-md-5 me-1" (click)="showSystemStatus()"
[disabled]="!systemStatus"
*pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.Admin }">
@if (!systemStatus) {
<div class="spinner-border spinner-border-sm me-1 h-75" role="status"></div>
} @else {
<i-bs class="me-2" name="card-checklist"></i-bs>
@if (systemStatusHasErrors) {
<span class="badge bg-body position-absolute top-0 start-100 translate-middle rounded-pill p-0">
<i-bs name="exclamation-circle-fill" class="text-danger" width="1.75em" height="1.75em"></i-bs>
</span>
} @else {
<span class="badge bg-body position-absolute top-0 start-100 translate-middle rounded-pill p-0">
<i-bs name="check-circle-fill" class="text-primary" width="1.75em" height="1.75em"></i-bs>
</span>
}
}
<ng-container i18n>System Status</ng-container>
</button>
<a *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.Admin }" class="btn btn-sm btn-primary" href="admin/" target="_blank">
<ng-container i18n>Open Django Admin</ng-container> <ng-container i18n>Open Django Admin</ng-container>
<i-bs name="arrow-up-right"></i-bs> &nbsp;<i-bs name="arrow-up-right"></i-bs>
</a> </a>
</pngx-page-header> </pngx-page-header>

View File

@ -9,6 +9,8 @@ import {
NgbModule, NgbModule,
NgbAlertModule, NgbAlertModule,
NgbNavLink, NgbNavLink,
NgbModal,
NgbModalModule,
} from '@ng-bootstrap/ng-bootstrap' } from '@ng-bootstrap/ng-bootstrap'
import { NgSelectModule } from '@ng-select/ng-select' import { NgSelectModule } from '@ng-select/ng-select'
import { of, throwError } from 'rxjs' import { of, throwError } from 'rxjs'
@ -39,6 +41,13 @@ import { SettingsComponent } from './settings.component'
import { IfOwnerDirective } from 'src/app/directives/if-owner.directive' import { IfOwnerDirective } from 'src/app/directives/if-owner.directive'
import { NgxBootstrapIconsModule, allIcons } from 'ngx-bootstrap-icons' import { NgxBootstrapIconsModule, allIcons } from 'ngx-bootstrap-icons'
import { ConfirmButtonComponent } from '../../common/confirm-button/confirm-button.component' import { ConfirmButtonComponent } from '../../common/confirm-button/confirm-button.component'
import { SystemStatusDialogComponent } from '../../common/system-status-dialog/system-status-dialog.component'
import { SystemStatusService } from 'src/app/services/system-status.service'
import {
SystemStatus,
InstallType,
SystemStatusItemStatus,
} from 'src/app/data/system-status'
const savedViews = [ const savedViews = [
{ id: 1, name: 'view1', show_in_sidebar: true, show_on_dashboard: true }, { id: 1, name: 'view1', show_in_sidebar: true, show_on_dashboard: true },
@ -65,6 +74,8 @@ describe('SettingsComponent', () => {
let userService: UserService let userService: UserService
let permissionsService: PermissionsService let permissionsService: PermissionsService
let groupService: GroupService let groupService: GroupService
let modalService: NgbModal
let systemStatusService: SystemStatusService
beforeEach(async () => { beforeEach(async () => {
TestBed.configureTestingModule({ TestBed.configureTestingModule({
@ -96,6 +107,7 @@ describe('SettingsComponent', () => {
NgbAlertModule, NgbAlertModule,
NgSelectModule, NgSelectModule,
NgxBootstrapIconsModule.pick(allIcons), NgxBootstrapIconsModule.pick(allIcons),
NgbModalModule,
], ],
}).compileComponents() }).compileComponents()
@ -107,6 +119,8 @@ describe('SettingsComponent', () => {
settingsService.currentUser = users[0] settingsService.currentUser = users[0]
userService = TestBed.inject(UserService) userService = TestBed.inject(UserService)
permissionsService = TestBed.inject(PermissionsService) permissionsService = TestBed.inject(PermissionsService)
modalService = TestBed.inject(NgbModal)
systemStatusService = TestBed.inject(SystemStatusService)
jest.spyOn(permissionsService, 'currentUserCan').mockReturnValue(true) jest.spyOn(permissionsService, 'currentUserCan').mockReturnValue(true)
jest jest
.spyOn(permissionsService, 'currentUserHasObjectPermissions') .spyOn(permissionsService, 'currentUserHasObjectPermissions')
@ -372,4 +386,54 @@ describe('SettingsComponent', () => {
fixture.detectChanges() fixture.detectChanges()
expect(toastErrorSpy).toBeCalled() expect(toastErrorSpy).toBeCalled()
}) })
it('should load system status on initialize, show errors if needed', () => {
const status: SystemStatus = {
pngx_version: '2.4.3',
server_os: 'macOS-14.1.1-arm64-arm-64bit',
install_type: InstallType.BareMetal,
storage: { total: 494384795648, available: 13573525504 },
database: {
type: 'sqlite',
url: '/paperless-ngx/data/db.sqlite3',
status: SystemStatusItemStatus.ERROR,
error: null,
migration_status: {
latest_migration: 'socialaccount.0006_alter_socialaccount_extra_data',
unapplied_migrations: [],
},
},
tasks: {
redis_url: 'redis://localhost:6379',
redis_status: SystemStatusItemStatus.ERROR,
redis_error:
'Error 61 connecting to localhost:6379. Connection refused.',
celery_status: SystemStatusItemStatus.ERROR,
index_status: SystemStatusItemStatus.OK,
index_last_modified: new Date().toISOString(),
index_error: null,
classifier_status: SystemStatusItemStatus.OK,
classifier_last_trained: new Date().toISOString(),
classifier_error: null,
},
}
jest.spyOn(systemStatusService, 'get').mockReturnValue(of(status))
completeSetup()
expect(component['systemStatus']).toEqual(status) // private
expect(component.systemStatusHasErrors).toBeTruthy()
// coverage
component['systemStatus'].database.status = SystemStatusItemStatus.OK
component['systemStatus'].tasks.redis_status = SystemStatusItemStatus.OK
component['systemStatus'].tasks.celery_status = SystemStatusItemStatus.OK
expect(component.systemStatusHasErrors).toBeFalsy()
})
it('should open system status dialog', () => {
const modalOpenSpy = jest.spyOn(modalService, 'open')
completeSetup()
component.showSystemStatus()
expect(modalOpenSpy).toHaveBeenCalledWith(SystemStatusDialogComponent, {
size: 'xl',
})
})
}) })

View File

@ -9,7 +9,11 @@ import {
} from '@angular/core' } from '@angular/core'
import { FormGroup, FormControl } from '@angular/forms' import { FormGroup, FormControl } from '@angular/forms'
import { ActivatedRoute, Router } from '@angular/router' import { ActivatedRoute, Router } from '@angular/router'
import { NgbNavChangeEvent } from '@ng-bootstrap/ng-bootstrap' import {
NgbModal,
NgbModalRef,
NgbNavChangeEvent,
} from '@ng-bootstrap/ng-bootstrap'
import { DirtyComponent, dirtyCheck } from '@ngneat/dirty-check-forms' import { DirtyComponent, dirtyCheck } from '@ngneat/dirty-check-forms'
import { TourService } from 'ngx-ui-tour-ng-bootstrap' import { TourService } from 'ngx-ui-tour-ng-bootstrap'
import { import {
@ -40,6 +44,12 @@ import {
} from 'src/app/services/settings.service' } from 'src/app/services/settings.service'
import { ToastService, Toast } from 'src/app/services/toast.service' import { ToastService, Toast } from 'src/app/services/toast.service'
import { ComponentWithPermissions } from '../../with-permissions/with-permissions.component' import { ComponentWithPermissions } from '../../with-permissions/with-permissions.component'
import { SystemStatusDialogComponent } from '../../common/system-status-dialog/system-status-dialog.component'
import { SystemStatusService } from 'src/app/services/system-status.service'
import {
SystemStatusItemStatus,
SystemStatus,
} from 'src/app/data/system-status'
enum SettingsNavIDs { enum SettingsNavIDs {
General = 1, General = 1,
@ -111,6 +121,18 @@ export class SettingsComponent
users: User[] users: User[]
groups: Group[] groups: Group[]
private systemStatus: SystemStatus
get systemStatusHasErrors(): boolean {
return (
this.systemStatus.database.status === SystemStatusItemStatus.ERROR ||
this.systemStatus.tasks.redis_status === SystemStatusItemStatus.ERROR ||
this.systemStatus.tasks.celery_status === SystemStatusItemStatus.ERROR ||
this.systemStatus.tasks.index_status === SystemStatusItemStatus.ERROR ||
this.systemStatus.tasks.classifier_status === SystemStatusItemStatus.ERROR
)
}
get computedDateLocale(): string { get computedDateLocale(): string {
return ( return (
this.settingsForm.value.dateLocale || this.settingsForm.value.dateLocale ||
@ -131,7 +153,9 @@ export class SettingsComponent
private usersService: UserService, private usersService: UserService,
private groupsService: GroupService, private groupsService: GroupService,
private router: Router, private router: Router,
public permissionsService: PermissionsService public permissionsService: PermissionsService,
private modalService: NgbModal,
private systemStatusService: SystemStatusService
) { ) {
super() super()
this.settings.settingsSaved.subscribe(() => { this.settings.settingsSaved.subscribe(() => {
@ -360,6 +384,17 @@ export class SettingsComponent
// prevents loss of unsaved changes // prevents loss of unsaved changes
this.settingsForm.patchValue(currentFormValue) this.settingsForm.patchValue(currentFormValue)
} }
if (
this.permissionsService.currentUserCan(
PermissionAction.View,
PermissionType.Admin
)
) {
this.systemStatusService.get().subscribe((status) => {
this.systemStatus = status
})
}
} }
private emptyGroup(group: FormGroup) { private emptyGroup(group: FormGroup) {
@ -565,4 +600,14 @@ export class SettingsComponent
clearThemeColor() { clearThemeColor() {
this.settingsForm.get('themeColor').patchValue('') this.settingsForm.get('themeColor').patchValue('')
} }
showSystemStatus() {
const modal: NgbModalRef = this.modalService.open(
SystemStatusDialogComponent,
{
size: 'xl',
}
)
modal.componentInstance.status = this.systemStatus
}
} }

View File

@ -32,7 +32,7 @@
[formControl]="searchField" [ngbTypeahead]="searchAutoComplete" (keyup)="searchFieldKeyup($event)" [formControl]="searchField" [ngbTypeahead]="searchAutoComplete" (keyup)="searchFieldKeyup($event)"
(selectItem)="itemSelected($event)" i18n-placeholder> (selectItem)="itemSelected($event)" i18n-placeholder>
@if (!searchFieldEmpty) { @if (!searchFieldEmpty) {
<button type="button" class="btn btn-link btn-sm px-0 position-absolute top-0 end-0" (click)="resetSearchField()"> <button type="button" class="btn btn-link btn-sm ps-0 pe-1 position-absolute top-0 end-0" (click)="resetSearchField()">
<i-bs width="1em" height="1em" name="x"></i-bs> <i-bs width="1em" height="1em" name="x"></i-bs>
</button> </button>
} }

View File

@ -1,6 +1,9 @@
<form [formGroup]="objectForm" (ngSubmit)="save()" autocomplete="off"> <form [formGroup]="objectForm" (ngSubmit)="save()" autocomplete="off">
<div class="modal-header"> <div class="modal-header">
<h4 class="modal-title" id="modal-basic-title">{{getTitle()}}</h4> <h4 class="modal-title" id="modal-basic-title">{{getTitle()}}</h4>
@if (object?.id) {
<span class="badge bg-primary text-primary-text-contrast ms-2">ID: {{object.id}}</span>
}
<button type="button" [disabled]="!closeEnabled" class="btn-close" aria-label="Close" (click)="cancel()"> <button type="button" [disabled]="!closeEnabled" class="btn-close" aria-label="Close" (click)="cancel()">
</button> </button>
</div> </div>

View File

@ -1,6 +1,9 @@
<form [formGroup]="objectForm" (ngSubmit)="save()" autocomplete="off"> <form [formGroup]="objectForm" (ngSubmit)="save()" autocomplete="off">
<div class="modal-header"> <div class="modal-header">
<h4 class="modal-title" id="modal-basic-title">{{getTitle()}}</h4> <h4 class="modal-title" id="modal-basic-title">{{getTitle()}}</h4>
@if (object?.id) {
<span class="badge bg-primary text-primary-text-contrast ms-2">ID: {{object.id}}</span>
}
<button type="button" [disabled]="!closeEnabled" class="btn-close" aria-label="Close" (click)="cancel()"> <button type="button" [disabled]="!closeEnabled" class="btn-close" aria-label="Close" (click)="cancel()">
</button> </button>
</div> </div>

View File

@ -1,6 +1,9 @@
<form [formGroup]="objectForm" (ngSubmit)="save()" autocomplete="off"> <form [formGroup]="objectForm" (ngSubmit)="save()" autocomplete="off">
<div class="modal-header"> <div class="modal-header">
<h4 class="modal-title" id="modal-basic-title">{{getTitle()}}</h4> <h4 class="modal-title" id="modal-basic-title">{{getTitle()}}</h4>
@if (object?.id) {
<span class="badge bg-primary text-primary-text-contrast ms-2">ID: {{object.id}}</span>
}
<button type="button" [disabled]="!closeEnabled" class="btn-close" aria-label="Close" (click)="cancel()"> <button type="button" [disabled]="!closeEnabled" class="btn-close" aria-label="Close" (click)="cancel()">
</button> </button>
</div> </div>

View File

@ -1,6 +1,9 @@
<form [formGroup]="objectForm" (ngSubmit)="save()" autocomplete="off"> <form [formGroup]="objectForm" (ngSubmit)="save()" autocomplete="off">
<div class="modal-header"> <div class="modal-header">
<h4 class="modal-title" id="modal-basic-title">{{getTitle()}}</h4> <h4 class="modal-title" id="modal-basic-title">{{getTitle()}}</h4>
@if (object?.id) {
<span class="badge bg-primary text-primary-text-contrast ms-2">ID: {{object.id}}</span>
}
<button type="button" [disabled]="!closeEnabled" class="btn-close" aria-label="Close" (click)="cancel()"> <button type="button" [disabled]="!closeEnabled" class="btn-close" aria-label="Close" (click)="cancel()">
</button> </button>
</div> </div>

View File

@ -1,6 +1,9 @@
<form [formGroup]="objectForm" (ngSubmit)="save()" autocomplete="off"> <form [formGroup]="objectForm" (ngSubmit)="save()" autocomplete="off">
<div class="modal-header"> <div class="modal-header">
<h4 class="modal-title" id="modal-basic-title">{{getTitle()}}</h4> <h4 class="modal-title" id="modal-basic-title">{{getTitle()}}</h4>
@if (object?.id) {
<span class="badge bg-primary text-primary-text-contrast ms-2">ID: {{object.id}}</span>
}
<button type="button" [disabled]="!closeEnabled" class="btn-close" aria-label="Close" (click)="cancel()"> <button type="button" [disabled]="!closeEnabled" class="btn-close" aria-label="Close" (click)="cancel()">
</button> </button>
</div> </div>

View File

@ -1,6 +1,9 @@
<form [formGroup]="objectForm" (ngSubmit)="save()" autocomplete="off"> <form [formGroup]="objectForm" (ngSubmit)="save()" autocomplete="off">
<div class="modal-header"> <div class="modal-header">
<h4 class="modal-title" id="modal-basic-title">{{getTitle()}}</h4> <h4 class="modal-title" id="modal-basic-title">{{getTitle()}}</h4>
@if (object?.id) {
<span class="badge bg-primary text-primary-text-contrast ms-2">ID: {{object.id}}</span>
}
<button type="button" [disabled]="!closeEnabled" class="btn-close" aria-label="Close" (click)="cancel()"> <button type="button" [disabled]="!closeEnabled" class="btn-close" aria-label="Close" (click)="cancel()">
</button> </button>
</div> </div>
@ -29,7 +32,6 @@
@if (showActionParamField) { @if (showActionParamField) {
<pngx-input-text i18n-title title="Action parameter" formControlName="action_parameter" [error]="error?.action_parameter"></pngx-input-text> <pngx-input-text i18n-title title="Action parameter" formControlName="action_parameter" [error]="error?.action_parameter"></pngx-input-text>
} }
<p class="small fst-italic mt-5" i18n>Assignments specified here will supersede any consumption templates.</p>
<pngx-input-select i18n-title title="Assign title from" [items]="metadataTitleOptions" formControlName="assign_title_from"></pngx-input-select> <pngx-input-select i18n-title title="Assign title from" [items]="metadataTitleOptions" formControlName="assign_title_from"></pngx-input-select>
<pngx-input-tags [allowCreate]="false" formControlName="assign_tags"></pngx-input-tags> <pngx-input-tags [allowCreate]="false" formControlName="assign_tags"></pngx-input-tags>
<pngx-input-select i18n-title title="Assign document type" [items]="documentTypes" [allowNull]="true" formControlName="assign_document_type"></pngx-input-select> <pngx-input-select i18n-title title="Assign document type" [items]="documentTypes" [allowNull]="true" formControlName="assign_document_type"></pngx-input-select>

View File

@ -1,6 +1,9 @@
<form [formGroup]="objectForm" (ngSubmit)="save()" autocomplete="off"> <form [formGroup]="objectForm" (ngSubmit)="save()" autocomplete="off">
<div class="modal-header"> <div class="modal-header">
<h4 class="modal-title" id="modal-basic-title">{{getTitle()}}</h4> <h4 class="modal-title" id="modal-basic-title">{{getTitle()}}</h4>
@if (object?.id) {
<span class="badge bg-primary text-primary-text-contrast ms-2">ID: {{object.id}}</span>
}
<button type="button" [disabled]="!closeEnabled" class="btn-close" aria-label="Close" (click)="cancel()"> <button type="button" [disabled]="!closeEnabled" class="btn-close" aria-label="Close" (click)="cancel()">
</button> </button>
</div> </div>

View File

@ -1,6 +1,9 @@
<form [formGroup]="objectForm" (ngSubmit)="save()" autocomplete="off"> <form [formGroup]="objectForm" (ngSubmit)="save()" autocomplete="off">
<div class="modal-header"> <div class="modal-header">
<h4 class="modal-title" id="modal-basic-title">{{getTitle()}}</h4> <h4 class="modal-title" id="modal-basic-title">{{getTitle()}}</h4>
@if (object?.id) {
<span class="badge bg-primary text-primary-text-contrast ms-2">ID: {{object.id}}</span>
}
<button type="button" [disabled]="!closeEnabled" class="btn-close" aria-label="Close" (click)="cancel()"> <button type="button" [disabled]="!closeEnabled" class="btn-close" aria-label="Close" (click)="cancel()">
</button> </button>
</div> </div>

View File

@ -1,6 +1,9 @@
<form [formGroup]="objectForm" (ngSubmit)="save()" autocomplete="off"> <form [formGroup]="objectForm" (ngSubmit)="save()" autocomplete="off">
<div class="modal-header"> <div class="modal-header">
<h4 class="modal-title" id="modal-basic-title">{{getTitle()}}</h4> <h4 class="modal-title" id="modal-basic-title">{{getTitle()}}</h4>
@if (object?.id) {
<span class="badge bg-primary text-primary-text-contrast ms-2">ID: {{object.id}}</span>
}
<button type="button" [disabled]="!closeEnabled" class="btn-close" aria-label="Close" (click)="cancel()"> <button type="button" [disabled]="!closeEnabled" class="btn-close" aria-label="Close" (click)="cancel()">
</button> </button>
</div> </div>

View File

@ -1,6 +1,9 @@
<form [formGroup]="objectForm" (ngSubmit)="save()" autocomplete="off"> <form [formGroup]="objectForm" (ngSubmit)="save()" autocomplete="off">
<div class="modal-header"> <div class="modal-header">
<h4 class="modal-title" id="modal-basic-title">{{getTitle()}}</h4> <h4 class="modal-title" id="modal-basic-title">{{getTitle()}}</h4>
@if (object?.id) {
<span class="badge bg-primary text-primary-text-contrast ms-2">ID: {{object.id}}</span>
}
<button type="button" [disabled]="!closeEnabled" class="btn-close" aria-label="Close" (click)="cancel()"> <button type="button" [disabled]="!closeEnabled" class="btn-close" aria-label="Close" (click)="cancel()">
</button> </button>
</div> </div>
@ -35,7 +38,7 @@
<div ngbAccordionItem> <div ngbAccordionItem>
<div ngbAccordionHeader> <div ngbAccordionHeader>
<button ngbAccordionButton>{{i + 1}}. {{getTriggerTypeOptionName(triggerFields.controls[i].value.type)}} <button ngbAccordionButton>{{i + 1}}. {{getTriggerTypeOptionName(triggerFields.controls[i].value.type)}}
@if(trigger.id > -1) { @if(trigger.id) {
<span class="badge bg-primary text-primary-text-contrast ms-2">ID: {{trigger.id}}</span> <span class="badge bg-primary text-primary-text-contrast ms-2">ID: {{trigger.id}}</span>
} }
<pngx-confirm-button <pngx-confirm-button
@ -77,7 +80,7 @@
<div ngbAccordionItem cdkDrag [formGroup]="actionFields.controls[i]"> <div ngbAccordionItem cdkDrag [formGroup]="actionFields.controls[i]">
<div ngbAccordionHeader> <div ngbAccordionHeader>
<button ngbAccordionButton>{{i + 1}}. {{getActionTypeOptionName(actionFields.controls[i].value.type)}} <button ngbAccordionButton>{{i + 1}}. {{getActionTypeOptionName(actionFields.controls[i].value.type)}}
@if(action.id > -1) { @if(action.id) {
<span class="badge bg-primary text-primary-text-contrast ms-2">ID: {{action.id}}</span> <span class="badge bg-primary text-primary-text-contrast ms-2">ID: {{action.id}}</span>
} }
<pngx-confirm-button <pngx-confirm-button
@ -91,8 +94,67 @@
</div> </div>
<div ngbAccordionCollapse> <div ngbAccordionCollapse>
<div ngbAccordionBody> <div ngbAccordionBody>
<pngx-input-select i18n-title title="Action type" [horizontal]="true" [items]="actionTypeOptions" formControlName="type"></pngx-input-select> <ng-template [ngTemplateOutlet]="actionForm" [ngTemplateOutletContext]="{ formGroup: actionFields.controls[i], action: action }"></ng-template>
</div>
</div>
</div>
}
</div>
</ng-template>
</div>
</div>
</div>
</div>
</div>
<div class="modal-footer">
@if (error?.non_field_errors) {
<span class="text-danger"><ng-container i18n>Error</ng-container>: {{error.non_field_errors}}</span>
}
<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>
</div>
</form>
<ng-template #triggerForm let-formGroup="formGroup" let-trigger="trigger">
<div [formGroup]="formGroup">
<input type="hidden" formControlName="id" /> <input type="hidden" formControlName="id" />
<pngx-input-select i18n-title title="Trigger type" [horizontal]="true" [items]="triggerTypeOptions" formControlName="type"></pngx-input-select>
<p class="small" i18n>Trigger for documents that match <em>all</em> filters specified below.</p>
<div class="row">
<div class="col">
<pngx-input-text i18n-title title="Filter filename" formControlName="filter_filename" i18n-hint hint="Apply to documents that match this filename. Wildcards such as *.pdf or *invoice* are allowed. Case insensitive." [error]="error?.filter_filename"></pngx-input-text>
@if (formGroup.get('type').value === WorkflowTriggerType.Consumption) {
<pngx-input-select i18n-title title="Filter sources" [items]="sourceOptions" [multiple]="true" formControlName="sources" [error]="error?.sources"></pngx-input-select>
<pngx-input-text i18n-title title="Filter path" formControlName="filter_path" i18n-hint hint="Apply to documents that match this path. Wildcards specified as * are allowed. Case-normalized.</a>" [error]="error?.filter_path"></pngx-input-text>
<pngx-input-select i18n-title title="Filter mail rule" [items]="mailRules" [allowNull]="true" formControlName="filter_mailrule" i18n-hint hint="Apply to documents consumed via this mail rule." [error]="error?.filter_mailrule"></pngx-input-select>
}
@if (formGroup.get('type').value === WorkflowTriggerType.DocumentAdded || formGroup.get('type').value === WorkflowTriggerType.DocumentUpdated) {
<pngx-input-select i18n-title title="Content matching algorithm" [items]="getMatchingAlgorithms()" formControlName="matching_algorithm"></pngx-input-select>
@if (patternRequired) {
<pngx-input-text i18n-title title="Content matching pattern" formControlName="match" [error]="error?.match"></pngx-input-text>
}
@if (patternRequired) {
<pngx-input-check i18n-title title="Case insensitive" formControlName="is_insensitive"></pngx-input-check>
}
}
</div>
@if (formGroup.get('type').value === WorkflowTriggerType.DocumentAdded || formGroup.get('type').value === WorkflowTriggerType.DocumentUpdated) {
<div class="col-md-6">
<pngx-input-tags [allowCreate]="false" i18n-title title="Has tags" formControlName="filter_has_tags"></pngx-input-tags>
<pngx-input-select i18n-title title="Has correspondent" [items]="correspondents" [allowNull]="true" formControlName="filter_has_correspondent"></pngx-input-select>
<pngx-input-select i18n-title title="Has document type" [items]="documentTypes" [allowNull]="true" formControlName="filter_has_document_type"></pngx-input-select>
</div>
}
</div>
</div>
</ng-template>
<ng-template #actionForm let-formGroup="formGroup" let action="action">
<div [formGroup]="formGroup">
<input type="hidden" formControlName="id" />
<pngx-input-select i18n-title title="Action type" [horizontal]="true" [items]="actionTypeOptions" formControlName="type"></pngx-input-select>
@switch(formGroup.get('type').value) {
@case ( WorkflowActionType.Assignment) {
<div class="row"> <div class="row">
<div class="col"> <div class="col">
<pngx-input-text i18n-title title="Assign title" formControlName="assign_title" i18n-hint hint="Can include some placeholders, see <a target='_blank' href='https://docs.paperless-ngx.com/usage/#workflows'>documentation</a>." [error]="error?.actions?.[i]?.assign_title"></pngx-input-text> <pngx-input-text i18n-title title="Assign title" formControlName="assign_title" i18n-hint hint="Can include some placeholders, see <a target='_blank' href='https://docs.paperless-ngx.com/usage/#workflows'>documentation</a>." [error]="error?.actions?.[i]?.assign_title"></pngx-input-text>
@ -147,57 +209,93 @@
</div> </div>
</div> </div>
</div> </div>
</div>
</div>
</div>
} }
</div> @case (WorkflowActionType.Removal) {
</ng-template>
</div>
</div>
</div>
</div>
</div>
<div class="modal-footer">
@if (error?.non_field_errors) {
<span class="text-danger"><ng-container i18n>Error</ng-container>: {{error.non_field_errors}}</span>
}
<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>
</div>
</form>
<ng-template #triggerForm let-formGroup="formGroup" let-trigger="trigger">
<div [formGroup]="formGroup">
<input type="hidden" formControlName="id" />
<pngx-input-select i18n-title title="Trigger type" [horizontal]="true" [items]="triggerTypeOptions" formControlName="type"></pngx-input-select>
<p class="small" i18n>Trigger for documents that match <em>all</em> filters specified below.</p>
<div class="row"> <div class="row">
<div class="col"> <div class="col">
<pngx-input-text i18n-title title="Filter filename" formControlName="filter_filename" i18n-hint hint="Apply to documents that match this filename. Wildcards such as *.pdf or *invoice* are allowed. Case insensitive." [error]="error?.filter_filename"></pngx-input-text> <h6 class="form-label" i18n>Remove tags</h6>
@if (formGroup.get('type').value === WorkflowTriggerType.Consumption) { <pngx-input-switch i18n-title title="Remove all" [horizontal]="true" formControlName="remove_all_tags"></pngx-input-switch>
<pngx-input-select i18n-title title="Filter sources" [items]="sourceOptions" [multiple]="true" formControlName="sources" [error]="error?.sources"></pngx-input-select> <div class="mt-n3">
<pngx-input-text i18n-title title="Filter path" formControlName="filter_path" i18n-hint hint="Apply to documents that match this path. Wildcards specified as * are allowed. Case-normalized.</a>" [error]="error?.filter_path"></pngx-input-text> <pngx-input-tags [allowCreate]="false" title="" formControlName="remove_tags"></pngx-input-tags>
<pngx-input-select i18n-title title="Filter mail rule" [items]="mailRules" [allowNull]="true" formControlName="filter_mailrule" i18n-hint hint="Apply to documents consumed via this mail rule." [error]="error?.filter_mailrule"></pngx-input-select> </div>
}
@if (formGroup.get('type').value === WorkflowTriggerType.DocumentAdded || formGroup.get('type').value === WorkflowTriggerType.DocumentUpdated) { <h6 class="form-label" i18n>Remove correspondents</h6>
<pngx-input-select i18n-title title="Content matching algorithm" [items]="getMatchingAlgorithms()" formControlName="matching_algorithm"></pngx-input-select> <pngx-input-switch i18n-title title="Remove all" [horizontal]="true" formControlName="remove_all_correspondents"></pngx-input-switch>
@if (patternRequired) { <div class="mt-n3">
<pngx-input-text i18n-title title="Content matching pattern" formControlName="match" [error]="error?.match"></pngx-input-text> <pngx-input-select i18n-title title="" multiple="true" [items]="correspondents" formControlName="remove_correspondents"></pngx-input-select>
} </div>
@if (patternRequired) {
<pngx-input-check i18n-title title="Case insensitive" formControlName="is_insensitive"></pngx-input-check> <h6 class="form-label" i18n>Remove document types</h6>
} <pngx-input-switch i18n-title title="Remove all" [horizontal]="true" formControlName="remove_all_document_types"></pngx-input-switch>
} <div class="mt-n3">
<pngx-input-select i18n-title title="" multiple="true" [items]="documentTypes" formControlName="remove_document_types"></pngx-input-select>
</div>
<h6 class="form-label" i18n>Remove storage paths</h6>
<pngx-input-switch i18n-title title="Remove all" [horizontal]="true" formControlName="remove_all_storage_paths"></pngx-input-switch>
<div class="mt-n3">
<pngx-input-select i18n-title title="" multiple="true" [items]="storagePaths" formControlName="remove_storage_paths"></pngx-input-select>
</div>
<h6 class="form-label" i18n>Remove custom fields</h6>
<pngx-input-switch i18n-title title="Remove all" [horizontal]="true" formControlName="remove_all_custom_fields"></pngx-input-switch>
<div class="mt-n3">
<pngx-input-select i18n-title title="" multiple="true" [items]="customFields" formControlName="remove_custom_fields"></pngx-input-select>
</div>
</div>
<div class="col">
<h6 class="form-label" i18n>Remove owners</h6>
<pngx-input-switch i18n-title title="Remove all" [horizontal]="true" formControlName="remove_all_owners"></pngx-input-switch>
<div class="mt-n3">
<pngx-input-select i18n-title title="" multiple="true" [items]="users" bindLabel="username" formControlName="remove_owners"></pngx-input-select>
</div>
<h6 class="form-label" i18n>Remove permissions</h6>
<pngx-input-switch i18n-title title="Remove all" [horizontal]="true" formControlName="remove_all_permissions"></pngx-input-switch>
<div>
<label class="form-label" i18n>View permissions</label>
<div class="mb-2">
<div class="row mb-1">
<div class="col-lg-3">
<label class="form-label d-block my-2 text-nowrap" i18n>Users:</label>
</div>
<div class="col-lg-9">
<pngx-permissions-user type="view" formControlName="remove_view_users"></pngx-permissions-user>
</div>
</div>
<div class="row">
<div class="col-lg-3">
<label class="form-label d-block my-2 text-nowrap" i18n>Groups:</label>
</div>
<div class="col-lg-9">
<pngx-permissions-group type="view" formControlName="remove_view_groups"></pngx-permissions-group>
</div>
</div>
</div>
<label class="form-label" i18n>Edit permissions</label>
<div>
<div class="row mb-1">
<div class="col-lg-3">
<label class="form-label d-block my-2 text-nowrap" i18n>Users:</label>
</div>
<div class="col-lg-9">
<pngx-permissions-user type="change" formControlName="remove_change_users"></pngx-permissions-user>
</div>
</div>
<div class="row">
<div class="col-lg-3">
<label class="form-label d-block my-2 text-nowrap" i18n>Groups:</label>
</div>
<div class="col-lg-9">
<pngx-permissions-group type="change" formControlName="remove_change_groups"></pngx-permissions-group>
</div>
</div>
<small class="form-text text-muted text-end d-block" i18n>Edit permissions also grant viewing permissions</small>
</div>
</div>
</div> </div>
@if (formGroup.get('type').value === WorkflowTriggerType.DocumentAdded || formGroup.get('type').value === WorkflowTriggerType.DocumentUpdated) {
<div class="col-md-6">
<pngx-input-tags [allowCreate]="false" i18n-title title="Has tags" formControlName="filter_has_tags"></pngx-input-tags>
<pngx-input-select i18n-title title="Has correspondent" [items]="correspondents" [allowNull]="true" formControlName="filter_has_correspondent"></pngx-input-select>
<pngx-input-select i18n-title title="Has document type" [items]="documentTypes" [allowNull]="true" formControlName="filter_has_document_type"></pngx-input-select>
</div> </div>
} }
</div> }
</div> </div>
</ng-template> </ng-template>

View File

@ -235,4 +235,103 @@ describe('WorkflowEditDialogComponent', () => {
MATCHING_ALGORITHMS.find((a) => a.id === MATCH_AUTO) MATCHING_ALGORITHMS.find((a) => a.id === MATCH_AUTO)
) )
}) })
it('should disable or enable action fields based on removal action type', () => {
const workflow: Workflow = {
name: 'Workflow 1',
id: 1,
order: 1,
enabled: true,
triggers: [],
actions: [
{
id: 1,
type: WorkflowActionType.Removal,
remove_all_tags: true,
remove_all_document_types: true,
remove_all_correspondents: true,
remove_all_storage_paths: true,
remove_all_custom_fields: true,
remove_all_owners: true,
remove_all_permissions: true,
},
],
}
component.object = workflow
component.ngOnInit()
component['checkRemovalActionFields'](workflow)
// Assert that the action fields are disabled or enabled correctly
expect(
component.actionFields.at(0).get('remove_tags').disabled
).toBeTruthy()
expect(
component.actionFields.at(0).get('remove_document_types').disabled
).toBeTruthy()
expect(
component.actionFields.at(0).get('remove_correspondents').disabled
).toBeTruthy()
expect(
component.actionFields.at(0).get('remove_storage_paths').disabled
).toBeTruthy()
expect(
component.actionFields.at(0).get('remove_custom_fields').disabled
).toBeTruthy()
expect(
component.actionFields.at(0).get('remove_owners').disabled
).toBeTruthy()
expect(
component.actionFields.at(0).get('remove_view_users').disabled
).toBeTruthy()
expect(
component.actionFields.at(0).get('remove_view_groups').disabled
).toBeTruthy()
expect(
component.actionFields.at(0).get('remove_change_users').disabled
).toBeTruthy()
expect(
component.actionFields.at(0).get('remove_change_groups').disabled
).toBeTruthy()
workflow.actions[0].remove_all_tags = false
workflow.actions[0].remove_all_document_types = false
workflow.actions[0].remove_all_correspondents = false
workflow.actions[0].remove_all_storage_paths = false
workflow.actions[0].remove_all_custom_fields = false
workflow.actions[0].remove_all_owners = false
workflow.actions[0].remove_all_permissions = false
component['checkRemovalActionFields'](workflow)
// Assert that the action fields are disabled or enabled correctly
expect(component.actionFields.at(0).get('remove_tags').disabled).toBeFalsy()
expect(
component.actionFields.at(0).get('remove_document_types').disabled
).toBeFalsy()
expect(
component.actionFields.at(0).get('remove_correspondents').disabled
).toBeFalsy()
expect(
component.actionFields.at(0).get('remove_storage_paths').disabled
).toBeFalsy()
expect(
component.actionFields.at(0).get('remove_custom_fields').disabled
).toBeFalsy()
expect(
component.actionFields.at(0).get('remove_owners').disabled
).toBeFalsy()
expect(
component.actionFields.at(0).get('remove_view_users').disabled
).toBeFalsy()
expect(
component.actionFields.at(0).get('remove_view_groups').disabled
).toBeFalsy()
expect(
component.actionFields.at(0).get('remove_change_users').disabled
).toBeFalsy()
expect(
component.actionFields.at(0).get('remove_change_groups').disabled
).toBeFalsy()
})
}) })

View File

@ -68,6 +68,10 @@ export const WORKFLOW_ACTION_OPTIONS = [
id: WorkflowActionType.Assignment, id: WorkflowActionType.Assignment,
name: $localize`Assignment`, name: $localize`Assignment`,
}, },
{
id: WorkflowActionType.Removal,
name: $localize`Removal`,
},
] ]
const TRIGGER_MATCHING_ALGORITHMS = MATCHING_ALGORITHMS.filter( const TRIGGER_MATCHING_ALGORITHMS = MATCHING_ALGORITHMS.filter(
@ -84,6 +88,7 @@ export class WorkflowEditDialogComponent
implements OnInit implements OnInit
{ {
public WorkflowTriggerType = WorkflowTriggerType public WorkflowTriggerType = WorkflowTriggerType
public WorkflowActionType = WorkflowActionType
templates: Workflow[] templates: Workflow[]
correspondents: Correspondent[] correspondents: Correspondent[]
@ -159,6 +164,124 @@ export class WorkflowEditDialogComponent
ngOnInit(): void { ngOnInit(): void {
super.ngOnInit() super.ngOnInit()
this.updateAllTriggerActionFields() this.updateAllTriggerActionFields()
this.objectForm.valueChanges.subscribe(
this.checkRemovalActionFields.bind(this)
)
this.checkRemovalActionFields(this.objectForm.value)
}
private checkRemovalActionFields(formWorkflow: Workflow) {
formWorkflow.actions
.filter((action) => action.type === WorkflowActionType.Removal)
.forEach((action, i) => {
if (action.remove_all_tags) {
this.actionFields
.at(i)
.get('remove_tags')
.disable({ emitEvent: false })
} else {
this.actionFields
.at(i)
.get('remove_tags')
.enable({ emitEvent: false })
}
if (action.remove_all_document_types) {
this.actionFields
.at(i)
.get('remove_document_types')
.disable({ emitEvent: false })
} else {
this.actionFields
.at(i)
.get('remove_document_types')
.enable({ emitEvent: false })
}
if (action.remove_all_correspondents) {
this.actionFields
.at(i)
.get('remove_correspondents')
.disable({ emitEvent: false })
} else {
this.actionFields
.at(i)
.get('remove_correspondents')
.enable({ emitEvent: false })
}
if (action.remove_all_storage_paths) {
this.actionFields
.at(i)
.get('remove_storage_paths')
.disable({ emitEvent: false })
} else {
this.actionFields
.at(i)
.get('remove_storage_paths')
.enable({ emitEvent: false })
}
if (action.remove_all_custom_fields) {
this.actionFields
.at(i)
.get('remove_custom_fields')
.disable({ emitEvent: false })
} else {
this.actionFields
.at(i)
.get('remove_custom_fields')
.enable({ emitEvent: false })
}
if (action.remove_all_owners) {
this.actionFields
.at(i)
.get('remove_owners')
.disable({ emitEvent: false })
} else {
this.actionFields
.at(i)
.get('remove_owners')
.enable({ emitEvent: false })
}
if (action.remove_all_permissions) {
this.actionFields
.at(i)
.get('remove_view_users')
.disable({ emitEvent: false })
this.actionFields
.at(i)
.get('remove_view_groups')
.disable({ emitEvent: false })
this.actionFields
.at(i)
.get('remove_change_users')
.disable({ emitEvent: false })
this.actionFields
.at(i)
.get('remove_change_groups')
.disable({ emitEvent: false })
} else {
this.actionFields
.at(i)
.get('remove_view_users')
.enable({ emitEvent: false })
this.actionFields
.at(i)
.get('remove_view_groups')
.enable({ emitEvent: false })
this.actionFields
.at(i)
.get('remove_change_users')
.enable({ emitEvent: false })
this.actionFields
.at(i)
.get('remove_change_groups')
.enable({ emitEvent: false })
}
})
} }
get triggerFields(): FormArray { get triggerFields(): FormArray {
@ -215,6 +338,31 @@ export class WorkflowEditDialogComponent
assign_change_users: new FormControl(action.assign_change_users), assign_change_users: new FormControl(action.assign_change_users),
assign_change_groups: new FormControl(action.assign_change_groups), assign_change_groups: new FormControl(action.assign_change_groups),
assign_custom_fields: new FormControl(action.assign_custom_fields), assign_custom_fields: new FormControl(action.assign_custom_fields),
remove_tags: new FormControl(action.remove_tags),
remove_all_tags: new FormControl(action.remove_all_tags),
remove_document_types: new FormControl(action.remove_document_types),
remove_all_document_types: new FormControl(
action.remove_all_document_types
),
remove_correspondents: new FormControl(action.remove_correspondents),
remove_all_correspondents: new FormControl(
action.remove_all_correspondents
),
remove_storage_paths: new FormControl(action.remove_storage_paths),
remove_all_storage_paths: new FormControl(
action.remove_all_storage_paths
),
remove_owners: new FormControl(action.remove_owners),
remove_all_owners: new FormControl(action.remove_all_owners),
remove_view_users: new FormControl(action.remove_view_users),
remove_view_groups: new FormControl(action.remove_view_groups),
remove_change_users: new FormControl(action.remove_change_users),
remove_change_groups: new FormControl(action.remove_change_groups),
remove_all_permissions: new FormControl(action.remove_all_permissions),
remove_custom_fields: new FormControl(action.remove_custom_fields),
remove_all_custom_fields: new FormControl(
action.remove_all_custom_fields
),
}), }),
{ emitEvent } { emitEvent }
) )
@ -290,6 +438,23 @@ export class WorkflowEditDialogComponent
assign_change_users: [], assign_change_users: [],
assign_change_groups: [], assign_change_groups: [],
assign_custom_fields: [], assign_custom_fields: [],
remove_tags: [],
remove_all_tags: false,
remove_document_types: [],
remove_all_document_types: false,
remove_correspondents: [],
remove_all_correspondents: false,
remove_storage_paths: [],
remove_all_storage_paths: false,
remove_owners: [],
remove_all_owners: false,
remove_view_users: [],
remove_view_groups: [],
remove_change_users: [],
remove_change_groups: [],
remove_all_permissions: false,
remove_custom_fields: [],
remove_all_custom_fields: false,
} }
this.object.actions.push(action) this.object.actions.push(action)
this.createActionField(action) this.createActionField(action)

View File

@ -1,4 +1,4 @@
<div class="paperless-input-select"> <div class="paperless-input-select" [class.disabled]="disabled">
<div> <div>
<ng-select name="inputId" [(ngModel)]="value" <ng-select name="inputId" [(ngModel)]="value"
[disabled]="disabled" [disabled]="disabled"

View File

@ -0,0 +1,11 @@
.paperless-input-select.disabled {
cursor: not-allowed;
::ng-deep ng-select {
pointer-events: none;
.ng-select-container {
background-color: var(--pngx-bg-alt) !important;
}
}
}

View File

@ -1,4 +1,4 @@
<div class="paperless-input-select"> <div class="paperless-input-select" [class.disabled]="disabled">
<div> <div>
<ng-select name="inputId" [(ngModel)]="value" <ng-select name="inputId" [(ngModel)]="value"
[disabled]="disabled" [disabled]="disabled"

View File

@ -0,0 +1,11 @@
.paperless-input-select.disabled {
cursor: not-allowed;
::ng-deep ng-select {
pointer-events: none;
.ng-select-container {
background-color: var(--pngx-bg-alt) !important;
}
}
}

View File

@ -1,6 +1,7 @@
// styles for ng-select child are in styles.scss // styles for ng-select child are in styles.scss
.paperless-input-select.disabled { .paperless-input-select.disabled {
.input-group { .input-group,
div > div {
cursor: not-allowed; cursor: not-allowed;
} }

View File

@ -19,7 +19,7 @@
} }
</h3> </h3>
</div> </div>
<div class="btn-toolbar col col-md-auto"> <div class="btn-toolbar col col-md-auto gap-2">
<ng-content></ng-content> <ng-content></ng-content>
</div> </div>
</div> </div>

View File

@ -0,0 +1,158 @@
<div class="modal-header">
<h5 class="modal-title" id="modal-basic-title" i18n>System Status</h5>
<button type="button" class="btn-close" aria-label="Close" (click)="close()"></button>
</div>
<div class="modal-body">
@if (!status) {
<div class="w-100 h-100 d-flex align-items-center justify-content-center">
<div>
<div class="spinner-border spinner-border-sm me-2" role="status"></div>
<ng-container i18n>Loading...</ng-container>
</div>
</div>
} @else {
<div class="row row-cols-1 row-cols-md-3 g-3">
<div class="col">
<div class="card bg-light h-100">
<div class="card-header">
<h5 class="card-title mb-0" i18n>Environment</h5>
</div>
<div class="card-body">
<dl class="card-text">
<dt i18n>Paperless-ngx Version</dt>
<dd>{{status.pngx_version}}</dd>
<dt i18n>Install Type</dt>
<dd>{{status.install_type}}</dd>
<dt i18n>Server OS</dt>
<dd>{{status.server_os}}</dd>
<dt i18n>Media Storage</dt>
<dd>
<ngb-progressbar style="height: 4px;" class="mt-2 mb-1" type="primary" [max]="status.storage.total" [value]="status.storage.total - status.storage.available"></ngb-progressbar>
<span class="small">{{status.storage.available | filesize}} <ng-container i18n>available</ng-container> ({{status.storage.total | filesize}} <ng-container i18n>total</ng-container>)</span>
</dd>
</dl>
</div>
</div>
</div>
<div class="col">
<div class="card bg-light h-100">
<div class="card-header">
<h5 class="card-title mb-0" i18n>Database</h5>
</div>
<div class="card-body">
<dl class="card-text">
<dt i18n>Type</dt>
<dd>{{status.database.type}}</dd>
<dt i18n>Status</dt>
<dd class="d-flex align-items-center">
{{status.database.status}}
@if (status.database.status === 'OK') {
<i-bs name="check-circle-fill" class="text-primary ms-2 lh-1" ngbPopover="{{status.database.url}}" triggers="mouseenter:mouseleave"></i-bs>
} @else {
<i-bs name="exclamation-triangle-fill" class="text-danger ms-2 lh-1" ngbPopover="{{status.database.url}}: {{status.database.error}}" triggers="mouseenter:mouseleave"></i-bs>
}
</dd>
<dt i18n>Migration Status</dt>
<dd class="d-flex align-items-center">
@if (status.database.migration_status.unapplied_migrations.length === 0) {
<ng-container>Up to date</ng-container><i-bs name="check-circle-fill" class="text-primary ms-2 lh-1" [ngbPopover]="migrationStatus" triggers="mouseenter:mouseleave"></i-bs>
} @else {
<ng-container>{{status.database.migration_status.unapplied_migrations.length}} Pending</ng-container><i-bs name="exclamation-triangle-fill" class="text-warning ms-2 lh-1" [ngbPopover]="migrationStatus" triggers="mouseenter:mouseleave"></i-bs>
}
<ng-template #migrationStatus>
<h6><ng-container i18n>Latest Migration</ng-container>:</h6> <span class="font-monospace small">{{status.database.migration_status.latest_migration}}</span>
@if (status.database.migration_status.unapplied_migrations.length > 0) {
<h6 class="mt-3"><ng-container i18n>Pending Migrations</ng-container>:</h6>
<ul>
@for (migration of status.database.migration_status.unapplied_migrations; track migration) {
<li class="font-monospace small">{{migration}}</li>
}
</ul>
}
</ng-template>
</dd>
</dl>
</div>
</div>
</div>
<div class="col">
<div class="card bg-light h-100">
<div class="card-header">
<h5 class="card-title mb-0" i18n>Tasks</h5>
</div>
<div class="card-body">
<dl class="card-text">
<dt i18n>Redis Status</dt>
<dd class="d-flex align-items-center">
{{status.tasks.redis_status}}
@if (status.tasks.redis_status === 'OK') {
<i-bs name="check-circle-fill" class="text-primary ms-2 lh-1" ngbPopover="{{status.tasks.redis_url}}" triggers="mouseenter:mouseleave"></i-bs>
} @else {
<i-bs name="exclamation-triangle-fill" class="text-danger ms-2 lh-1" ngbPopover="{{status.tasks.redis_url}}: {{status.tasks.redis_error}}" triggers="mouseenter:mouseleave"></i-bs>
}
</dd>
<dt i18n>Celery Status</dt>
<dd class="d-flex align-items-center">
{{status.tasks.celery_status}}
@if (status.tasks.celery_status === 'OK') {
<i-bs name="check-circle-fill" class="text-primary ms-2 lh-1"></i-bs>
} @else {
<i-bs name="exclamation-triangle-fill" class="text-danger ms-2 lh-1"></i-bs>
}
</dd>
<dt i18n>Search Index</dt>
<dd class="d-flex align-items-center">
{{status.tasks.index_status}}
@if (status.tasks.index_status === 'OK') {
@if (isStale(status.tasks.index_last_modified)) {
<i-bs name="exclamation-triangle-fill" class="text-warning ms-2 lh-1" [ngbPopover]="indexStatus" triggers="mouseenter:mouseleave"></i-bs>
} @else {
<i-bs name="check-circle-fill" class="text-primary ms-2 lh-1" [ngbPopover]="indexStatus" triggers="mouseenter:mouseleave"></i-bs>
}
} @else {
<i-bs name="exclamation-triangle-fill" class="text-danger ms-2 lh-1" ngbPopover="{{status.tasks.index_error}}" triggers="mouseenter:mouseleave"></i-bs>
}
</dd>
<ng-template #indexStatus>
<h6><ng-container i18n>Last Updated</ng-container>:</h6> <span class="font-monospace small">{{status.tasks.index_last_modified | customDate:'medium'}}</span>
</ng-template>
<dt i18n>Classifier</dt>
<dd class="d-flex align-items-center">
{{status.tasks.classifier_status}}
@if (status.tasks.classifier_status === 'OK') {
@if (isStale(status.tasks.classifier_last_trained)) {
<i-bs name="exclamation-triangle-fill" class="text-warning ms-2 lh-1" [ngbPopover]="classifierStatus" triggers="mouseenter:mouseleave"></i-bs>
} @else {
<i-bs name="check-circle-fill" class="text-primary ms-2 lh-1" [ngbPopover]="classifierStatus" triggers="mouseenter:mouseleave"></i-bs>
}
} @else {
<i-bs name="exclamation-triangle-fill" class="ms-2 lh-1"
[class.text-danger]="status.tasks.classifier_status === SystemStatusItemStatus.ERROR"
[class.text-warning]="status.tasks.classifier_status === SystemStatusItemStatus.WARNING"
ngbPopover="{{status.tasks.classifier_error}}"
triggers="mouseenter:mouseleave"></i-bs>
}
</dd>
<ng-template #classifierStatus>
<h6><ng-container i18n>Last Trained</ng-container>:</h6> <span class="font-monospace small">{{status.tasks.classifier_last_trained | customDate:'medium'}}</span>
</ng-template>
</dl>
</div>
</div>
</div>
</div>
}
</div>
<div class="modal-footer">
<button class="btn btn-sm btn-outline-secondary" (click)="copy()">
@if (!copied) {
<i-bs name="clipboard-fill"></i-bs>&nbsp;
}
@if (copied) {
<i-bs name="clipboard-check-fill"></i-bs>&nbsp;
}
<ng-container i18n>Copy</ng-container>
</button>
</div>

View File

@ -0,0 +1,103 @@
import {
ComponentFixture,
TestBed,
fakeAsync,
tick,
} from '@angular/core/testing'
import {
NgbActiveModal,
NgbModalModule,
NgbPopoverModule,
NgbProgressbarModule,
} from '@ng-bootstrap/ng-bootstrap'
import { Clipboard, ClipboardModule } from '@angular/cdk/clipboard'
import { SystemStatusDialogComponent } from './system-status-dialog.component'
import {
SystemStatusItemStatus,
InstallType,
SystemStatus,
} from 'src/app/data/system-status'
import { HttpClientTestingModule } from '@angular/common/http/testing'
import { NgxBootstrapIconsModule, allIcons } from 'ngx-bootstrap-icons'
import { NgxFilesizeModule } from 'ngx-filesize'
const status: SystemStatus = {
pngx_version: '2.4.3',
server_os: 'macOS-14.1.1-arm64-arm-64bit',
install_type: InstallType.BareMetal,
storage: { total: 494384795648, available: 13573525504 },
database: {
type: 'sqlite',
url: '/paperless-ngx/data/db.sqlite3',
status: SystemStatusItemStatus.ERROR,
error: null,
migration_status: {
latest_migration: 'socialaccount.0006_alter_socialaccount_extra_data',
unapplied_migrations: [],
},
},
tasks: {
redis_url: 'redis://localhost:6379',
redis_status: SystemStatusItemStatus.ERROR,
redis_error: 'Error 61 connecting to localhost:6379. Connection refused.',
celery_status: SystemStatusItemStatus.ERROR,
index_status: SystemStatusItemStatus.OK,
index_last_modified: new Date().toISOString(),
index_error: null,
classifier_status: SystemStatusItemStatus.OK,
classifier_last_trained: new Date().toISOString(),
classifier_error: null,
},
}
describe('SystemStatusDialogComponent', () => {
let component: SystemStatusDialogComponent
let fixture: ComponentFixture<SystemStatusDialogComponent>
let clipboard: Clipboard
beforeEach(async () => {
await TestBed.configureTestingModule({
declarations: [SystemStatusDialogComponent],
providers: [NgbActiveModal],
imports: [
NgbModalModule,
ClipboardModule,
HttpClientTestingModule,
NgxBootstrapIconsModule.pick(allIcons),
NgxFilesizeModule,
NgbPopoverModule,
NgbProgressbarModule,
],
}).compileComponents()
fixture = TestBed.createComponent(SystemStatusDialogComponent)
component = fixture.componentInstance
component.status = status
clipboard = TestBed.inject(Clipboard)
fixture.detectChanges()
})
it('should close the active modal', () => {
const closeSpy = jest.spyOn(component.activeModal, 'close')
component.close()
expect(closeSpy).toHaveBeenCalled()
})
it('should copy the system status to clipboard', fakeAsync(() => {
jest.spyOn(clipboard, 'copy')
component.copy()
expect(clipboard.copy).toHaveBeenCalledWith(
JSON.stringify(component.status)
)
expect(component.copied).toBeTruthy()
tick(3000)
expect(component.copied).toBeFalsy()
}))
it('should calculate if date is stale', () => {
const date = new Date()
date.setHours(date.getHours() - 25)
expect(component.isStale(date.toISOString())).toBeTruthy()
expect(component.isStale(date.toISOString(), 26)).toBeFalsy()
})
})

View File

@ -0,0 +1,43 @@
import { Component, Input } from '@angular/core'
import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap'
import {
SystemStatus,
SystemStatusItemStatus,
} from 'src/app/data/system-status'
import { SystemStatusService } from 'src/app/services/system-status.service'
import { Clipboard } from '@angular/cdk/clipboard'
@Component({
selector: 'pngx-system-status-dialog',
templateUrl: './system-status-dialog.component.html',
styleUrl: './system-status-dialog.component.scss',
})
export class SystemStatusDialogComponent {
public SystemStatusItemStatus = SystemStatusItemStatus
public status: SystemStatus
public copied: boolean = false
constructor(
public activeModal: NgbActiveModal,
private clipboard: Clipboard
) {}
public close() {
this.activeModal.close()
}
public copy() {
this.clipboard.copy(JSON.stringify(this.status))
this.copied = true
setTimeout(() => {
this.copied = false
}, 3000)
}
public isStale(dateStr: string, hours: number = 24): boolean {
const date = new Date(dateStr)
const now = new Date()
return now.getTime() - date.getTime() > hours * 60 * 60 * 1000
}
}

View File

@ -1,11 +1,11 @@
<pngx-page-header [(title)]="title"> <pngx-page-header [(title)]="title">
@if (contentRenderType === ContentRenderType.PDF && !useNativePdfViewer) { @if (contentRenderType === ContentRenderType.PDF && !useNativePdfViewer) {
<div class="input-group input-group-sm me-2 d-none d-md-flex"> <div class="input-group input-group-sm d-none d-md-flex">
<div class="input-group-text" i18n>Page</div> <div class="input-group-text" i18n>Page</div>
<input class="form-control flex-grow-0 w-auto" type="number" min="1" [max]="previewNumPages" [(ngModel)]="previewCurrentPage" /> <input class="form-control flex-grow-0 w-auto" type="number" min="1" [max]="previewNumPages" [(ngModel)]="previewCurrentPage" />
<div class="input-group-text" i18n>of {{previewNumPages}}</div> <div class="input-group-text" i18n>of {{previewNumPages}}</div>
</div> </div>
<div class="input-group input-group-sm me-5 d-none d-md-flex"> <div class="input-group input-group-sm me-md-5 d-none d-md-flex">
<button class="btn btn-outline-secondary" (click)="decreaseZoom()" i18n>-</button> <button class="btn btn-outline-secondary" (click)="decreaseZoom()" i18n>-</button>
<select class="form-select" (change)="onZoomSelect($event)"> <select class="form-select" (change)="onZoomSelect($event)">
@for (setting of zoomSettings; track setting) { @for (setting of zoomSettings; track setting) {
@ -18,11 +18,11 @@
</div> </div>
} }
<button type="button" class="btn btn-sm btn-outline-danger me-4" (click)="delete()" [disabled]="!userIsOwner" *pngxIfPermissions="{ action: PermissionAction.Delete, type: PermissionType.Document }"> <button type="button" class="btn btn-sm btn-outline-danger me-md-4" (click)="delete()" [disabled]="!userIsOwner" *pngxIfPermissions="{ action: PermissionAction.Delete, type: PermissionType.Document }">
<i-bs width="1.2em" height="1.2em" name="trash"></i-bs><span class="d-none d-lg-inline ps-1" i18n>Delete</span> <i-bs width="1.2em" height="1.2em" name="trash"></i-bs><span class="d-none d-lg-inline ps-1" i18n>Delete</span>
</button> </button>
<div class="btn-group me-2"> <div class="btn-group">
<a [href]="downloadUrl" class="btn btn-sm btn-outline-primary"> <a [href]="downloadUrl" class="btn btn-sm btn-outline-primary">
<i-bs width="1.2em" height="1.2em" name="download"></i-bs><span class="d-none d-lg-inline ps-1" i18n>Download</span> <i-bs width="1.2em" height="1.2em" name="download"></i-bs><span class="d-none d-lg-inline ps-1" i18n>Download</span>
</a> </a>
@ -38,7 +38,7 @@
</div> </div>
<div class="ms-auto" ngbDropdown> <div class="ms-auto" ngbDropdown>
<button class="btn btn-sm btn-outline-primary me-2" id="actionsDropdown" ngbDropdownToggle> <button class="btn btn-sm btn-outline-primary" id="actionsDropdown" ngbDropdownToggle>
<i-bs name="three-dots"></i-bs> <i-bs name="three-dots"></i-bs>
<div class="d-none d-sm-inline">&nbsp;<ng-container i18n>Actions</ng-container></div> <div class="d-none d-sm-inline">&nbsp;<ng-container i18n>Actions</ng-container></div>
</button> </button>
@ -55,7 +55,6 @@
<pngx-custom-fields-dropdown <pngx-custom-fields-dropdown
*pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.CustomField }" *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.CustomField }"
class="me-2"
[documentId]="documentId" [documentId]="documentId"
[disabled]="!userIsOwner" [disabled]="!userIsOwner"
[existingFields]="document?.custom_fields" [existingFields]="document?.custom_fields"

View File

@ -1,4 +1,4 @@
<div class="card mb-3 shadow-sm" [class.card-selected]="selected" [class.document-card]="selectable" [class.popover-hidden]="popoverHidden" (mouseleave)="mouseLeaveCard()"> <div class="card mb-3 shadow-sm bg-light" [class.card-selected]="selected" [class.document-card]="selectable" [class.popover-hidden]="popoverHidden" (mouseleave)="mouseLeaveCard()">
<div class="row g-0"> <div class="row g-0">
<div class="col-md-2 doc-img-background rounded-start" [class.doc-img-background-selected]="selected" (click)="this.toggleSelected.emit($event)" (dblclick)="dblClickDocument.emit()"> <div class="col-md-2 doc-img-background rounded-start" [class.doc-img-background-selected]="selected" (click)="this.toggleSelected.emit($event)" (dblclick)="dblClickDocument.emit()">
<img [src]="getThumbUrl()" class="card-img doc-img border-end rounded-start" [class.inverted]="getIsThumbInverted()"> <img [src]="getThumbUrl()" class="card-img doc-img border-end rounded-start" [class.inverted]="getIsThumbInverted()">
@ -12,8 +12,7 @@
</div> </div>
<div class="col"> <div class="col">
<div class="card-body bg-light"> <div class="card-body">
<div class="d-flex justify-content-between align-items-center"> <div class="d-flex justify-content-between align-items-center">
<h5 class="card-title"> <h5 class="card-title">
@if (document.correspondent) { @if (document.correspondent) {
@ -45,7 +44,6 @@
} }
</p> </p>
<div class="d-flex flex-column flex-md-row align-items-md-center"> <div class="d-flex flex-column flex-md-row align-items-md-center">
<div class="btn-group"> <div class="btn-group">
<a class="btn btn-sm btn-outline-secondary" (click)="clickMoreLike.emit()"> <a class="btn btn-sm btn-outline-secondary" (click)="clickMoreLike.emit()">
@ -67,28 +65,27 @@
</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">
@if (notesEnabled && document.notes.length) { @if (notesEnabled && document.notes.length) {
<button routerLink="/documents/{{document.id}}/notes" class="list-group-item btn btn-sm bg-light text-dark p-1 border-0 me-2 d-flex align-items-center" title="View notes" i18n-title> <button routerLink="/documents/{{document.id}}/notes" class="list-group-item btn btn-sm bg-light text-dark p-1 border-0 me-2 d-flex align-items-center" title="View notes" i18n-title>
<i-bs width="09.rem" height="0.9rem" class="me-2 text-muted" name="chat-left-text"></i-bs><small i18n>{{document.notes.length}} Notes</small> <i-bs width=".9em" height=".9em" class="me-2 text-muted" name="chat-left-text"></i-bs><small i18n>{{document.notes.length}} Notes</small>
</button> </button>
} }
@if (document.document_type) { @if (document.document_type) {
<button type="button" class="list-group-item btn btn-sm bg-light text-dark p-1 border-0 me-2 d-flex align-items-center" title="Filter by document type" i18n-title <button type="button" class="list-group-item btn btn-sm bg-light text-dark p-1 border-0 me-2 d-flex align-items-center" title="Filter by document type" i18n-title
(click)="clickDocumentType.emit(document.document_type);$event.stopPropagation()"> (click)="clickDocumentType.emit(document.document_type);$event.stopPropagation()">
<i-bs width="09.rem" height="0.9rem" class="me-2 text-muted" name="file-earmark"></i-bs><small>{{(document.document_type$ | async)?.name}}</small> <i-bs width=".9em" height=".9em" class="me-2 text-muted" name="file-earmark"></i-bs><small>{{(document.document_type$ | async)?.name}}</small>
</button> </button>
} }
@if (document.storage_path) { @if (document.storage_path) {
<button type="button" class="list-group-item btn btn-sm bg-light text-dark p-1 border-0 me-2 d-flex align-items-center" title="Filter by storage path" i18n-title <button type="button" class="list-group-item btn btn-sm bg-light text-dark p-1 border-0 me-2 d-flex align-items-center" title="Filter by storage path" i18n-title
(click)="clickStoragePath.emit(document.storage_path);$event.stopPropagation()"> (click)="clickStoragePath.emit(document.storage_path);$event.stopPropagation()">
<i-bs width="09.rem" height="0.9rem" class="me-2 text-muted" name="archive"></i-bs><small>{{(document.storage_path$ | async)?.name}}</small> <i-bs width=".9em" height=".9em" class="me-2 text-muted" name="archive"></i-bs><small>{{(document.storage_path$ | async)?.name}}</small>
</button> </button>
} }
@if (document.archive_serial_number | isNumber) { @if (document.archive_serial_number | isNumber) {
<div class="list-group-item me-2 bg-light text-dark p-1 border-0 d-flex align-items-center"> <div class="list-group-item me-2 bg-light text-dark p-1 border-0 d-flex align-items-center">
<i-bs width="09.rem" height="0.9rem" class="me-2 text-muted" name="upc-scan"></i-bs><small>#{{document.archive_serial_number}}</small> <i-bs width=".9em" height=".9em" class="me-2 text-muted" name="upc-scan"></i-bs><small>#{{document.archive_serial_number}}</small>
</div> </div>
} }
<ng-template #dateTooltip> <ng-template #dateTooltip>
@ -99,27 +96,26 @@
</div> </div>
</ng-template> </ng-template>
<div class="list-group-item bg-light text-dark p-1 border-0 d-flex align-items-center" [ngbTooltip]="dateTooltip"> <div class="list-group-item bg-light text-dark p-1 border-0 d-flex align-items-center" [ngbTooltip]="dateTooltip">
<i-bs width="09.rem" height="0.9rem" class="me-2 text-muted" name="calendar-event"></i-bs><small>{{document.created_date | customDate:'mediumDate'}}</small> <i-bs width=".9em" height=".9em" class="me-2 text-muted" name="calendar-event"></i-bs><small>{{document.created_date | customDate:'mediumDate'}}</small>
</div> </div>
@if (document.owner && document.owner !== settingsService.currentUser.id) { @if (document.owner && document.owner !== settingsService.currentUser.id) {
<div class="list-group-item bg-light text-dark p-1 border-0 d-flex align-items-center"> <div class="list-group-item bg-light text-dark p-1 border-0 d-flex align-items-center">
<i-bs width="09.rem" height="0.9rem" class="me-2 text-muted" name="person-fill-lock"></i-bs><small>{{document.owner | username}}</small> <i-bs width=".9em" height=".9em" class="me-2 text-muted" name="person-fill-lock"></i-bs><small>{{document.owner | username}}</small>
</div> </div>
} }
@if (document.is_shared_by_requester) { @if (document.is_shared_by_requester) {
<div class="list-group-item bg-light text-dark p-1 border-0 d-flex align-items-center"> <div class="list-group-item bg-light text-dark p-1 border-0 d-flex align-items-center">
<i-bs width="09.rem" height="0.9rem" class="me-2 text-muted" name="people-fill"></i-bs><small i18n>Shared</small> <i-bs width=".9em" height=".9em" class="me-2 text-muted" name="people-fill"></i-bs><small i18n>Shared</small>
</div> </div>
} }
@if (document.__search_hit__?.score) { @if (document.__search_hit__?.score) {
<div class="list-group-item bg-light text-dark border-0 d-flex p-0 ps-4 search-score"> <div class="list-group-item bg-light text-dark border-0 d-flex p-0 ps-4 search-score">
<small class="text-muted" i18n>Score:</small> <small class="me-2 text-muted" i18n>Score:</small>
<ngb-progressbar [type]="searchScoreClass" [value]="document.__search_hit__.score" class="search-score-bar mx-2 mt-1" [max]="1"></ngb-progressbar> <ngb-progressbar [type]="searchScoreClass" [value]="document.__search_hit__.score" class="search-score-bar mx-2 mt-1" [max]="1"></ngb-progressbar>
</div> </div>
} }
</div> </div>
</div> </div>
</div> </div>
</div> </div>
</div> </div>

View File

@ -1,6 +1,6 @@
<pngx-page-header [title]="getTitle()"> <pngx-page-header [title]="getTitle()">
<div ngbDropdown class="me-2 d-flex"> <div ngbDropdown class="d-flex">
<button class="btn btn-sm btn-outline-primary" id="dropdownSelect" ngbDropdownToggle> <button class="btn btn-sm btn-outline-primary" id="dropdownSelect" ngbDropdownToggle>
<i-bs name="text-indent-left"></i-bs> <i-bs name="text-indent-left"></i-bs>
<div class="d-none d-sm-inline">&nbsp;<ng-container i18n>Select</ng-container></div> <div class="d-none d-sm-inline">&nbsp;<ng-container i18n>Select</ng-container></div>
@ -26,7 +26,7 @@
</label> </label>
</div> </div>
<div ngbDropdown class="btn-group ms-2 flex-fill"> <div ngbDropdown class="btn-group flex-fill">
<button class="btn btn-outline-primary btn-sm" id="dropdownBasic1" ngbDropdownToggle i18n>Sort</button> <button class="btn btn-outline-primary btn-sm" id="dropdownBasic1" ngbDropdownToggle i18n>Sort</button>
<div ngbDropdownMenu aria-labelledby="dropdownBasic1" class="shadow dropdown-menu-right"> <div ngbDropdownMenu aria-labelledby="dropdownBasic1" class="shadow dropdown-menu-right">
<div class="w-100 d-flex pb-2 mb-1 border-bottom"> <div class="w-100 d-flex pb-2 mb-1 border-bottom">
@ -49,7 +49,7 @@
</div> </div>
</div> </div>
<div class="btn-group ms-2 flex-fill" *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.SavedView }" ngbDropdown role="group"> <div class="btn-group flex-fill" *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.SavedView }" ngbDropdown role="group">
<button class="btn btn-sm btn-outline-primary dropdown-toggle flex-fill" tourAnchor="tour.documents-views" ngbDropdownToggle> <button class="btn btn-sm btn-outline-primary dropdown-toggle flex-fill" tourAnchor="tour.documents-views" ngbDropdownToggle>
<ng-container i18n>Views</ng-container> <ng-container i18n>Views</ng-container>
@if (savedViewIsModified) { @if (savedViewIsModified) {

View File

@ -5,7 +5,7 @@
i18n-info i18n-info
infoLink="usage/#custom-fields" infoLink="usage/#custom-fields"
> >
<button type="button" class="btn btn-sm btn-outline-primary ms-4" (click)="editField()" *pngxIfPermissions="{ action: PermissionAction.Add, type: PermissionType.CustomField }"> <button type="button" class="btn btn-sm btn-outline-primary" (click)="editField()" *pngxIfPermissions="{ action: PermissionAction.Add, type: PermissionType.CustomField }">
<i-bs name="plus-circle"></i-bs>&nbsp;<ng-container i18n>Add Field</ng-container> <i-bs name="plus-circle"></i-bs>&nbsp;<ng-container i18n>Add Field</ng-container>
</button> </button>
</pngx-page-header> </pngx-page-header>

View File

@ -1,14 +1,14 @@
<pngx-page-header title="{{ typeNamePlural | titlecase }}"> <pngx-page-header title="{{ typeNamePlural | titlecase }}">
<button class="btn btn-sm btn-outline-secondary me-2" (click)="clearSelection()" [hidden]="selectedObjects.size === 0"> <button class="btn btn-sm btn-outline-secondary" (click)="clearSelection()" [hidden]="selectedObjects.size === 0">
<i-bs name="x"></i-bs>&nbsp;<ng-container i18n>Clear selection</ng-container> <i-bs name="x"></i-bs>&nbsp;<ng-container i18n>Clear selection</ng-container>
</button> </button>
<button type="button" class="btn btn-sm btn-outline-primary me-2" (click)="setPermissions()" [disabled]="!userCanBulkEdit(PermissionAction.Change) || selectedObjects.size === 0"> <button type="button" class="btn btn-sm btn-outline-primary" (click)="setPermissions()" [disabled]="!userCanBulkEdit(PermissionAction.Change) || selectedObjects.size === 0">
<i-bs name="person-fill-lock"></i-bs>&nbsp;<ng-container i18n>Permissions</ng-container> <i-bs name="person-fill-lock"></i-bs>&nbsp;<ng-container i18n>Permissions</ng-container>
</button> </button>
<button type="button" class="btn btn-sm btn-outline-danger me-5" (click)="delete()" [disabled]="!userCanBulkEdit(PermissionAction.Delete) || selectedObjects.size === 0"> <button type="button" class="btn btn-sm btn-outline-danger" (click)="delete()" [disabled]="!userCanBulkEdit(PermissionAction.Delete) || selectedObjects.size === 0">
<i-bs name="trash"></i-bs>&nbsp;<ng-container i18n>Delete</ng-container> <i-bs name="trash"></i-bs>&nbsp;<ng-container i18n>Delete</ng-container>
</button> </button>
<button type="button" class="btn btn-sm btn-outline-primary" (click)="openCreateDialog()" *pngxIfPermissions="{ action: PermissionAction.Add, type: permissionType }"> <button type="button" class="btn btn-sm btn-outline-primary ms-md-5" (click)="openCreateDialog()" *pngxIfPermissions="{ action: PermissionAction.Add, type: permissionType }">
<i-bs name="plus-circle"></i-bs>&nbsp;<ng-container i18n>Create</ng-container> <i-bs name="plus-circle"></i-bs>&nbsp;<ng-container i18n>Create</ng-container>
</button> </button>
</pngx-page-header> </pngx-page-header>
@ -60,7 +60,7 @@
<label class="form-check-label" for="{{typeName}}{{object.id}}"></label> <label class="form-check-label" for="{{typeName}}{{object.id}}"></label>
</div> </div>
</td> </td>
<td scope="row"><button class="btn btn-link ms-0 ps-0 text-start" (click)="userCanEdit(object) ? openEditDialog(object) : null">{{ object.name }}</button> </td> <td scope="row"><button class="btn btn-link ms-0 ps-0 text-start" (click)="userCanEdit(object) ? openEditDialog(object) : null; $event.stopPropagation()">{{ object.name }}</button> </td>
<td scope="row" class="d-none d-sm-table-cell">{{ getMatching(object) }}</td> <td scope="row" class="d-none d-sm-table-cell">{{ getMatching(object) }}</td>
<td scope="row">{{ object.document_count }}</td> <td scope="row">{{ object.document_count }}</td>
@for (column of extraColumns; track column) { @for (column of extraColumns; track column) {
@ -75,7 +75,7 @@
<td scope="row"> <td scope="row">
<div class="btn-group d-block d-sm-none"> <div class="btn-group d-block d-sm-none">
<div ngbDropdown class="d-inline-block"> <div ngbDropdown class="d-inline-block">
<button type="button" class="btn btn-link" id="actionsMenuMobile" ngbDropdownToggle> <button type="button" class="btn btn-link" id="actionsMenuMobile" (click)="$event.stopPropagation()" ngbDropdownToggle>
<i-bs name="three-dots-vertical"></i-bs> <i-bs name="three-dots-vertical"></i-bs>
</button> </button>
<div ngbDropdownMenu aria-labelledby="actionsMenuMobile"> <div ngbDropdownMenu aria-labelledby="actionsMenuMobile">

View File

@ -5,7 +5,7 @@
i18n-info i18n-info
infoLink="usage/#workflows" infoLink="usage/#workflows"
> >
<button type="button" class="btn btn-sm btn-outline-primary ms-4" (click)="editWorkflow()" *pngxIfPermissions="{ action: PermissionAction.Add, type: PermissionType.Workflow }"> <button type="button" class="btn btn-sm btn-outline-primary" (click)="editWorkflow()" *pngxIfPermissions="{ action: PermissionAction.Add, type: PermissionType.Workflow }">
<i-bs name="plus-circle"></i-bs>&nbsp;<ng-container i18n>Add Workflow</ng-container> <i-bs name="plus-circle"></i-bs>&nbsp;<ng-container i18n>Add Workflow</ng-container>
</button> </button>
</pngx-page-header> </pngx-page-header>

View File

@ -0,0 +1,42 @@
export enum InstallType {
Containerized = 'containerized',
BareMetal = 'bare-metal',
}
export enum SystemStatusItemStatus {
OK = 'OK',
ERROR = 'ERROR',
WARNING = 'WARNING',
}
export interface SystemStatus {
pngx_version: string
server_os: string
install_type: InstallType
storage: {
total: number
available: number
}
database: {
type: string
url: string
status: SystemStatusItemStatus
error?: string
migration_status: {
latest_migration: string
unapplied_migrations: string[]
}
}
tasks: {
redis_url: string
redis_status: SystemStatusItemStatus
redis_error: string
celery_status: SystemStatusItemStatus
index_status: SystemStatusItemStatus
index_last_modified: string // ISO date string
index_error: string
classifier_status: SystemStatusItemStatus
classifier_last_trained: string // ISO date string
classifier_error: string
}
}

View File

@ -2,6 +2,7 @@ import { ObjectWithId } from './object-with-id'
export enum WorkflowActionType { export enum WorkflowActionType {
Assignment = 1, Assignment = 1,
Removal = 2,
} }
export interface WorkflowAction extends ObjectWithId { export interface WorkflowAction extends ObjectWithId {
type: WorkflowActionType type: WorkflowActionType
@ -27,4 +28,38 @@ export interface WorkflowAction extends ObjectWithId {
assign_change_groups?: number[] // [Group.id] assign_change_groups?: number[] // [Group.id]
assign_custom_fields?: number[] // [CustomField.id] assign_custom_fields?: number[] // [CustomField.id]
remove_tags?: number[] // Tag.id
remove_all_tags?: boolean
remove_document_types?: number[] // [DocumentType.id]
remove_all_document_types?: boolean
remove_correspondents?: number[] // [Correspondent.id]
remove_all_correspondents?: boolean
remove_storage_paths?: number[] // [StoragePath.id]
remove_all_storage_paths?: boolean
remove_owners?: number[] // [User.id]
remove_all_owners?: boolean
remove_view_users?: number[] // [User.id]
remove_view_groups?: number[] // [Group.id]
remove_change_users?: number[] // [User.id]
remove_change_groups?: number[] // [Group.id]
remove_all_permissions?: boolean
remove_custom_fields?: number[] // [CustomField.id]
remove_all_custom_fields?: boolean
} }

View File

@ -0,0 +1,35 @@
import { TestBed } from '@angular/core/testing'
import { SystemStatusService } from './system-status.service'
import {
HttpClientTestingModule,
HttpTestingController,
} from '@angular/common/http/testing'
import { environment } from 'src/environments/environment'
describe('SystemStatusService', () => {
let httpTestingController: HttpTestingController
let service: SystemStatusService
beforeEach(() => {
TestBed.configureTestingModule({
providers: [SystemStatusService],
imports: [HttpClientTestingModule],
})
httpTestingController = TestBed.inject(HttpTestingController)
service = TestBed.inject(SystemStatusService)
})
afterEach(() => {
httpTestingController.verify()
})
it('calls get status endpoint', () => {
service.get().subscribe()
const req = httpTestingController.expectOne(
`${environment.apiBaseUrl}status/`
)
expect(req.request.method).toEqual('GET')
})
})

View File

@ -0,0 +1,20 @@
import { HttpClient } from '@angular/common/http'
import { Injectable } from '@angular/core'
import { Observable } from 'rxjs'
import { SystemStatus } from '../data/system-status'
import { environment } from 'src/environments/environment'
@Injectable({
providedIn: 'root',
})
export class SystemStatusService {
private endpoint = 'status'
constructor(private http: HttpClient) {}
get(): Observable<SystemStatus> {
return this.http.get<SystemStatus>(
`${environment.apiBaseUrl}${this.endpoint}/`
)
}
}

View File

@ -5,7 +5,7 @@ export const environment = {
apiBaseUrl: document.baseURI + 'api/', apiBaseUrl: document.baseURI + 'api/',
apiVersion: '5', apiVersion: '5',
appTitle: 'Paperless-ngx', appTitle: 'Paperless-ngx',
version: '2.5.4-dev', version: '2.6.1-dev',
webSocketHost: window.location.host, webSocketHost: window.location.host,
webSocketProtocol: window.location.protocol == 'https:' ? 'wss:' : 'ws:', webSocketProtocol: window.location.protocol == 'https:' ? 'wss:' : 'ws:',
webSocketBaseUrl: base_url.pathname + 'ws/', webSocketBaseUrl: base_url.pathname + 'ws/',

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -155,7 +155,7 @@ $form-check-radio-checked-bg-image-dark: url("data:image/svg+xml,<svg xmlns='htt
} }
} }
.row-cols-paperless-cards .card { .row-cols-paperless-cards .card, pngx-document-card-large .card {
--bs-border-color-translucent: rgba(0, 0, 0, .3); --bs-border-color-translucent: rgba(0, 0, 0, .3);
} }

View File

@ -20,6 +20,7 @@ from documents.plugins.base import StopConsumeTaskError
from documents.plugins.helpers import ProgressStatusOptions from documents.plugins.helpers import ProgressStatusOptions
from documents.utils import copy_basic_file_stats from documents.utils import copy_basic_file_stats
from documents.utils import copy_file_with_basic_stats from documents.utils import copy_file_with_basic_stats
from documents.utils import maybe_override_pixel_limit
logger = logging.getLogger("paperless.barcodes") logger = logging.getLogger("paperless.barcodes")
@ -81,6 +82,9 @@ class BarcodePlugin(ConsumeTaskPlugin):
self.barcodes: list[Barcode] = [] self.barcodes: list[Barcode] = []
def run(self) -> Optional[str]: def run(self) -> Optional[str]:
# Some operations may use PIL, override pixel setting if needed
maybe_override_pixel_limit()
# Maybe do the conversion of TIFF to PDF # Maybe do the conversion of TIFF to PDF
self.convert_from_tiff_to_pdf() self.convert_from_tiff_to_pdf()

View File

@ -7,6 +7,7 @@ from enum import Enum
from pathlib import Path from pathlib import Path
from subprocess import CompletedProcess from subprocess import CompletedProcess
from subprocess import run from subprocess import run
from typing import TYPE_CHECKING
from typing import Optional from typing import Optional
import magic import magic
@ -35,6 +36,7 @@ from documents.models import FileInfo
from documents.models import StoragePath from documents.models import StoragePath
from documents.models import Tag from documents.models import Tag
from documents.models import Workflow from documents.models import Workflow
from documents.models import WorkflowAction
from documents.models import WorkflowTrigger from documents.models import WorkflowTrigger
from documents.parsers import DocumentParser from documents.parsers import DocumentParser
from documents.parsers import ParseError from documents.parsers import ParseError
@ -63,9 +65,26 @@ class WorkflowTriggerPlugin(
""" """
Get overrides from matching workflows Get overrides from matching workflows
""" """
msg = ""
overrides = DocumentMetadataOverrides() overrides = DocumentMetadataOverrides()
for workflow in Workflow.objects.filter(enabled=True).order_by("order"): for workflow in (
template_overrides = DocumentMetadataOverrides() Workflow.objects.filter(enabled=True)
.prefetch_related("actions")
.prefetch_related("actions__assign_view_users")
.prefetch_related("actions__assign_view_groups")
.prefetch_related("actions__assign_change_users")
.prefetch_related("actions__assign_change_groups")
.prefetch_related("actions__assign_custom_fields")
.prefetch_related("actions__remove_tags")
.prefetch_related("actions__remove_correspondents")
.prefetch_related("actions__remove_document_types")
.prefetch_related("actions__remove_storage_paths")
.prefetch_related("actions__remove_custom_fields")
.prefetch_related("actions__remove_owners")
.prefetch_related("triggers")
.order_by("order")
):
action_overrides = DocumentMetadataOverrides()
if document_matches_workflow( if document_matches_workflow(
self.input_doc, self.input_doc,
@ -73,49 +92,137 @@ class WorkflowTriggerPlugin(
WorkflowTrigger.WorkflowTriggerType.CONSUMPTION, WorkflowTrigger.WorkflowTriggerType.CONSUMPTION,
): ):
for action in workflow.actions.all(): for action in workflow.actions.all():
if TYPE_CHECKING:
assert isinstance(action, WorkflowAction)
msg += f"Applying {action} from {workflow}\n"
if action.type == WorkflowAction.WorkflowActionType.ASSIGNMENT:
if action.assign_title is not None: if action.assign_title is not None:
template_overrides.title = action.assign_title action_overrides.title = action.assign_title
if action.assign_tags is not None: if action.assign_tags is not None:
template_overrides.tag_ids = [ action_overrides.tag_ids = list(
tag.pk for tag in action.assign_tags.all() action.assign_tags.values_list("pk", flat=True),
] )
if action.assign_correspondent is not None: if action.assign_correspondent is not None:
template_overrides.correspondent_id = ( action_overrides.correspondent_id = (
action.assign_correspondent.pk action.assign_correspondent.pk
) )
if action.assign_document_type is not None: if action.assign_document_type is not None:
template_overrides.document_type_id = ( action_overrides.document_type_id = (
action.assign_document_type.pk action.assign_document_type.pk
) )
if action.assign_storage_path is not None: if action.assign_storage_path is not None:
template_overrides.storage_path_id = ( action_overrides.storage_path_id = (
action.assign_storage_path.pk action.assign_storage_path.pk
) )
if action.assign_owner is not None: if action.assign_owner is not None:
template_overrides.owner_id = action.assign_owner.pk action_overrides.owner_id = action.assign_owner.pk
if action.assign_view_users is not None: if action.assign_view_users is not None:
template_overrides.view_users = [ action_overrides.view_users = list(
user.pk for user in action.assign_view_users.all() action.assign_view_users.values_list("pk", flat=True),
] )
if action.assign_view_groups is not None: if action.assign_view_groups is not None:
template_overrides.view_groups = [ action_overrides.view_groups = list(
group.pk for group in action.assign_view_groups.all() action.assign_view_groups.values_list("pk", flat=True),
] )
if action.assign_change_users is not None: if action.assign_change_users is not None:
template_overrides.change_users = [ action_overrides.change_users = list(
user.pk for user in action.assign_change_users.all() action.assign_change_users.values_list("pk", flat=True),
] )
if action.assign_change_groups is not None: if action.assign_change_groups is not None:
template_overrides.change_groups = [ action_overrides.change_groups = list(
group.pk for group in action.assign_change_groups.all() action.assign_change_groups.values_list(
] "pk",
flat=True,
),
)
if action.assign_custom_fields is not None: if action.assign_custom_fields is not None:
template_overrides.custom_field_ids = [ action_overrides.custom_field_ids = list(
field.pk for field in action.assign_custom_fields.all() action.assign_custom_fields.values_list(
] "pk",
flat=True,
),
)
overrides.update(action_overrides)
elif action.type == WorkflowAction.WorkflowActionType.REMOVAL:
# Removal actions overwrite the current overrides
if action.remove_all_tags:
overrides.tag_ids = []
elif overrides.tag_ids:
for tag in action.remove_custom_fields.filter(
pk__in=overrides.tag_ids,
):
overrides.tag_ids.remove(tag.pk)
if action.remove_all_correspondents or (
overrides.correspondent_id is not None
and action.remove_correspondents.filter(
pk=overrides.correspondent_id,
).exists()
):
overrides.correspondent_id = None
if action.remove_all_document_types or (
overrides.document_type_id is not None
and action.remove_document_types.filter(
pk=overrides.document_type_id,
).exists()
):
overrides.document_type_id = None
if action.remove_all_storage_paths or (
overrides.storage_path_id is not None
and action.remove_storage_paths.filter(
pk=overrides.storage_path_id,
).exists()
):
overrides.storage_path_id = None
if action.remove_all_custom_fields:
overrides.custom_field_ids = []
elif overrides.custom_field_ids:
for field in action.remove_custom_fields.filter(
pk__in=overrides.custom_field_ids,
):
overrides.custom_field_ids.remove(field.pk)
if action.remove_all_owners or (
overrides.owner_id is not None
and action.remove_owners.filter(
pk=overrides.owner_id,
).exists()
):
overrides.owner_id = None
if action.remove_all_permissions:
overrides.view_users = []
overrides.view_groups = []
overrides.change_users = []
overrides.change_groups = []
else:
if overrides.view_users:
for user in action.remove_view_users.filter(
pk__in=overrides.view_users,
):
overrides.view_users.remove(user.pk)
if overrides.change_users:
for user in action.remove_change_users.filter(
pk__in=overrides.change_users,
):
overrides.change_users.remove(user.pk)
if overrides.view_groups:
for user in action.remove_view_groups.filter(
pk__in=overrides.view_groups,
):
overrides.view_groups.remove(user.pk)
if overrides.change_groups:
for user in action.remove_change_groups.filter(
pk__in=overrides.change_groups,
):
overrides.change_groups.remove(user.pk)
overrides.update(template_overrides)
self.metadata.update(overrides) self.metadata.update(overrides)
return msg
class ConsumerError(Exception): class ConsumerError(Exception):

View File

@ -6,6 +6,7 @@ from django.conf import settings
from PIL import Image from PIL import Image
from documents.utils import copy_basic_file_stats from documents.utils import copy_basic_file_stats
from documents.utils import maybe_override_pixel_limit
def convert_from_tiff_to_pdf(tiff_path: Path, target_directory: Path) -> Path: def convert_from_tiff_to_pdf(tiff_path: Path, target_directory: Path) -> Path:
@ -17,6 +18,9 @@ def convert_from_tiff_to_pdf(tiff_path: Path, target_directory: Path) -> Path:
Returns the path of the PDF created. Returns the path of the PDF created.
""" """
# override pixel setting if needed
maybe_override_pixel_limit()
with Image.open(tiff_path) as im: with Image.open(tiff_path) as im:
has_alpha_layer = im.mode in ("RGBA", "LA") has_alpha_layer = im.mode in ("RGBA", "LA")
if has_alpha_layer: if has_alpha_layer:

View File

@ -0,0 +1,223 @@
# Generated by Django 4.2.10 on 2024-02-21 21:19
import django.db.models.deletion
from django.conf import settings
from django.db import migrations
from django.db import models
class Migration(migrations.Migration):
dependencies = [
("auth", "0012_alter_user_first_name_max_length"),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
("documents", "1045_alter_customfieldinstance_value_monetary"),
]
operations = [
migrations.AddField(
model_name="workflowaction",
name="remove_all_correspondents",
field=models.BooleanField(
default=False,
verbose_name="remove all correspondents",
),
),
migrations.AddField(
model_name="workflowaction",
name="remove_all_custom_fields",
field=models.BooleanField(
default=False,
verbose_name="remove all custom fields",
),
),
migrations.AddField(
model_name="workflowaction",
name="remove_all_document_types",
field=models.BooleanField(
default=False,
verbose_name="remove all document types",
),
),
migrations.AddField(
model_name="workflowaction",
name="remove_all_owners",
field=models.BooleanField(default=False, verbose_name="remove all owners"),
),
migrations.AddField(
model_name="workflowaction",
name="remove_all_permissions",
field=models.BooleanField(
default=False,
verbose_name="remove all permissions",
),
),
migrations.AddField(
model_name="workflowaction",
name="remove_all_storage_paths",
field=models.BooleanField(
default=False,
verbose_name="remove all storage paths",
),
),
migrations.AddField(
model_name="workflowaction",
name="remove_all_tags",
field=models.BooleanField(default=False, verbose_name="remove all tags"),
),
migrations.AddField(
model_name="workflowaction",
name="remove_change_groups",
field=models.ManyToManyField(
blank=True,
related_name="+",
to="auth.group",
verbose_name="remove change permissions for these groups",
),
),
migrations.AddField(
model_name="workflowaction",
name="remove_change_users",
field=models.ManyToManyField(
blank=True,
related_name="+",
to=settings.AUTH_USER_MODEL,
verbose_name="remove change permissions for these users",
),
),
migrations.AddField(
model_name="workflowaction",
name="remove_correspondents",
field=models.ManyToManyField(
blank=True,
related_name="+",
to="documents.correspondent",
verbose_name="remove these correspondent(s)",
),
),
migrations.AddField(
model_name="workflowaction",
name="remove_custom_fields",
field=models.ManyToManyField(
blank=True,
related_name="+",
to="documents.customfield",
verbose_name="remove these custom fields",
),
),
migrations.AddField(
model_name="workflowaction",
name="remove_document_types",
field=models.ManyToManyField(
blank=True,
related_name="+",
to="documents.documenttype",
verbose_name="remove these document type(s)",
),
),
migrations.AddField(
model_name="workflowaction",
name="remove_owners",
field=models.ManyToManyField(
blank=True,
related_name="+",
to=settings.AUTH_USER_MODEL,
verbose_name="remove these owner(s)",
),
),
migrations.AddField(
model_name="workflowaction",
name="remove_storage_paths",
field=models.ManyToManyField(
blank=True,
related_name="+",
to="documents.storagepath",
verbose_name="remove these storage path(s)",
),
),
migrations.AddField(
model_name="workflowaction",
name="remove_tags",
field=models.ManyToManyField(
blank=True,
related_name="+",
to="documents.tag",
verbose_name="remove these tag(s)",
),
),
migrations.AddField(
model_name="workflowaction",
name="remove_view_groups",
field=models.ManyToManyField(
blank=True,
related_name="+",
to="auth.group",
verbose_name="remove view permissions for these groups",
),
),
migrations.AddField(
model_name="workflowaction",
name="remove_view_users",
field=models.ManyToManyField(
blank=True,
related_name="+",
to=settings.AUTH_USER_MODEL,
verbose_name="remove view permissions for these users",
),
),
migrations.AlterField(
model_name="workflowaction",
name="assign_correspondent",
field=models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name="+",
to="documents.correspondent",
verbose_name="assign this correspondent",
),
),
migrations.AlterField(
model_name="workflowaction",
name="assign_document_type",
field=models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name="+",
to="documents.documenttype",
verbose_name="assign this document type",
),
),
migrations.AlterField(
model_name="workflowaction",
name="assign_storage_path",
field=models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name="+",
to="documents.storagepath",
verbose_name="assign this storage path",
),
),
migrations.AlterField(
model_name="workflowaction",
name="assign_tags",
field=models.ManyToManyField(
blank=True,
related_name="+",
to="documents.tag",
verbose_name="assign this tag",
),
),
migrations.AlterField(
model_name="workflowaction",
name="type",
field=models.PositiveIntegerField(
choices=[(1, "Assignment"), (2, "Removal")],
default=1,
verbose_name="Workflow Action Type",
),
),
]

View File

@ -997,7 +997,14 @@ class WorkflowTrigger(models.Model):
class WorkflowAction(models.Model): class WorkflowAction(models.Model):
class WorkflowActionType(models.IntegerChoices): class WorkflowActionType(models.IntegerChoices):
ASSIGNMENT = 1, _("Assignment") ASSIGNMENT = (
1,
_("Assignment"),
)
REMOVAL = (
2,
_("Removal"),
)
type = models.PositiveIntegerField( type = models.PositiveIntegerField(
_("Workflow Action Type"), _("Workflow Action Type"),
@ -1019,6 +1026,7 @@ class WorkflowAction(models.Model):
assign_tags = models.ManyToManyField( assign_tags = models.ManyToManyField(
Tag, Tag,
blank=True, blank=True,
related_name="+",
verbose_name=_("assign this tag"), verbose_name=_("assign this tag"),
) )
@ -1027,6 +1035,7 @@ class WorkflowAction(models.Model):
null=True, null=True,
blank=True, blank=True,
on_delete=models.SET_NULL, on_delete=models.SET_NULL,
related_name="+",
verbose_name=_("assign this document type"), verbose_name=_("assign this document type"),
) )
@ -1035,6 +1044,7 @@ class WorkflowAction(models.Model):
null=True, null=True,
blank=True, blank=True,
on_delete=models.SET_NULL, on_delete=models.SET_NULL,
related_name="+",
verbose_name=_("assign this correspondent"), verbose_name=_("assign this correspondent"),
) )
@ -1043,6 +1053,7 @@ class WorkflowAction(models.Model):
null=True, null=True,
blank=True, blank=True,
on_delete=models.SET_NULL, on_delete=models.SET_NULL,
related_name="+",
verbose_name=_("assign this storage path"), verbose_name=_("assign this storage path"),
) )
@ -1090,6 +1101,111 @@ class WorkflowAction(models.Model):
verbose_name=_("assign these custom fields"), verbose_name=_("assign these custom fields"),
) )
remove_tags = models.ManyToManyField(
Tag,
blank=True,
related_name="+",
verbose_name=_("remove these tag(s)"),
)
remove_all_tags = models.BooleanField(
default=False,
verbose_name=_("remove all tags"),
)
remove_document_types = models.ManyToManyField(
DocumentType,
blank=True,
related_name="+",
verbose_name=_("remove these document type(s)"),
)
remove_all_document_types = models.BooleanField(
default=False,
verbose_name=_("remove all document types"),
)
remove_correspondents = models.ManyToManyField(
Correspondent,
blank=True,
related_name="+",
verbose_name=_("remove these correspondent(s)"),
)
remove_all_correspondents = models.BooleanField(
default=False,
verbose_name=_("remove all correspondents"),
)
remove_storage_paths = models.ManyToManyField(
StoragePath,
blank=True,
related_name="+",
verbose_name=_("remove these storage path(s)"),
)
remove_all_storage_paths = models.BooleanField(
default=False,
verbose_name=_("remove all storage paths"),
)
remove_owners = models.ManyToManyField(
User,
blank=True,
related_name="+",
verbose_name=_("remove these owner(s)"),
)
remove_all_owners = models.BooleanField(
default=False,
verbose_name=_("remove all owners"),
)
remove_view_users = models.ManyToManyField(
User,
blank=True,
related_name="+",
verbose_name=_("remove view permissions for these users"),
)
remove_view_groups = models.ManyToManyField(
Group,
blank=True,
related_name="+",
verbose_name=_("remove view permissions for these groups"),
)
remove_change_users = models.ManyToManyField(
User,
blank=True,
related_name="+",
verbose_name=_("remove change permissions for these users"),
)
remove_change_groups = models.ManyToManyField(
Group,
blank=True,
related_name="+",
verbose_name=_("remove change permissions for these groups"),
)
remove_all_permissions = models.BooleanField(
default=False,
verbose_name=_("remove all permissions"),
)
remove_custom_fields = models.ManyToManyField(
CustomField,
blank=True,
related_name="+",
verbose_name=_("remove these custom fields"),
)
remove_all_custom_fields = models.BooleanField(
default=False,
verbose_name=_("remove all custom fields"),
)
class Meta: class Meta:
verbose_name = _("workflow action") verbose_name = _("workflow action")
verbose_name_plural = _("workflow actions") verbose_name_plural = _("workflow actions")

View File

@ -1471,6 +1471,23 @@ class WorkflowActionSerializer(serializers.ModelSerializer):
"assign_change_users", "assign_change_users",
"assign_change_groups", "assign_change_groups",
"assign_custom_fields", "assign_custom_fields",
"remove_all_tags",
"remove_tags",
"remove_all_correspondents",
"remove_correspondents",
"remove_all_document_types",
"remove_document_types",
"remove_all_storage_paths",
"remove_storage_paths",
"remove_custom_fields",
"remove_all_custom_fields",
"remove_all_owners",
"remove_owners",
"remove_all_permissions",
"remove_view_users",
"remove_view_groups",
"remove_change_users",
"remove_change_groups",
] ]
def validate(self, attrs): def validate(self, attrs):
@ -1551,10 +1568,22 @@ class WorkflowSerializer(serializers.ModelSerializer):
assign_change_users = action.pop("assign_change_users", None) assign_change_users = action.pop("assign_change_users", None)
assign_change_groups = action.pop("assign_change_groups", None) assign_change_groups = action.pop("assign_change_groups", None)
assign_custom_fields = action.pop("assign_custom_fields", None) assign_custom_fields = action.pop("assign_custom_fields", None)
remove_tags = action.pop("remove_tags", None)
remove_correspondents = action.pop("remove_correspondents", None)
remove_document_types = action.pop("remove_document_types", None)
remove_storage_paths = action.pop("remove_storage_paths", None)
remove_custom_fields = action.pop("remove_custom_fields", None)
remove_owners = action.pop("remove_owners", None)
remove_view_users = action.pop("remove_view_users", None)
remove_view_groups = action.pop("remove_view_groups", None)
remove_change_users = action.pop("remove_change_users", None)
remove_change_groups = action.pop("remove_change_groups", None)
action_instance, _ = WorkflowAction.objects.update_or_create( action_instance, _ = WorkflowAction.objects.update_or_create(
id=action.get("id"), id=action.get("id"),
defaults=action, defaults=action,
) )
if assign_tags is not None: if assign_tags is not None:
action_instance.assign_tags.set(assign_tags) action_instance.assign_tags.set(assign_tags)
if assign_view_users is not None: if assign_view_users is not None:
@ -1567,6 +1596,27 @@ class WorkflowSerializer(serializers.ModelSerializer):
action_instance.assign_change_groups.set(assign_change_groups) action_instance.assign_change_groups.set(assign_change_groups)
if assign_custom_fields is not None: if assign_custom_fields is not None:
action_instance.assign_custom_fields.set(assign_custom_fields) action_instance.assign_custom_fields.set(assign_custom_fields)
if remove_tags is not None:
action_instance.remove_tags.set(remove_tags)
if remove_correspondents is not None:
action_instance.remove_correspondents.set(remove_correspondents)
if remove_document_types is not None:
action_instance.remove_document_types.set(remove_document_types)
if remove_storage_paths is not None:
action_instance.remove_storage_paths.set(remove_storage_paths)
if remove_custom_fields is not None:
action_instance.remove_custom_fields.set(remove_custom_fields)
if remove_owners is not None:
action_instance.remove_owners.set(remove_owners)
if remove_view_users is not None:
action_instance.remove_view_users.set(remove_view_users)
if remove_view_groups is not None:
action_instance.remove_view_groups.set(remove_view_groups)
if remove_change_users is not None:
action_instance.remove_change_users.set(remove_change_users)
if remove_change_groups is not None:
action_instance.remove_change_groups.set(remove_change_groups)
set_actions.append(action_instance) set_actions.append(action_instance)
instance.triggers.set(set_triggers) instance.triggers.set(set_triggers)

View File

@ -20,6 +20,7 @@ from django.db.models import Q
from django.dispatch import receiver from django.dispatch import receiver
from django.utils import timezone from django.utils import timezone
from filelock import FileLock from filelock import FileLock
from guardian.shortcuts import remove_perm
from documents import matching from documents import matching
from documents.caching import clear_metadata_cache from documents.caching import clear_metadata_cache
@ -34,6 +35,7 @@ from documents.models import MatchingModel
from documents.models import PaperlessTask from documents.models import PaperlessTask
from documents.models import Tag from documents.models import Tag
from documents.models import Workflow from documents.models import Workflow
from documents.models import WorkflowAction
from documents.models import WorkflowTrigger from documents.models import WorkflowTrigger
from documents.permissions import get_objects_for_user_owner_aware from documents.permissions import get_objects_for_user_owner_aware
from documents.permissions import set_permissions_for_object from documents.permissions import set_permissions_for_object
@ -529,20 +531,39 @@ def run_workflow(
document: Document, document: Document,
logging_group=None, logging_group=None,
): ):
for workflow in Workflow.objects.filter( for workflow in (
Workflow.objects.filter(
enabled=True, enabled=True,
triggers__type=trigger_type, triggers__type=trigger_type,
).order_by("order"): )
.prefetch_related("actions")
.prefetch_related("actions__assign_view_users")
.prefetch_related("actions__assign_view_groups")
.prefetch_related("actions__assign_change_users")
.prefetch_related("actions__assign_change_groups")
.prefetch_related("actions__assign_custom_fields")
.prefetch_related("actions__remove_tags")
.prefetch_related("actions__remove_correspondents")
.prefetch_related("actions__remove_document_types")
.prefetch_related("actions__remove_storage_paths")
.prefetch_related("actions__remove_custom_fields")
.prefetch_related("actions__remove_owners")
.prefetch_related("triggers")
.order_by("order")
):
if matching.document_matches_workflow( if matching.document_matches_workflow(
document, document,
workflow, workflow,
trigger_type, trigger_type,
): ):
action: WorkflowAction
for action in workflow.actions.all(): for action in workflow.actions.all():
logger.info( logger.info(
f"Applying {action} from {workflow}", f"Applying {action} from {workflow}",
extra={"group": logging_group}, extra={"group": logging_group},
) )
if action.type == WorkflowAction.WorkflowActionType.ASSIGNMENT:
if action.assign_tags.all().count() > 0: if action.assign_tags.all().count() > 0:
document.tags.add(*action.assign_tags.all()) document.tags.add(*action.assign_tags.all())
@ -611,13 +632,19 @@ def run_workflow(
): ):
permissions = { permissions = {
"view": { "view": {
"users": action.assign_view_users.all().values_list("id") "users": action.assign_view_users.all().values_list(
"id",
)
or [], or [],
"groups": action.assign_view_groups.all().values_list("id") "groups": action.assign_view_groups.all().values_list(
"id",
)
or [], or [],
}, },
"change": { "change": {
"users": action.assign_change_users.all().values_list("id") "users": action.assign_change_users.all().values_list(
"id",
)
or [], or [],
"groups": action.assign_change_groups.all().values_list( "groups": action.assign_change_groups.all().values_list(
"id", "id",
@ -646,6 +673,90 @@ def run_workflow(
document=document, document=document,
) )
elif action.type == WorkflowAction.WorkflowActionType.REMOVAL:
if action.remove_all_tags:
document.tags.clear()
else:
for tag in action.remove_tags.filter(
pk__in=list(document.tags.values_list("pk", flat=True)),
).all():
document.tags.remove(tag.pk)
if action.remove_all_correspondents or (
document.correspondent
and (
action.remove_correspondents.filter(
pk=document.correspondent.pk,
).exists()
)
):
document.correspondent = None
if action.remove_all_document_types or (
document.document_type
and (
action.remove_document_types.filter(
pk=document.document_type.pk,
).exists()
)
):
document.document_type = None
if action.remove_all_storage_paths or (
document.storage_path
and (
action.remove_storage_paths.filter(
pk=document.storage_path.pk,
).exists()
)
):
document.storage_path = None
if action.remove_all_owners or (
document.owner
and (action.remove_owners.filter(pk=document.owner.pk).exists())
):
document.owner = None
if action.remove_all_permissions:
permissions = {
"view": {
"users": [],
"groups": [],
},
"change": {
"users": [],
"groups": [],
},
}
set_permissions_for_object(
permissions=permissions,
object=document,
merge=False,
)
elif (
(action.remove_view_users.all().count() > 0)
or (action.remove_view_groups.all().count() > 0)
or (action.remove_change_users.all().count() > 0)
or (action.remove_change_groups.all().count() > 0)
):
for user in action.remove_view_users.all():
remove_perm("view_document", user, document)
for user in action.remove_change_users.all():
remove_perm("change_document", user, document)
for group in action.remove_view_groups.all():
remove_perm("view_document", group, document)
for group in action.remove_change_groups.all():
remove_perm("change_document", group, document)
if action.remove_all_custom_fields:
CustomFieldInstance.objects.filter(document=document).delete()
elif action.remove_custom_fields.all().count() > 0:
CustomFieldInstance.objects.filter(
field__in=action.remove_custom_fields.all(),
document=document,
).delete()
document.save() document.save()

View File

@ -0,0 +1,238 @@
import os
import tempfile
from pathlib import Path
from unittest import mock
from django.contrib.auth.models import User
from django.test import override_settings
from rest_framework import status
from rest_framework.test import APITestCase
from documents.classifier import ClassifierModelCorruptError
from documents.classifier import DocumentClassifier
from documents.classifier import load_classifier
from documents.models import Document
from documents.models import Tag
from paperless import version
class TestSystemStatus(APITestCase):
ENDPOINT = "/api/status/"
def setUp(self):
self.user = User.objects.create_superuser(
username="temp_admin",
)
def test_system_status(self):
"""
GIVEN:
- A user is logged in
WHEN:
- The user requests the system status
THEN:
- The response contains relevant system status information
"""
self.client.force_login(self.user)
response = self.client.get(self.ENDPOINT)
self.assertEqual(response.status_code, status.HTTP_200_OK)
self.assertEqual(response.data["pngx_version"], version.__full_version_str__)
self.assertIsNotNone(response.data["server_os"])
self.assertEqual(response.data["install_type"], "bare-metal")
self.assertIsNotNone(response.data["storage"]["total"])
self.assertIsNotNone(response.data["storage"]["available"])
self.assertEqual(response.data["database"]["type"], "sqlite")
self.assertIsNotNone(response.data["database"]["url"])
self.assertEqual(response.data["database"]["status"], "OK")
self.assertIsNone(response.data["database"]["error"])
self.assertIsNotNone(response.data["database"]["migration_status"])
self.assertEqual(response.data["tasks"]["redis_url"], "redis://localhost:6379")
self.assertEqual(response.data["tasks"]["redis_status"], "ERROR")
self.assertIsNotNone(response.data["tasks"]["redis_error"])
def test_system_status_insufficient_permissions(self):
"""
GIVEN:
- A user is not logged in or does not have permissions
WHEN:
- The user requests the system status
THEN:
- The response contains a 401 status code or a 403 status code
"""
response = self.client.get(self.ENDPOINT)
self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED)
normal_user = User.objects.create_user(username="normal_user")
self.client.force_login(normal_user)
response = self.client.get(self.ENDPOINT)
self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN)
def test_system_status_container_detection(self):
"""
GIVEN:
- The application is running in a containerized environment
WHEN:
- The user requests the system status
THEN:
- The response contains the correct install type
"""
self.client.force_login(self.user)
os.environ["PNGX_CONTAINERIZED"] = "1"
response = self.client.get(self.ENDPOINT)
self.assertEqual(response.status_code, status.HTTP_200_OK)
self.assertEqual(response.data["install_type"], "docker")
os.environ["KUBERNETES_SERVICE_HOST"] = "http://localhost"
response = self.client.get(self.ENDPOINT)
self.assertEqual(response.data["install_type"], "kubernetes")
@mock.patch("redis.Redis.execute_command")
def test_system_status_redis_ping(self, mock_ping):
"""
GIVEN:
- Redies ping returns True
WHEN:
- The user requests the system status
THEN:
- The response contains the correct redis status
"""
mock_ping.return_value = True
self.client.force_login(self.user)
response = self.client.get(self.ENDPOINT)
self.assertEqual(response.status_code, status.HTTP_200_OK)
self.assertEqual(response.data["tasks"]["redis_status"], "OK")
@mock.patch("celery.app.control.Inspect.ping")
def test_system_status_celery_ping(self, mock_ping):
"""
GIVEN:
- Celery ping returns pong
WHEN:
- The user requests the system status
THEN:
- The response contains the correct celery status
"""
mock_ping.return_value = {"hostname": {"ok": "pong"}}
self.client.force_login(self.user)
response = self.client.get(self.ENDPOINT)
self.assertEqual(response.status_code, status.HTTP_200_OK)
self.assertEqual(response.data["tasks"]["celery_status"], "OK")
@override_settings(INDEX_DIR=Path("/tmp/index"))
@mock.patch("whoosh.index.FileIndex.last_modified")
def test_system_status_index_ok(self, mock_last_modified):
"""
GIVEN:
- The index last modified time is set
WHEN:
- The user requests the system status
THEN:
- The response contains the correct index status
"""
mock_last_modified.return_value = 1707839087
self.client.force_login(self.user)
response = self.client.get(self.ENDPOINT)
self.assertEqual(response.status_code, status.HTTP_200_OK)
self.assertEqual(response.data["tasks"]["index_status"], "OK")
self.assertIsNotNone(response.data["tasks"]["index_last_modified"])
@override_settings(INDEX_DIR="/tmp/index/")
@mock.patch("documents.index.open_index", autospec=True)
def test_system_status_index_error(self, mock_open_index):
"""
GIVEN:
- The index is not found
WHEN:
- The user requests the system status
THEN:
- The response contains the correct index status
"""
mock_open_index.return_value = None
mock_open_index.side_effect = Exception("Index error")
self.client.force_login(self.user)
response = self.client.get(self.ENDPOINT)
mock_open_index.assert_called_once()
self.assertEqual(response.status_code, status.HTTP_200_OK)
self.assertEqual(response.data["tasks"]["index_status"], "ERROR")
self.assertIsNotNone(response.data["tasks"]["index_error"])
@override_settings(DATA_DIR="/tmp/does_not_exist/data/")
def test_system_status_classifier_ok(self):
"""
GIVEN:
- The classifier is found
WHEN:
- The user requests the system status
THEN:
- The response contains an OK classifier status
"""
load_classifier()
test_classifier = DocumentClassifier()
test_classifier.save()
self.client.force_login(self.user)
response = self.client.get(self.ENDPOINT)
self.assertEqual(response.status_code, status.HTTP_200_OK)
self.assertEqual(response.data["tasks"]["classifier_status"], "OK")
self.assertIsNone(response.data["tasks"]["classifier_error"])
def test_system_status_classifier_warning(self):
"""
GIVEN:
- The classifier does not exist yet
- > 0 documents and tags with auto matching exist
WHEN:
- The user requests the system status
THEN:
- The response contains an WARNING classifier status
"""
with override_settings(MODEL_FILE="does_not_exist"):
Document.objects.create(
title="Test Document",
)
Tag.objects.create(name="Test Tag", matching_algorithm=Tag.MATCH_AUTO)
self.client.force_login(self.user)
response = self.client.get(self.ENDPOINT)
self.assertEqual(response.status_code, status.HTTP_200_OK)
self.assertEqual(response.data["tasks"]["classifier_status"], "WARNING")
self.assertIsNotNone(response.data["tasks"]["classifier_error"])
def test_system_status_classifier_error(self):
"""
GIVEN:
- The classifier does exist but is corrupt
- > 0 documents and tags with auto matching exist
WHEN:
- The user requests the system status
THEN:
- The response contains an ERROR classifier status
"""
does_exist = tempfile.NamedTemporaryFile(
dir="/tmp",
delete=False,
)
with override_settings(MODEL_FILE=does_exist):
with mock.patch("documents.classifier.load_classifier") as mock_load:
mock_load.side_effect = ClassifierModelCorruptError()
Document.objects.create(
title="Test Document",
)
Tag.objects.create(name="Test Tag", matching_algorithm=Tag.MATCH_AUTO)
self.client.force_login(self.user)
response = self.client.get(self.ENDPOINT)
self.assertEqual(response.status_code, status.HTTP_200_OK)
self.assertEqual(response.data["tasks"]["classifier_status"], "ERROR")
self.assertIsNotNone(response.data["tasks"]["classifier_error"])
def test_system_status_classifier_ok_no_objects(self):
"""
GIVEN:
- The classifier does not exist (and should not)
- No documents nor objects with auto matching exist
WHEN:
- The user requests the system status
THEN:
- The response contains an OK classifier status
"""
with override_settings(MODEL_FILE="does_not_exist"):
self.client.force_login(self.user)
response = self.client.get(self.ENDPOINT)
self.assertEqual(response.status_code, status.HTTP_200_OK)
self.assertEqual(response.data["tasks"]["classifier_status"], "OK")

View File

@ -202,6 +202,19 @@ class TestApiWorkflows(DirectoriesMixin, APITestCase):
"assign_change_groups": [self.group1.id], "assign_change_groups": [self.group1.id],
"assign_custom_fields": [self.cf2.id], "assign_custom_fields": [self.cf2.id],
}, },
{
"type": WorkflowAction.WorkflowActionType.REMOVAL,
"remove_tags": [self.t3.id],
"remove_document_types": [self.dt.id],
"remove_correspondents": [self.c.id],
"remove_storage_paths": [self.sp.id],
"remove_custom_fields": [self.cf1.id],
"remove_owners": [self.user2.id],
"remove_view_users": [self.user3.id],
"remove_change_users": [self.user3.id],
"remove_view_groups": [self.group1.id],
"remove_change_groups": [self.group1.id],
},
], ],
}, },
), ),

View File

@ -1223,3 +1223,332 @@ class TestWorkflows(DirectoriesMixin, FileSystemAssertsMixin, APITestCase):
title="test", title="test",
) )
self.assertRaises(Exception, document_matches_workflow, doc, w, 4) self.assertRaises(Exception, document_matches_workflow, doc, w, 4)
def test_removal_action_document_updated_workflow(self):
"""
GIVEN:
- Workflow with removal action
WHEN:
- File that matches is updated
THEN:
- Action removals are applied
"""
trigger = WorkflowTrigger.objects.create(
type=WorkflowTrigger.WorkflowTriggerType.DOCUMENT_UPDATED,
filter_path="*",
)
action = WorkflowAction.objects.create(
type=WorkflowAction.WorkflowActionType.REMOVAL,
)
action.remove_correspondents.add(self.c)
action.remove_tags.add(self.t1)
action.remove_document_types.add(self.dt)
action.remove_storage_paths.add(self.sp)
action.remove_owners.add(self.user2)
action.remove_custom_fields.add(self.cf1)
action.remove_view_users.add(self.user3)
action.remove_view_groups.add(self.group1)
action.remove_change_users.add(self.user3)
action.remove_change_groups.add(self.group1)
action.save()
w = Workflow.objects.create(
name="Workflow 1",
order=0,
)
w.triggers.add(trigger)
w.actions.add(action)
w.save()
doc = Document.objects.create(
title="sample test",
correspondent=self.c,
document_type=self.dt,
storage_path=self.sp,
owner=self.user2,
original_filename="sample.pdf",
)
doc.tags.set([self.t1, self.t2])
CustomFieldInstance.objects.create(document=doc, field=self.cf1)
doc.save()
assign_perm("documents.view_document", self.user3, doc)
assign_perm("documents.change_document", self.user3, doc)
assign_perm("documents.view_document", self.group1, doc)
assign_perm("documents.change_document", self.group1, doc)
superuser = User.objects.create_superuser("superuser")
self.client.force_authenticate(user=superuser)
self.client.patch(
f"/api/documents/{doc.id}/",
{"title": "new title"},
format="json",
)
doc.refresh_from_db()
self.assertIsNone(doc.document_type)
self.assertIsNone(doc.correspondent)
self.assertIsNone(doc.storage_path)
self.assertEqual(doc.tags.all().count(), 1)
self.assertIn(self.t2, doc.tags.all())
self.assertIsNone(doc.owner)
self.assertEqual(doc.custom_fields.all().count(), 0)
self.assertFalse(self.user3.has_perm("documents.view_document", doc))
self.assertFalse(self.user3.has_perm("documents.change_document", doc))
group_perms: QuerySet = get_groups_with_perms(doc)
self.assertNotIn(self.group1, group_perms)
def test_removal_action_document_updated_removeall(self):
"""
GIVEN:
- Workflow with removal action with remove all fields set
WHEN:
- File that matches is updated
THEN:
- Action removals are applied
"""
trigger = WorkflowTrigger.objects.create(
type=WorkflowTrigger.WorkflowTriggerType.DOCUMENT_UPDATED,
filter_path="*",
)
action = WorkflowAction.objects.create(
type=WorkflowAction.WorkflowActionType.REMOVAL,
remove_all_correspondents=True,
remove_all_tags=True,
remove_all_document_types=True,
remove_all_storage_paths=True,
remove_all_custom_fields=True,
remove_all_owners=True,
remove_all_permissions=True,
)
action.save()
w = Workflow.objects.create(
name="Workflow 1",
order=0,
)
w.triggers.add(trigger)
w.actions.add(action)
w.save()
doc = Document.objects.create(
title="sample test",
correspondent=self.c,
document_type=self.dt,
storage_path=self.sp,
owner=self.user2,
original_filename="sample.pdf",
)
doc.tags.set([self.t1, self.t2])
CustomFieldInstance.objects.create(document=doc, field=self.cf1)
doc.save()
assign_perm("documents.view_document", self.user3, doc)
assign_perm("documents.change_document", self.user3, doc)
assign_perm("documents.view_document", self.group1, doc)
assign_perm("documents.change_document", self.group1, doc)
superuser = User.objects.create_superuser("superuser")
self.client.force_authenticate(user=superuser)
self.client.patch(
f"/api/documents/{doc.id}/",
{"title": "new title"},
format="json",
)
doc.refresh_from_db()
self.assertIsNone(doc.document_type)
self.assertIsNone(doc.correspondent)
self.assertIsNone(doc.storage_path)
self.assertEqual(doc.tags.all().count(), 0)
self.assertEqual(doc.tags.all().count(), 0)
self.assertIsNone(doc.owner)
self.assertEqual(doc.custom_fields.all().count(), 0)
self.assertFalse(self.user3.has_perm("documents.view_document", doc))
self.assertFalse(self.user3.has_perm("documents.change_document", doc))
group_perms: QuerySet = get_groups_with_perms(doc)
self.assertNotIn(self.group1, group_perms)
@mock.patch("documents.consumer.Consumer.try_consume_file")
def test_removal_action_document_consumed(self, m):
"""
GIVEN:
- Workflow with assignment and removal actions
WHEN:
- File that matches is consumed
THEN:
- Action removals are applied
"""
trigger = WorkflowTrigger.objects.create(
type=WorkflowTrigger.WorkflowTriggerType.CONSUMPTION,
filter_filename="*simple*",
)
action = WorkflowAction.objects.create(
assign_title="Doc from {correspondent}",
assign_correspondent=self.c,
assign_document_type=self.dt,
assign_storage_path=self.sp,
assign_owner=self.user2,
)
action.assign_tags.add(self.t1)
action.assign_tags.add(self.t2)
action.assign_tags.add(self.t3)
action.assign_view_users.add(self.user2)
action.assign_view_users.add(self.user3)
action.assign_view_groups.add(self.group1)
action.assign_view_groups.add(self.group2)
action.assign_change_users.add(self.user2)
action.assign_change_users.add(self.user3)
action.assign_change_groups.add(self.group1)
action.assign_change_groups.add(self.group2)
action.assign_custom_fields.add(self.cf1)
action.assign_custom_fields.add(self.cf2)
action.save()
action2 = WorkflowAction.objects.create(
type=WorkflowAction.WorkflowActionType.REMOVAL,
)
action2.remove_correspondents.add(self.c)
action2.remove_tags.add(self.t1)
action2.remove_document_types.add(self.dt)
action2.remove_storage_paths.add(self.sp)
action2.remove_owners.add(self.user2)
action2.remove_custom_fields.add(self.cf1)
action2.remove_view_users.add(self.user3)
action2.remove_change_users.add(self.user3)
action2.remove_view_groups.add(self.group1)
action2.remove_change_groups.add(self.group1)
action2.save()
w = Workflow.objects.create(
name="Workflow 1",
order=0,
)
w.triggers.add(trigger)
w.actions.add(action)
w.actions.add(action2)
w.save()
test_file = self.SAMPLE_DIR / "simple.pdf"
with mock.patch("documents.tasks.ProgressManager", DummyProgressManager):
with self.assertLogs("paperless.matching", level="INFO") as cm:
tasks.consume_file(
ConsumableDocument(
source=DocumentSource.ConsumeFolder,
original_file=test_file,
),
None,
)
m.assert_called_once()
_, overrides = m.call_args
self.assertIsNone(overrides["override_correspondent_id"])
self.assertIsNone(overrides["override_document_type_id"])
self.assertEqual(
overrides["override_tag_ids"],
[self.t2.pk, self.t3.pk],
)
self.assertIsNone(overrides["override_storage_path_id"])
self.assertIsNone(overrides["override_owner_id"])
self.assertEqual(overrides["override_view_users"], [self.user2.pk])
self.assertEqual(overrides["override_view_groups"], [self.group2.pk])
self.assertEqual(overrides["override_change_users"], [self.user2.pk])
self.assertEqual(overrides["override_change_groups"], [self.group2.pk])
self.assertEqual(
overrides["override_title"],
"Doc from {correspondent}",
)
self.assertEqual(
overrides["override_custom_field_ids"],
[self.cf2.pk],
)
info = cm.output[0]
expected_str = f"Document matched {trigger} from {w}"
self.assertIn(expected_str, info)
@mock.patch("documents.consumer.Consumer.try_consume_file")
def test_removal_action_document_consumed_removeall(self, m):
"""
GIVEN:
- Workflow with assignment and removal actions with remove all fields set
WHEN:
- File that matches is consumed
THEN:
- Action removals are applied
"""
trigger = WorkflowTrigger.objects.create(
type=WorkflowTrigger.WorkflowTriggerType.CONSUMPTION,
filter_filename="*simple*",
)
action = WorkflowAction.objects.create(
assign_title="Doc from {correspondent}",
assign_correspondent=self.c,
assign_document_type=self.dt,
assign_storage_path=self.sp,
assign_owner=self.user2,
)
action.assign_tags.add(self.t1)
action.assign_tags.add(self.t2)
action.assign_tags.add(self.t3)
action.assign_view_users.add(self.user3.pk)
action.assign_view_groups.add(self.group1.pk)
action.assign_change_users.add(self.user3.pk)
action.assign_change_groups.add(self.group1.pk)
action.assign_custom_fields.add(self.cf1.pk)
action.assign_custom_fields.add(self.cf2.pk)
action.save()
action2 = WorkflowAction.objects.create(
type=WorkflowAction.WorkflowActionType.REMOVAL,
remove_all_correspondents=True,
remove_all_tags=True,
remove_all_document_types=True,
remove_all_storage_paths=True,
remove_all_custom_fields=True,
remove_all_owners=True,
remove_all_permissions=True,
)
w = Workflow.objects.create(
name="Workflow 1",
order=0,
)
w.triggers.add(trigger)
w.actions.add(action)
w.actions.add(action2)
w.save()
test_file = self.SAMPLE_DIR / "simple.pdf"
with mock.patch("documents.tasks.ProgressManager", DummyProgressManager):
with self.assertLogs("paperless.matching", level="INFO") as cm:
tasks.consume_file(
ConsumableDocument(
source=DocumentSource.ConsumeFolder,
original_file=test_file,
),
None,
)
m.assert_called_once()
_, overrides = m.call_args
self.assertIsNone(overrides["override_correspondent_id"])
self.assertIsNone(overrides["override_document_type_id"])
self.assertEqual(
overrides["override_tag_ids"],
[],
)
self.assertIsNone(overrides["override_storage_path_id"])
self.assertIsNone(overrides["override_owner_id"])
self.assertEqual(overrides["override_view_users"], [])
self.assertEqual(overrides["override_view_groups"], [])
self.assertEqual(overrides["override_change_users"], [])
self.assertEqual(overrides["override_change_groups"], [])
self.assertEqual(
overrides["override_custom_field_ids"],
[],
)
info = cm.output[0]
expected_str = f"Document matched {trigger} from {w}"
self.assertIn(expected_str, info)

View File

@ -1,8 +1,12 @@
import shutil import shutil
from os import utime from os import utime
from pathlib import Path from pathlib import Path
from typing import Optional
from typing import Union from typing import Union
from django.conf import settings
from PIL import Image
def _coerce_to_path( def _coerce_to_path(
source: Union[Path, str], source: Union[Path, str],
@ -40,3 +44,15 @@ def copy_file_with_basic_stats(
shutil.copy(source, dest) shutil.copy(source, dest)
copy_basic_file_stats(source, dest) copy_basic_file_stats(source, dest)
def maybe_override_pixel_limit() -> None:
"""
Maybe overrides the PIL limit on pixel count, if configured to allow it
"""
limit: Optional[Union[float, int]] = settings.MAX_IMAGE_PIXELS
if limit is not None and limit >= 0:
pixel_count = limit
if pixel_count == 0:
pixel_count = None
Image.MAX_IMAGE_PIXELS = pixel_count

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