]> git.ipfire.org Git - thirdparty/paperless-ngx.git/commitdiff
Feature: Enhanced templating for filename format (#7836)
authorTrenton H <797416+stumpylog@users.noreply.github.com>
Sun, 6 Oct 2024 19:54:01 +0000 (12:54 -0700)
committerGitHub <noreply@github.com>
Sun, 6 Oct 2024 19:54:01 +0000 (12:54 -0700)
Co-authored-by: shamoon <4887959+shamoon@users.noreply.github.com>
29 files changed:
Pipfile
Pipfile.lock
docs/advanced_usage.md
src-ui/messages.xlf
src-ui/src/app/app.module.ts
src-ui/src/app/components/common/edit-dialog/storage-path-edit-dialog/storage-path-edit-dialog.component.html
src-ui/src/app/components/common/edit-dialog/storage-path-edit-dialog/storage-path-edit-dialog.component.spec.ts
src-ui/src/app/components/common/edit-dialog/storage-path-edit-dialog/storage-path-edit-dialog.component.ts
src-ui/src/app/components/common/input/textarea/textarea.component.html [new file with mode: 0644]
src-ui/src/app/components/common/input/textarea/textarea.component.scss [new file with mode: 0644]
src-ui/src/app/components/common/input/textarea/textarea.component.spec.ts [new file with mode: 0644]
src-ui/src/app/components/common/input/textarea/textarea.component.ts [new file with mode: 0644]
src-ui/src/app/components/manage/management-list/management-list.component.html
src-ui/src/app/components/manage/management-list/management-list.component.ts
src-ui/src/app/components/manage/storage-path-list/storage-path-list.component.spec.ts
src-ui/src/app/components/manage/storage-path-list/storage-path-list.component.ts
src/documents/checks.py
src/documents/file_handling.py
src/documents/migrations/1012_fix_archive_files.py
src/documents/migrations/1055_alter_storagepath_path.py [new file with mode: 0644]
src/documents/models.py
src/documents/serialisers.py
src/documents/templating/__init__.py [new file with mode: 0644]
src/documents/templating/filepath.py [new file with mode: 0644]
src/documents/templating/utils.py [new file with mode: 0644]
src/documents/tests/test_api_objects.py
src/documents/tests/test_checks.py
src/documents/tests/test_file_handling.py
src/documents/tests/test_migration_storage_path_template.py [new file with mode: 0644]

diff --git a/Pipfile b/Pipfile
index a872e1184f8cfb552586acf3b814e945f3292088..c2db3348771d391ab7c95b0e305d4264d690d3a1 100644 (file)
--- a/Pipfile
+++ b/Pipfile
@@ -57,6 +57,7 @@ watchdog = "~=4.0"
 whitenoise = "~=6.7"
 whoosh = "~=2.7"
 zxing-cpp = {version = "*", platform_machine = "== 'x86_64'"}
+jinja2 = "~=3.1"
 
 [dev-packages]
 # Linting
index 47622a94f49714f8f28228d9671522c095e58e59..675e89c10452d50176114692f02cb8cbc50a4d7c 100644 (file)
@@ -1,7 +1,7 @@
 {
     "_meta": {
         "hash": {
-            "sha256": "1be8ddf875b6aa77fcf61f5c065c9dc3941cad4b9285ce64da60b5684357dade"
+            "sha256": "1e113d0879e4e0bc3c384115057647ac8d9be05252dd7c708a1fc873f294ef28"
         },
         "pipfile-spec": 6,
         "requires": {},
         },
         "django-soft-delete": {
             "hashes": [
-                "sha256:428df56ea4fbb13f42d4f752f11f2a517aa31ac3d1b450e6b78c4c5d5d9dfc3b",
-                "sha256:558821ea988fd69a3a7008cdb33a06ded491af828bdffa5b287fa0fb72b52a09"
+                "sha256:36cf26a9eaa5f4c0fdb5cb6367ea183e91b7f73783cad173e4071a4747dd1277",
+                "sha256:fc16c870020984b7f58254adead12fdfb637a6c2f4bd8a93a3a636b18b1463e0"
             ],
             "index": "pypi",
             "markers": "python_version >= '3.6'",
-            "version": "==1.0.14"
+            "version": "==1.0.15"
         },
         "djangorestframework": {
             "hashes": [
         },
         "httpcore": {
             "hashes": [
-                "sha256:34a38e2f9291467ee3b44e89dd52615370e152954ba21721378a87b2960f7a61",
-                "sha256:421f18bac248b25d310f3cacd198d55b8e6125c107797b609ff9b7a6ba7991b5"
+                "sha256:27b59625743b85577a8c0e10e55b50b5368a4f2cfe8cc7bcfa9cf00829c2682f",
+                "sha256:73f6dbd6eb8c21bbf7ef8efad555481853f5f6acdeaff1edb0694289269ee17f"
             ],
             "markers": "python_version >= '3.8'",
-            "version": "==1.0.5"
+            "version": "==1.0.6"
         },
         "httptools": {
             "hashes": [
         },
         "imap-tools": {
             "hashes": [
-                "sha256:218ea6495d73275ecc2fa4a34717c137bacf2c4a3d34c9d10a9581a6af1ac94f",
-                "sha256:4c31e9df1d28149436a86871cf84a0b37221a91521fc1a57897e0a152ee3f6d1"
+                "sha256:bd84d0f40fbd7be27f6ff5c3908e74d96e99d6b5f44f19cd6e928d308c811916",
+                "sha256:e657df2f62c1b263c0fd1610cfcd9f8cde26de6b696ae25c401ba75d91a5fd93"
             ],
             "index": "pypi",
-            "version": "==1.7.2"
+            "version": "==1.7.3"
         },
         "img2pdf": {
             "hashes": [
             "hashes": [
                 "sha256:8440ffe49c4ae81a8df57c1ae1eb4b6bfa7acb830099bfb3e305b383005cc128"
             ],
-            "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'",
             "version": "==1.3.5"
         },
         "inotifyrecursive": {
                 "sha256:a2c450b317693e4538416f90eb1d7858506dafe6b8b885037bd2dd9ae2dafa1e"
             ],
             "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'",
             "version": "==0.3.5"
         },
+        "jinja2": {
+            "hashes": [
+                "sha256:4a3aee7acbbe7303aede8e9648d13b8bf88a429282aa6122a993f0ac800cb369",
+                "sha256:bc5dd2abb727a5319567b7a813e6a2e7318c39f4f487cfe6c89c6f9c7d25197d"
+            ],
+            "index": "pypi",
+            "markers": "python_version >= '3.7'",
+            "version": "==3.1.4"
+        },
         "joblib": {
             "hashes": [
                 "sha256:06d478d5674cbc267e7496a410ee875abd68e4340feff4490bcb7afb88060ae6",
             "markers": "python_version >= '3.8'",
             "version": "==3.0.0"
         },
+        "markupsafe": {
+            "hashes": [
+                "sha256:00e046b6dd71aa03a41079792f8473dc494d564611a8f89bbbd7cb93295ebdcf",
+                "sha256:075202fa5b72c86ad32dc7d0b56024ebdbcf2048c0ba09f1cde31bfdd57bcfff",
+                "sha256:0e397ac966fdf721b2c528cf028494e86172b4feba51d65f81ffd65c63798f3f",
+                "sha256:17b950fccb810b3293638215058e432159d2b71005c74371d784862b7e4683f3",
+                "sha256:1f3fbcb7ef1f16e48246f704ab79d79da8a46891e2da03f8783a5b6fa41a9532",
+                "sha256:2174c595a0d73a3080ca3257b40096db99799265e1c27cc5a610743acd86d62f",
+                "sha256:2b7c57a4dfc4f16f7142221afe5ba4e093e09e728ca65c51f5620c9aaeb9a617",
+                "sha256:2d2d793e36e230fd32babe143b04cec8a8b3eb8a3122d2aceb4a371e6b09b8df",
+                "sha256:30b600cf0a7ac9234b2638fbc0fb6158ba5bdcdf46aeb631ead21248b9affbc4",
+                "sha256:397081c1a0bfb5124355710fe79478cdbeb39626492b15d399526ae53422b906",
+                "sha256:3a57fdd7ce31c7ff06cdfbf31dafa96cc533c21e443d57f5b1ecc6cdc668ec7f",
+                "sha256:3c6b973f22eb18a789b1460b4b91bf04ae3f0c4234a0a6aa6b0a92f6f7b951d4",
+                "sha256:3e53af139f8579a6d5f7b76549125f0d94d7e630761a2111bc431fd820e163b8",
+                "sha256:4096e9de5c6fdf43fb4f04c26fb114f61ef0bf2e5604b6ee3019d51b69e8c371",
+                "sha256:4275d846e41ecefa46e2015117a9f491e57a71ddd59bbead77e904dc02b1bed2",
+                "sha256:4c31f53cdae6ecfa91a77820e8b151dba54ab528ba65dfd235c80b086d68a465",
+                "sha256:4f11aa001c540f62c6166c7726f71f7573b52c68c31f014c25cc7901deea0b52",
+                "sha256:5049256f536511ee3f7e1b3f87d1d1209d327e818e6ae1365e8653d7e3abb6a6",
+                "sha256:58c98fee265677f63a4385256a6d7683ab1832f3ddd1e66fe948d5880c21a169",
+                "sha256:598e3276b64aff0e7b3451b72e94fa3c238d452e7ddcd893c3ab324717456bad",
+                "sha256:5b7b716f97b52c5a14bffdf688f971b2d5ef4029127f1ad7a513973cfd818df2",
+                "sha256:5dedb4db619ba5a2787a94d877bc8ffc0566f92a01c0ef214865e54ecc9ee5e0",
+                "sha256:619bc166c4f2de5caa5a633b8b7326fbe98e0ccbfacabd87268a2b15ff73a029",
+                "sha256:629ddd2ca402ae6dbedfceeba9c46d5f7b2a61d9749597d4307f943ef198fc1f",
+                "sha256:656f7526c69fac7f600bd1f400991cc282b417d17539a1b228617081106feb4a",
+                "sha256:6ec585f69cec0aa07d945b20805be741395e28ac1627333b1c5b0105962ffced",
+                "sha256:72b6be590cc35924b02c78ef34b467da4ba07e4e0f0454a2c5907f473fc50ce5",
+                "sha256:7502934a33b54030eaf1194c21c692a534196063db72176b0c4028e140f8f32c",
+                "sha256:7a68b554d356a91cce1236aa7682dc01df0edba8d043fd1ce607c49dd3c1edcf",
+                "sha256:7b2e5a267c855eea6b4283940daa6e88a285f5f2a67f2220203786dfa59b37e9",
+                "sha256:823b65d8706e32ad2df51ed89496147a42a2a6e01c13cfb6ffb8b1e92bc910bb",
+                "sha256:8590b4ae07a35970728874632fed7bd57b26b0102df2d2b233b6d9d82f6c62ad",
+                "sha256:8dd717634f5a044f860435c1d8c16a270ddf0ef8588d4887037c5028b859b0c3",
+                "sha256:8dec4936e9c3100156f8a2dc89c4b88d5c435175ff03413b443469c7c8c5f4d1",
+                "sha256:97cafb1f3cbcd3fd2b6fbfb99ae11cdb14deea0736fc2b0952ee177f2b813a46",
+                "sha256:a17a92de5231666cfbe003f0e4b9b3a7ae3afb1ec2845aadc2bacc93ff85febc",
+                "sha256:a549b9c31bec33820e885335b451286e2969a2d9e24879f83fe904a5ce59d70a",
+                "sha256:ac07bad82163452a6884fe8fa0963fb98c2346ba78d779ec06bd7a6262132aee",
+                "sha256:ae2ad8ae6ebee9d2d94b17fb62763125f3f374c25618198f40cbb8b525411900",
+                "sha256:b91c037585eba9095565a3556f611e3cbfaa42ca1e865f7b8015fe5c7336d5a5",
+                "sha256:bc1667f8b83f48511b94671e0e441401371dfd0f0a795c7daa4a3cd1dde55bea",
+                "sha256:bec0a414d016ac1a18862a519e54b2fd0fc8bbfd6890376898a6c0891dd82e9f",
+                "sha256:bf50cd79a75d181c9181df03572cdce0fbb75cc353bc350712073108cba98de5",
+                "sha256:bff1b4290a66b490a2f4719358c0cdcd9bafb6b8f061e45c7a2460866bf50c2e",
+                "sha256:c061bb86a71b42465156a3ee7bd58c8c2ceacdbeb95d05a99893e08b8467359a",
+                "sha256:c8b29db45f8fe46ad280a7294f5c3ec36dbac9491f2d1c17345be8e69cc5928f",
+                "sha256:ce409136744f6521e39fd8e2a24c53fa18ad67aa5bc7c2cf83645cce5b5c4e50",
+                "sha256:d050b3361367a06d752db6ead6e7edeb0009be66bc3bae0ee9d97fb326badc2a",
+                "sha256:d283d37a890ba4c1ae73ffadf8046435c76e7bc2247bbb63c00bd1a709c6544b",
+                "sha256:d9fad5155d72433c921b782e58892377c44bd6252b5af2f67f16b194987338a4",
+                "sha256:daa4ee5a243f0f20d528d939d06670a298dd39b1ad5f8a72a4275124a7819eff",
+                "sha256:db0b55e0f3cc0be60c1f19efdde9a637c32740486004f20d1cff53c3c0ece4d2",
+                "sha256:e61659ba32cf2cf1481e575d0462554625196a1f2fc06a1c777d3f48e8865d46",
+                "sha256:ea3d8a3d18833cf4304cd2fc9cbb1efe188ca9b5efef2bdac7adc20594a0e46b",
+                "sha256:ec6a563cff360b50eed26f13adc43e61bc0c04d94b8be985e6fb24b81f6dcfdf",
+                "sha256:f5dfb42c4604dddc8e4305050aa6deb084540643ed5804d7455b5df8fe16f5e5",
+                "sha256:fa173ec60341d6bb97a89f5ea19c85c5643c1e7dedebc22f5181eb73573142c5",
+                "sha256:fa9db3f79de01457b03d4f01b34cf91bc0048eb2c3846ff26f66687c2f6d16ab",
+                "sha256:fce659a462a1be54d2ffcacea5e3ba2d74daa74f30f5f143fe0c58636e355fdd",
+                "sha256:ffee1f21e5ef0d712f9033568f8344d5da8cc2869dbd08d87c84656e6a2d2f68"
+            ],
+            "markers": "python_version >= '3.7'",
+            "version": "==2.1.5"
+        },
         "mdurl": {
             "hashes": [
                 "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8",
         },
         "pikepdf": {
             "hashes": [
-                "sha256:01be001988ce0f6a5a89319f37fc14f27df75c4e332222ed8e993d14405acb02",
-                "sha256:0759842e47369fe5fa0d61de2ac9ff073895c75567f3efbc4aebc6c1cafee17e",
-                "sha256:127e94632eb1ccd5d4d859511f084a0a314555cba621595a135915fc9e1710c5",
-                "sha256:163600dcd8d158e9287934b65a516b469b153859ab029e40fb3a0eff16c7dd7a",
-                "sha256:1dd707e6159af953f5560138f695b3a1ae2e1a0750535be70a3b75a720279330",
-                "sha256:1e6b3083ef2e3c29af33fcdb73a9a61a8e4dbe540edb474c19b9866194c6bf25",
-                "sha256:3c7e5c3a425de7db1fc13583883d2fa10119ce85071cc1d53344383498739254",
-                "sha256:3efff6ffda819d4193dd8e63c6f304bf85f9ae961c0247dc0b716b7c74fb7094",
-                "sha256:4a5c5ccccb5812a5be5b5cb66c8c8a6f796910ab89932a3048a4e66e5436bd01",
-                "sha256:4b9e9416da42da43f386244b2bab2a236830ccb11598b73fcd43d32fd234aaff",
-                "sha256:4c8bf24b8bf933f4022c6ace5ee757453e3dacb806a8e826461fd5f33ce15a70",
-                "sha256:531b6685912eb630a7fe57c527c9b5636c50c543eb0cdb5807b139e0d7712696",
-                "sha256:5e31aeb15ab21ba340a9013c1665e7ce85bd1f8167e6710c455d51f82c2e64e0",
-                "sha256:61bb9dfe58ee3ee2a286ea4cd21af87e1853a2d1433b550e3f58faa005b6ea3a",
-                "sha256:6275467b7eacb6fb04f16727e90e6562c6bbf449ece4e57273956beb8f1cdacd",
-                "sha256:6e15689fd715e83ff555cbdb939a0453c6c94af9975ae9b3292dd68231014653",
-                "sha256:755f559c206de5b3de0e35430ad28e50f37866d96a41b3ad41d7114660e1c58b",
-                "sha256:7fa15e5ff3e17dc6295d676d673787c79fec67cca59261a22ccf7604914170b1",
-                "sha256:8a50c58bee394f69561ab2861f77ce763f91cf7af6c8a1919109bb33fe8ca669",
-                "sha256:9699fe058b44e59cdcd05bcadf9cfa8f5242b48e44f9a4772bb321cd74d8e339",
-                "sha256:96ea92374d25481a2213403ae06c990ea41a1f35b0404dd072b7070dac76f41b",
-                "sha256:98ff348c97c7c641c2d2b741d60c8edf22e0fe76fa5c386cb351a3abd3f2a9b9",
-                "sha256:a32ef219737e53b48754acb45ad7840aee8403d97fc79539c26501a2d9089c91",
-                "sha256:aefa94f8ea6371fc3cbf78f55f669efec6e28e317927e8dd8a237e19a7be50fb",
-                "sha256:baaf78ed49e3cecfc4d30f2c7291d9b19bebe8a5f8e5940d7e7c93683b47a6f9",
-                "sha256:c1b883e1ebe28fbc318ce5c971b3dca9b30621bc2fe1642c99cda76cf442c4a2",
-                "sha256:c2c21c6a3d7ec96c7f9627ad61195eadff12659e3e00abe7156c34503189db47",
-                "sha256:c4eb22efae62b057a31ee4cb5574db8edfe15b185c8e89500eca8157fda15974",
-                "sha256:c6ea5f623629478abaf1e25b1d0edcaee3d0408fd9061fb4f7dc24fb78a25302",
-                "sha256:cd73d828799e41ee778606e30efd0c27be1e2420b1ed0c9cbc39299872ceed76",
-                "sha256:ceeac42bfb7227310e617e871d8f7ae6f304cf2783ca0131f3063c54ee1ecb73",
-                "sha256:d1a1314e4c4b2a28a1af1e700570b3c32c074cf363425768e8bc9f031438aee3",
-                "sha256:d209e4a9ba99a4460cf987f6cd8703a8723d8a62fc51451c4c1233eff07db02f",
-                "sha256:d360e64c31f73b16b78ca1e10e9d96f758b4a3fac195cd35f88a5f213808852e",
-                "sha256:d37ce8a4ade0cddf3827e13867208ffc8c161d38fdb12250b31e1b8cfa58ab1b",
-                "sha256:d6f240b0c1da5b6656efa3daa087394ddce5b3ecc411b85efcfd7e7228a1bc26",
-                "sha256:d9ba6c639faac47a85817854d002e2f57683ffe65388a746af580c4a6521646c",
-                "sha256:e199833ef11a64f22945a9a98d56a98968e988e407cb20d9fa8b6081075c9604",
-                "sha256:e1e47e80ecfd77dbfc6c7e807e78e5cce0c10d5bd7804c0d9064429d72af981c",
-                "sha256:e863185d6abadab140a7c3e152d9227afe495cf97d4738efc280896660249180",
-                "sha256:eb65a84fff25295707250b49f9e2d1186e9f6b4b7f828a0d9e7e2b65a7af6311",
-                "sha256:f2e4d5632dc03a41d901e4feee474557145c4906d96cf6e7ae8106a85142d2eb",
-                "sha256:f3ecbc250254b61de2ca973e3d57acb07720e5a810ee0c81d33b051c76d22208",
-                "sha256:f6b1ee86850fddaea15afdde394109332f7dc63a156e52fb131f9b647b16f920",
-                "sha256:fc0deac6dd356ef95fcf42db917cfe2c5375640295609924d4825052c2124509"
+                "sha256:08d0c72ba70cbe9f45772168e0c922b8d7625899cbfbcbd0dfd1316acff90258",
+                "sha256:0da5ebba4a31e257ca86a93657a4d47afffeda2ee48cde25227ce43d6dabae13",
+                "sha256:0f74ba40a3c6f450d19b0958df5c92f84965f4160fd973d4a00f00492093f01c",
+                "sha256:180e7423f3b517688cf14d6c5537e97a1a9b047421915bb28d3198f881b46f14",
+                "sha256:18e48cc0359f29b5083bad94237b53d928d8491f7ba5d4a389ca5c366226d766",
+                "sha256:287206055d2543ee768f85c24146e267c2465c1b2024e37ccf80b5a16674d2a2",
+                "sha256:344602b23ae6852180587c8e3280719ac31c78a4ca6cf08d8a51467d5f1741ba",
+                "sha256:363d01aa89f871c12fdc3d08c677456d693028cfb865e314cebe679273a7ebcb",
+                "sha256:38b3f882351d17f65d38d43d24772cfe471b63dc8c09dad52434c4fe02693e33",
+                "sha256:3afa0ea7b57a125a7744313b08062e59ecca15b2b3b31d13431244ec99b4d683",
+                "sha256:3ffc14ad4172f7acd7c1c7eb22eeac66f92c93c83941c63a3b56961602af67d7",
+                "sha256:40724cb905ce682c97f048e4eb3a728eade6dd1bc64425f3b7bb9872688964ea",
+                "sha256:4a56b7ccf13817689adb977ba92efa8d567d42a307154acff156179ddb76668b",
+                "sha256:53202d816838e87ee80c28af695b554e3cbfd5cb3598d7bcfba533f9dbd411e9",
+                "sha256:58e256aec46ee13256e264bae949e23a98707833fc27a3e3c7172c034d0ab870",
+                "sha256:5eef37caae6ad7a4baa4a6cdb35690945ee1a83bc0da5bbbf0023bc27d113f9c",
+                "sha256:663ddb129d823f9e1d1e5b4118906c508b801bf1d86fd8583938f96588bf8dda",
+                "sha256:689fcd1e89857ddc31191d4cc7a1fab2dbb5ce88c347f4de0db41abb176a11fb",
+                "sha256:6b905b05fc32c4e279aceb1578d7d917ed9a4e70a8a8e8d1b40ee8afff9d6bfc",
+                "sha256:7a9a738186b07a1177369713e8003371d0393808e5a62b2af86751dad6684a92",
+                "sha256:7a9feafdb688e64e4017b4596c3cf90793cd658b53e915e6c5a2668d1b3eb0c9",
+                "sha256:7ac65c0ace97d995dc7263d2912208ac5310c2f84f42f1fdf043b47d77c01852",
+                "sha256:7dd4166bb14db7d0711f2a32b21cf479217e34828af435b7ece0fab6ea02664d",
+                "sha256:8022a925cb2c67a1de3736c19de5d280d43241e1b118f1188b94df07e84c8b8f",
+                "sha256:80630a897d4203be10861e4e7fca8774cf1a85a1abcc41f978984564fb729ef6",
+                "sha256:8422a3944187a8d24626812044b6b09c865426e2bf8d0b2ead80f56f609b3345",
+                "sha256:84555d4039ea10935fa2d0084577de5b81b508b9716ce482163e2dc65db1b180",
+                "sha256:8dbab43c6a6fa2737df6cfccd049bbe5b762c39809a0b14484d0154f403be4fb",
+                "sha256:8f1153d3f7be818ba0f9f0875f37ed5203c3d500c33a4058a4d2d0f978d3ce29",
+                "sha256:906d8afc1aa4f2f7409381a58e158207170f3aeba8ad2aec40072a648e8a2914",
+                "sha256:98e546120b0d5707836a5ced43b09c086f5866f6eed93cfe4a0555c987fcba6f",
+                "sha256:9e5bb5e40394d6a15c494469be5026c063676918cbabf48345c7fdf8b2f776f5",
+                "sha256:a09688758168a86585bb0baeae0a704349285ef40a02da8739be4ad8f4b1aee7",
+                "sha256:cd796a039cbaddb6106127f210d5f2160654c0e629c1b663f2d9e6f67bba96b8",
+                "sha256:d0d6b11da16d280f83c5406ae0db03521e613c7758212b9104bad3dbf9bf2098",
+                "sha256:d96804a7e26e2ff37a9c2d796042754b7cae0668ed118a9185169fe1fc3b18d6",
+                "sha256:dbe7d9930789ea56e8b38b3b6b2b0b4e1090509825ceb572b906a1d23dea0282",
+                "sha256:e6bb3466f92b7a741a58fe348285d7bec69ea6102bbe3b2a3f49af0e6f2f3327",
+                "sha256:ecb8ab93305f07f806399101858ab9ff350c3e1de819d6043b5d54220cf81e71",
+                "sha256:f54ad2d6d3e4c564bf1f9c33e4165b4c36aea62c49654f356a5570f99b89c647"
             ],
-            "markers": "python_version >= '3.8'",
-            "version": "==9.2.1"
+            "markers": "python_version >= '3.9'",
+            "version": "==9.3.0"
         },
         "pillow": {
             "hashes": [
                 "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'",
             "version": "==2.9.0.post0"
         },
         "python-dotenv": {
                 "hiredis"
             ],
             "hashes": [
-                "sha256:b756df1e4a3858fcc0ef861f3fc53623a96c41e2b1f5304e09e0fe758d333d40",
-                "sha256:fd4fccba0d7f6aa48c58a78d76ddb4afc698f5da4a2c1d03d916e4fd7ab88cdd"
+                "sha256:f6c997521fedbae53387307c5d0bf784d9acc28d9f1d058abeac566ec4dbed72",
+                "sha256:f8ea06b7482a668c6475ae202ed8d9bcaa409f6e87fb77ed1043d912afd62e24"
             ],
             "markers": "python_version >= '3.8'",
-            "version": "==5.1.0"
+            "version": "==5.1.1"
         },
         "regex": {
             "hashes": [
-                "sha256:01c2acb51f8a7d6494c8c5eafe3d8e06d76563d8a8a4643b37e9b2dd8a2ff623",
-                "sha256:02087ea0a03b4af1ed6ebab2c54d7118127fee8d71b26398e8e4b05b78963199",
-                "sha256:040562757795eeea356394a7fb13076ad4f99d3c62ab0f8bdfb21f99a1f85664",
-                "sha256:042c55879cfeb21a8adacc84ea347721d3d83a159da6acdf1116859e2427c43f",
-                "sha256:079400a8269544b955ffa9e31f186f01d96829110a3bf79dc338e9910f794fca",
-                "sha256:07f45f287469039ffc2c53caf6803cd506eb5f5f637f1d4acb37a738f71dd066",
-                "sha256:09d77559e80dcc9d24570da3745ab859a9cf91953062e4ab126ba9d5993688ca",
-                "sha256:0cbff728659ce4bbf4c30b2a1be040faafaa9eca6ecde40aaff86f7889f4ab39",
-                "sha256:0e12c481ad92d129c78f13a2a3662317e46ee7ef96c94fd332e1c29131875b7d",
-                "sha256:0ea51dcc0835eea2ea31d66456210a4e01a076d820e9039b04ae8d17ac11dee6",
-                "sha256:0ffbcf9221e04502fc35e54d1ce9567541979c3fdfb93d2c554f0ca583a19b35",
-                "sha256:1494fa8725c285a81d01dc8c06b55287a1ee5e0e382d8413adc0a9197aac6408",
-                "sha256:16e13a7929791ac1216afde26f712802e3df7bf0360b32e4914dca3ab8baeea5",
-                "sha256:18406efb2f5a0e57e3a5881cd9354c1512d3bb4f5c45d96d110a66114d84d23a",
-                "sha256:18e707ce6c92d7282dfce370cd205098384b8ee21544e7cb29b8aab955b66fa9",
-                "sha256:220e92a30b426daf23bb67a7962900ed4613589bab80382be09b48896d211e92",
-                "sha256:23b30c62d0f16827f2ae9f2bb87619bc4fba2044911e2e6c2eb1af0161cdb766",
-                "sha256:23f9985c8784e544d53fc2930fc1ac1a7319f5d5332d228437acc9f418f2f168",
-                "sha256:297f54910247508e6e5cae669f2bc308985c60540a4edd1c77203ef19bfa63ca",
-                "sha256:2b08fce89fbd45664d3df6ad93e554b6c16933ffa9d55cb7e01182baaf971508",
-                "sha256:2cce2449e5927a0bf084d346da6cd5eb016b2beca10d0013ab50e3c226ffc0df",
-                "sha256:313ea15e5ff2a8cbbad96ccef6be638393041b0a7863183c2d31e0c6116688cf",
-                "sha256:323c1f04be6b2968944d730e5c2091c8c89767903ecaa135203eec4565ed2b2b",
-                "sha256:35f4a6f96aa6cb3f2f7247027b07b15a374f0d5b912c0001418d1d55024d5cb4",
-                "sha256:3b37fa423beefa44919e009745ccbf353d8c981516e807995b2bd11c2c77d268",
-                "sha256:3ce4f1185db3fbde8ed8aa223fc9620f276c58de8b0d4f8cc86fd1360829edb6",
-                "sha256:46989629904bad940bbec2106528140a218b4a36bb3042d8406980be1941429c",
-                "sha256:4838e24ee015101d9f901988001038f7f0d90dc0c3b115541a1365fb439add62",
-                "sha256:49b0e06786ea663f933f3710a51e9385ce0cba0ea56b67107fd841a55d56a231",
-                "sha256:4db21ece84dfeefc5d8a3863f101995de646c6cb0536952c321a2650aa202c36",
-                "sha256:54c4a097b8bc5bb0dfc83ae498061d53ad7b5762e00f4adaa23bee22b012e6ba",
-                "sha256:54d9ff35d4515debf14bc27f1e3b38bfc453eff3220f5bce159642fa762fe5d4",
-                "sha256:55b96e7ce3a69a8449a66984c268062fbaa0d8ae437b285428e12797baefce7e",
-                "sha256:57fdd2e0b2694ce6fc2e5ccf189789c3e2962916fb38779d3e3521ff8fe7a822",
-                "sha256:587d4af3979376652010e400accc30404e6c16b7df574048ab1f581af82065e4",
-                "sha256:5b513b6997a0b2f10e4fd3a1313568e373926e8c252bd76c960f96fd039cd28d",
                 "sha256:5ddcd9a179c0a6fa8add279a4444015acddcd7f232a49071ae57fa6e278f1f71",
-                "sha256:6113c008a7780792efc80f9dfe10ba0cd043cbf8dc9a76ef757850f51b4edc50",
-                "sha256:635a1d96665f84b292e401c3d62775851aedc31d4f8784117b3c68c4fcd4118d",
-                "sha256:64ce2799bd75039b480cc0360907c4fb2f50022f030bf9e7a8705b636e408fad",
-                "sha256:69dee6a020693d12a3cf892aba4808fe168d2a4cef368eb9bf74f5398bfd4ee8",
-                "sha256:6a2644a93da36c784e546de579ec1806bfd2763ef47babc1b03d765fe560c9f8",
-                "sha256:6b41e1adc61fa347662b09398e31ad446afadff932a24807d3ceb955ed865cc8",
                 "sha256:6c188c307e8433bcb63dc1915022deb553b4203a70722fc542c363bf120a01fd",
-                "sha256:6edd623bae6a737f10ce853ea076f56f507fd7726bee96a41ee3d68d347e4d16",
-                "sha256:73d6d2f64f4d894c96626a75578b0bf7d9e56dcda8c3d037a2118fdfe9b1c664",
-                "sha256:7a22ccefd4db3f12b526eccb129390942fe874a3a9fdbdd24cf55773a1faab1a",
-                "sha256:7fb89ee5d106e4a7a51bce305ac4efb981536301895f7bdcf93ec92ae0d91c7f",
-                "sha256:846bc79ee753acf93aef4184c040d709940c9d001029ceb7b7a52747b80ed2dd",
-                "sha256:85ab7824093d8f10d44330fe1e6493f756f252d145323dd17ab6b48733ff6c0a",
-                "sha256:8dee5b4810a89447151999428fe096977346cf2f29f4d5e29609d2e19e0199c9",
-                "sha256:8e5fb5f77c8745a60105403a774fe2c1759b71d3e7b4ca237a5e67ad066c7199",
-                "sha256:98eeee2f2e63edae2181c886d7911ce502e1292794f4c5ee71e60e23e8d26b5d",
-                "sha256:9d4a76b96f398697fe01117093613166e6aa8195d63f1b4ec3f21ab637632963",
-                "sha256:9e8719792ca63c6b8340380352c24dcb8cd7ec49dae36e963742a275dfae6009",
-                "sha256:a0b2b80321c2ed3fcf0385ec9e51a12253c50f146fddb2abbb10f033fe3d049a",
-                "sha256:a4cc92bb6db56ab0c1cbd17294e14f5e9224f0cc6521167ef388332604e92679",
-                "sha256:a738b937d512b30bf75995c0159c0ddf9eec0775c9d72ac0202076c72f24aa96",
-                "sha256:a8f877c89719d759e52783f7fe6e1c67121076b87b40542966c02de5503ace42",
-                "sha256:a906ed5e47a0ce5f04b2c981af1c9acf9e8696066900bf03b9d7879a6f679fc8",
-                "sha256:ae2941333154baff9838e88aa71c1d84f4438189ecc6021a12c7573728b5838e",
-                "sha256:b0d0a6c64fcc4ef9c69bd5b3b3626cc3776520a1637d8abaa62b9edc147a58f7",
-                "sha256:b5b029322e6e7b94fff16cd120ab35a253236a5f99a79fb04fda7ae71ca20ae8",
-                "sha256:b7aaa315101c6567a9a45d2839322c51c8d6e81f67683d529512f5bcfb99c802",
-                "sha256:be1c8ed48c4c4065ecb19d882a0ce1afe0745dfad8ce48c49586b90a55f02366",
-                "sha256:c0256beda696edcf7d97ef16b2a33a8e5a875affd6fa6567b54f7c577b30a137",
-                "sha256:c157bb447303070f256e084668b702073db99bbb61d44f85d811025fcf38f784",
-                "sha256:c57d08ad67aba97af57a7263c2d9006d5c404d721c5f7542f077f109ec2a4a29",
-                "sha256:c69ada171c2d0e97a4b5aa78fbb835e0ffbb6b13fc5da968c09811346564f0d3",
-                "sha256:c94bb0a9f1db10a1d16c00880bdebd5f9faf267273b8f5bd1878126e0fbde771",
-                "sha256:cb130fccd1a37ed894824b8c046321540263013da72745d755f2d35114b81a60",
-                "sha256:ced479f601cd2f8ca1fd7b23925a7e0ad512a56d6e9476f79b8f381d9d37090a",
-                "sha256:d05ac6fa06959c4172eccd99a222e1fbf17b5670c4d596cb1e5cde99600674c4",
-                "sha256:d552c78411f60b1fdaafd117a1fca2f02e562e309223b9d44b7de8be451ec5e0",
-                "sha256:dd4490a33eb909ef5078ab20f5f000087afa2a4daa27b4c072ccb3cb3050ad84",
-                "sha256:df5cbb1fbc74a8305b6065d4ade43b993be03dbe0f8b30032cced0d7740994bd",
-                "sha256:e28f9faeb14b6f23ac55bfbbfd3643f5c7c18ede093977f1df249f73fd22c7b1",
-                "sha256:e464b467f1588e2c42d26814231edecbcfe77f5ac414d92cbf4e7b55b2c2a776",
-                "sha256:e4c22e1ac1f1ec1e09f72e6c44d8f2244173db7eb9629cc3a346a8d7ccc31142",
-                "sha256:e53b5fbab5d675aec9f0c501274c467c0f9a5d23696cfc94247e1fb56501ed89",
-                "sha256:e93f1c331ca8e86fe877a48ad64e77882c0c4da0097f2212873a69bbfea95d0c",
-                "sha256:e997fd30430c57138adc06bba4c7c2968fb13d101e57dd5bb9355bf8ce3fa7e8",
-                "sha256:e9a091b0550b3b0207784a7d6d0f1a00d1d1c8a11699c1a4d93db3fbefc3ad35",
-                "sha256:eab4bb380f15e189d1313195b062a6aa908f5bd687a0ceccd47c8211e9cf0d4a",
-                "sha256:eb1ae19e64c14c7ec1995f40bd932448713d3c73509e82d8cd7744dc00e29e86",
-                "sha256:ecea58b43a67b1b79805f1a0255730edaf5191ecef84dbc4cc85eb30bc8b63b9",
-                "sha256:ee439691d8c23e76f9802c42a95cfeebf9d47cf4ffd06f18489122dbb0a7ad64",
-                "sha256:eee9130eaad130649fd73e5cd92f60e55708952260ede70da64de420cdcad554",
-                "sha256:f47cd43a5bfa48f86925fe26fbdd0a488ff15b62468abb5d2a1e092a4fb10e85",
-                "sha256:f6fff13ef6b5f29221d6904aa816c34701462956aa72a77f1f151a8ec4f56aeb",
-                "sha256:f745ec09bc1b0bd15cfc73df6fa4f726dcc26bb16c23a03f9e3367d357eeedd0",
-                "sha256:f8404bf61298bb6f8224bb9176c1424548ee1181130818fcd2cbffddc768bed8",
-                "sha256:f9268774428ec173654985ce55fc6caf4c6d11ade0f6f914d48ef4719eb05ebb",
-                "sha256:faa3c142464efec496967359ca99696c896c591c56c53506bac1ad465f66e919"
+                "sha256:ced479f601cd2f8ca1fd7b23925a7e0ad512a56d6e9476f79b8f381d9d37090a"
             ],
             "markers": "python_version >= '3.8'",
             "version": "==2024.9.11"
                 "sha256:55365417734eb18255590a9ff9eb97e9e1da868d4ccd6402399eaf68af20a760",
                 "sha256:70761cfe03c773ceb22aa2f671b4757976145175cdfca038c02654d061d6dcc6"
             ],
-            "markers": "python_version >= '3.8'",
             "version": "==2.32.3"
         },
         "requests-oauthlib": {
         },
         "rich": {
             "hashes": [
-                "sha256:1760a3c0848469b97b558fc61c85233e3dafb69c7a071b4d60c38099d3cd4c06",
-                "sha256:8260cda28e3db6bf04d2d1ef4dbc03ba80a824c88b0e7668a0f23126a424844a"
+                "sha256:51a2c62057461aaf7152b4d611168f93a9fc73068f8ded2790f29fe2b5366d0c",
+                "sha256:8c82a3d3f8dcfe9e734771313e606b39d8247bb6b826e196f4914b333b743cf1"
             ],
-            "markers": "python_full_version >= '3.7.0'",
-            "version": "==13.8.1"
+            "markers": "python_full_version >= '3.8.0'",
+            "version": "==13.9.2"
         },
         "scikit-learn": {
             "hashes": [
                 "sha256:03b6158efa3faaf1feea3faa884c840ebd61b6484167c711548fce208ea09445",
+                "sha256:178ddd0a5cb0044464fc1bfc4cca5b1833bfc7bb022d70b05db8530da4bb3dd3",
                 "sha256:1ff45e26928d3b4eb767a8f14a9a6efbf1cbff7c05d1fb0f95f211a89fd4f5de",
                 "sha256:299406827fb9a4f862626d0fe6c122f5f87f8910b86fe5daa4c32dcd742139b6",
                 "sha256:2d4cad1119c77930b235579ad0dc25e65c917e756fe80cab96aa3b9428bd3fb0",
                 "sha256:6c16d84a0d45e4894832b3c4d0bf73050939e21b99b01b6fd59cbb0cf39163b6",
                 "sha256:757c7d514ddb00ae249832fe87100d9c73c6ea91423802872d9e74970a0e40b9",
                 "sha256:8c412ccc2ad9bf3755915e3908e677b367ebc8d010acbb3f182814524f2e5540",
+                "sha256:b0768ad641981f5d3a198430a1d31c3e044ed2e8a6f22166b4d546a5116d7908",
                 "sha256:b4237ed7b3fdd0a4882792e68ef2545d5baa50aca3bb45aa7df468138ad8f94d",
+                "sha256:b7b0f9a0b1040830d38c39b91b3a44e1b643f4b36e36567b80b7c6bd2202a27f",
                 "sha256:c15b1ca23d7c5f33cc2cb0a0d6aaacf893792271cddff0edbd6a40e8319bc113",
                 "sha256:ca64b3089a6d9b9363cd3546f8978229dcbb737aceb2c12144ee3f70f95684b7",
+                "sha256:e9a702e2de732bbb20d3bad29ebd77fc05a6b427dc49964300340e4c9328b3f5",
                 "sha256:f60021ec1574e56632be2a36b946f8143bf4e5e6af4a06d85281adc22938e0dd",
+                "sha256:f7284ade780084d94505632241bf78c44ab3b6f1e8ccab3d2af58e0e950f9c12",
                 "sha256:f763897fe92d0e903aa4847b0aec0e68cadfff77e8a0687cabd946c89d17e675",
                 "sha256:f8b0ccd4a902836493e026c03256e8b206656f91fbcc4fde28c57a5b752561f1",
                 "sha256:f932a02c3f4956dfb981391ab24bda1dbd90fe3d628e4b42caef3e041c67707a"
                 "sha256:1e61c37477a1626458e36f7b1d82aa5c9b094fa4802892072e49de9c60c4c926",
                 "sha256:8abb2f1d86890a2dfb989f9a77cfcfd3e47c2a354b01111771326f8aa26e0254"
             ],
-            "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'",
             "version": "==1.16.0"
         },
         "sniffio": {
         },
         "faker": {
             "hashes": [
-                "sha256:bf0207af5777950054a2a3b43f4b5bdc33b585918d2b28f1dab52ac0ffe2bac0",
-                "sha256:f0a60009150736c1c033bea31aa19ae63071c9dcf10adfaf9f1a87a3add84bc8"
+                "sha256:dbf81295c948270a9e96cd48a9a3ebec73acac9a153d0c854fbbd0294557609f",
+                "sha256:e0593931bd7be9a9ea984b5d8c302ef1cec19392585d1e90d444199271d0a94d"
             ],
             "markers": "python_version >= '3.8'",
-            "version": "==30.0.0"
+            "version": "==30.1.0"
         },
         "filelock": {
             "hashes": [
         },
         "httpcore": {
             "hashes": [
-                "sha256:34a38e2f9291467ee3b44e89dd52615370e152954ba21721378a87b2960f7a61",
-                "sha256:421f18bac248b25d310f3cacd198d55b8e6125c107797b609ff9b7a6ba7991b5"
+                "sha256:27b59625743b85577a8c0e10e55b50b5368a4f2cfe8cc7bcfa9cf00829c2682f",
+                "sha256:73f6dbd6eb8c21bbf7ef8efad555481853f5f6acdeaff1edb0694289269ee17f"
             ],
             "markers": "python_version >= '3.8'",
-            "version": "==1.0.5"
+            "version": "==1.0.6"
         },
         "httpx": {
             "extras": [
                 "sha256:4a3aee7acbbe7303aede8e9648d13b8bf88a429282aa6122a993f0ac800cb369",
                 "sha256:bc5dd2abb727a5319567b7a813e6a2e7318c39f4f487cfe6c89c6f9c7d25197d"
             ],
+            "index": "pypi",
             "markers": "python_version >= '3.7'",
             "version": "==3.1.4"
         },
         },
         "pymdown-extensions": {
             "hashes": [
-                "sha256:2653fb658bca5f278029f8c67a67f0f08b7bd3c657e2630d261ad542e97c4192",
-                "sha256:e68080eac44634406b31f4aec58fbad17b0ec5fca6b086e29008616d54c3906b"
+                "sha256:41cdde0a77290e480cf53892f5c5e50921a7ee3e5cd60ba91bf19837b33badcf",
+                "sha256:bc8847ecc9e784a098efd35e20cba772bc5a1b529dfcef9dc1972db9021a1049"
             ],
             "markers": "python_version >= '3.8'",
-            "version": "==10.11"
+            "version": "==10.11.2"
         },
         "pyopenssl": {
             "hashes": [
                 "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'",
             "version": "==2.9.0.post0"
         },
         "pywavelets": {
         },
         "regex": {
             "hashes": [
-                "sha256:01c2acb51f8a7d6494c8c5eafe3d8e06d76563d8a8a4643b37e9b2dd8a2ff623",
-                "sha256:02087ea0a03b4af1ed6ebab2c54d7118127fee8d71b26398e8e4b05b78963199",
-                "sha256:040562757795eeea356394a7fb13076ad4f99d3c62ab0f8bdfb21f99a1f85664",
-                "sha256:042c55879cfeb21a8adacc84ea347721d3d83a159da6acdf1116859e2427c43f",
-                "sha256:079400a8269544b955ffa9e31f186f01d96829110a3bf79dc338e9910f794fca",
-                "sha256:07f45f287469039ffc2c53caf6803cd506eb5f5f637f1d4acb37a738f71dd066",
-                "sha256:09d77559e80dcc9d24570da3745ab859a9cf91953062e4ab126ba9d5993688ca",
-                "sha256:0cbff728659ce4bbf4c30b2a1be040faafaa9eca6ecde40aaff86f7889f4ab39",
-                "sha256:0e12c481ad92d129c78f13a2a3662317e46ee7ef96c94fd332e1c29131875b7d",
-                "sha256:0ea51dcc0835eea2ea31d66456210a4e01a076d820e9039b04ae8d17ac11dee6",
-                "sha256:0ffbcf9221e04502fc35e54d1ce9567541979c3fdfb93d2c554f0ca583a19b35",
-                "sha256:1494fa8725c285a81d01dc8c06b55287a1ee5e0e382d8413adc0a9197aac6408",
-                "sha256:16e13a7929791ac1216afde26f712802e3df7bf0360b32e4914dca3ab8baeea5",
-                "sha256:18406efb2f5a0e57e3a5881cd9354c1512d3bb4f5c45d96d110a66114d84d23a",
-                "sha256:18e707ce6c92d7282dfce370cd205098384b8ee21544e7cb29b8aab955b66fa9",
-                "sha256:220e92a30b426daf23bb67a7962900ed4613589bab80382be09b48896d211e92",
-                "sha256:23b30c62d0f16827f2ae9f2bb87619bc4fba2044911e2e6c2eb1af0161cdb766",
-                "sha256:23f9985c8784e544d53fc2930fc1ac1a7319f5d5332d228437acc9f418f2f168",
-                "sha256:297f54910247508e6e5cae669f2bc308985c60540a4edd1c77203ef19bfa63ca",
-                "sha256:2b08fce89fbd45664d3df6ad93e554b6c16933ffa9d55cb7e01182baaf971508",
-                "sha256:2cce2449e5927a0bf084d346da6cd5eb016b2beca10d0013ab50e3c226ffc0df",
-                "sha256:313ea15e5ff2a8cbbad96ccef6be638393041b0a7863183c2d31e0c6116688cf",
-                "sha256:323c1f04be6b2968944d730e5c2091c8c89767903ecaa135203eec4565ed2b2b",
-                "sha256:35f4a6f96aa6cb3f2f7247027b07b15a374f0d5b912c0001418d1d55024d5cb4",
-                "sha256:3b37fa423beefa44919e009745ccbf353d8c981516e807995b2bd11c2c77d268",
-                "sha256:3ce4f1185db3fbde8ed8aa223fc9620f276c58de8b0d4f8cc86fd1360829edb6",
-                "sha256:46989629904bad940bbec2106528140a218b4a36bb3042d8406980be1941429c",
-                "sha256:4838e24ee015101d9f901988001038f7f0d90dc0c3b115541a1365fb439add62",
-                "sha256:49b0e06786ea663f933f3710a51e9385ce0cba0ea56b67107fd841a55d56a231",
-                "sha256:4db21ece84dfeefc5d8a3863f101995de646c6cb0536952c321a2650aa202c36",
-                "sha256:54c4a097b8bc5bb0dfc83ae498061d53ad7b5762e00f4adaa23bee22b012e6ba",
-                "sha256:54d9ff35d4515debf14bc27f1e3b38bfc453eff3220f5bce159642fa762fe5d4",
-                "sha256:55b96e7ce3a69a8449a66984c268062fbaa0d8ae437b285428e12797baefce7e",
-                "sha256:57fdd2e0b2694ce6fc2e5ccf189789c3e2962916fb38779d3e3521ff8fe7a822",
-                "sha256:587d4af3979376652010e400accc30404e6c16b7df574048ab1f581af82065e4",
-                "sha256:5b513b6997a0b2f10e4fd3a1313568e373926e8c252bd76c960f96fd039cd28d",
                 "sha256:5ddcd9a179c0a6fa8add279a4444015acddcd7f232a49071ae57fa6e278f1f71",
-                "sha256:6113c008a7780792efc80f9dfe10ba0cd043cbf8dc9a76ef757850f51b4edc50",
-                "sha256:635a1d96665f84b292e401c3d62775851aedc31d4f8784117b3c68c4fcd4118d",
-                "sha256:64ce2799bd75039b480cc0360907c4fb2f50022f030bf9e7a8705b636e408fad",
-                "sha256:69dee6a020693d12a3cf892aba4808fe168d2a4cef368eb9bf74f5398bfd4ee8",
-                "sha256:6a2644a93da36c784e546de579ec1806bfd2763ef47babc1b03d765fe560c9f8",
-                "sha256:6b41e1adc61fa347662b09398e31ad446afadff932a24807d3ceb955ed865cc8",
                 "sha256:6c188c307e8433bcb63dc1915022deb553b4203a70722fc542c363bf120a01fd",
-                "sha256:6edd623bae6a737f10ce853ea076f56f507fd7726bee96a41ee3d68d347e4d16",
-                "sha256:73d6d2f64f4d894c96626a75578b0bf7d9e56dcda8c3d037a2118fdfe9b1c664",
-                "sha256:7a22ccefd4db3f12b526eccb129390942fe874a3a9fdbdd24cf55773a1faab1a",
-                "sha256:7fb89ee5d106e4a7a51bce305ac4efb981536301895f7bdcf93ec92ae0d91c7f",
-                "sha256:846bc79ee753acf93aef4184c040d709940c9d001029ceb7b7a52747b80ed2dd",
-                "sha256:85ab7824093d8f10d44330fe1e6493f756f252d145323dd17ab6b48733ff6c0a",
-                "sha256:8dee5b4810a89447151999428fe096977346cf2f29f4d5e29609d2e19e0199c9",
-                "sha256:8e5fb5f77c8745a60105403a774fe2c1759b71d3e7b4ca237a5e67ad066c7199",
-                "sha256:98eeee2f2e63edae2181c886d7911ce502e1292794f4c5ee71e60e23e8d26b5d",
-                "sha256:9d4a76b96f398697fe01117093613166e6aa8195d63f1b4ec3f21ab637632963",
-                "sha256:9e8719792ca63c6b8340380352c24dcb8cd7ec49dae36e963742a275dfae6009",
-                "sha256:a0b2b80321c2ed3fcf0385ec9e51a12253c50f146fddb2abbb10f033fe3d049a",
-                "sha256:a4cc92bb6db56ab0c1cbd17294e14f5e9224f0cc6521167ef388332604e92679",
-                "sha256:a738b937d512b30bf75995c0159c0ddf9eec0775c9d72ac0202076c72f24aa96",
-                "sha256:a8f877c89719d759e52783f7fe6e1c67121076b87b40542966c02de5503ace42",
-                "sha256:a906ed5e47a0ce5f04b2c981af1c9acf9e8696066900bf03b9d7879a6f679fc8",
-                "sha256:ae2941333154baff9838e88aa71c1d84f4438189ecc6021a12c7573728b5838e",
-                "sha256:b0d0a6c64fcc4ef9c69bd5b3b3626cc3776520a1637d8abaa62b9edc147a58f7",
-                "sha256:b5b029322e6e7b94fff16cd120ab35a253236a5f99a79fb04fda7ae71ca20ae8",
-                "sha256:b7aaa315101c6567a9a45d2839322c51c8d6e81f67683d529512f5bcfb99c802",
-                "sha256:be1c8ed48c4c4065ecb19d882a0ce1afe0745dfad8ce48c49586b90a55f02366",
-                "sha256:c0256beda696edcf7d97ef16b2a33a8e5a875affd6fa6567b54f7c577b30a137",
-                "sha256:c157bb447303070f256e084668b702073db99bbb61d44f85d811025fcf38f784",
-                "sha256:c57d08ad67aba97af57a7263c2d9006d5c404d721c5f7542f077f109ec2a4a29",
-                "sha256:c69ada171c2d0e97a4b5aa78fbb835e0ffbb6b13fc5da968c09811346564f0d3",
-                "sha256:c94bb0a9f1db10a1d16c00880bdebd5f9faf267273b8f5bd1878126e0fbde771",
-                "sha256:cb130fccd1a37ed894824b8c046321540263013da72745d755f2d35114b81a60",
-                "sha256:ced479f601cd2f8ca1fd7b23925a7e0ad512a56d6e9476f79b8f381d9d37090a",
-                "sha256:d05ac6fa06959c4172eccd99a222e1fbf17b5670c4d596cb1e5cde99600674c4",
-                "sha256:d552c78411f60b1fdaafd117a1fca2f02e562e309223b9d44b7de8be451ec5e0",
-                "sha256:dd4490a33eb909ef5078ab20f5f000087afa2a4daa27b4c072ccb3cb3050ad84",
-                "sha256:df5cbb1fbc74a8305b6065d4ade43b993be03dbe0f8b30032cced0d7740994bd",
-                "sha256:e28f9faeb14b6f23ac55bfbbfd3643f5c7c18ede093977f1df249f73fd22c7b1",
-                "sha256:e464b467f1588e2c42d26814231edecbcfe77f5ac414d92cbf4e7b55b2c2a776",
-                "sha256:e4c22e1ac1f1ec1e09f72e6c44d8f2244173db7eb9629cc3a346a8d7ccc31142",
-                "sha256:e53b5fbab5d675aec9f0c501274c467c0f9a5d23696cfc94247e1fb56501ed89",
-                "sha256:e93f1c331ca8e86fe877a48ad64e77882c0c4da0097f2212873a69bbfea95d0c",
-                "sha256:e997fd30430c57138adc06bba4c7c2968fb13d101e57dd5bb9355bf8ce3fa7e8",
-                "sha256:e9a091b0550b3b0207784a7d6d0f1a00d1d1c8a11699c1a4d93db3fbefc3ad35",
-                "sha256:eab4bb380f15e189d1313195b062a6aa908f5bd687a0ceccd47c8211e9cf0d4a",
-                "sha256:eb1ae19e64c14c7ec1995f40bd932448713d3c73509e82d8cd7744dc00e29e86",
-                "sha256:ecea58b43a67b1b79805f1a0255730edaf5191ecef84dbc4cc85eb30bc8b63b9",
-                "sha256:ee439691d8c23e76f9802c42a95cfeebf9d47cf4ffd06f18489122dbb0a7ad64",
-                "sha256:eee9130eaad130649fd73e5cd92f60e55708952260ede70da64de420cdcad554",
-                "sha256:f47cd43a5bfa48f86925fe26fbdd0a488ff15b62468abb5d2a1e092a4fb10e85",
-                "sha256:f6fff13ef6b5f29221d6904aa816c34701462956aa72a77f1f151a8ec4f56aeb",
-                "sha256:f745ec09bc1b0bd15cfc73df6fa4f726dcc26bb16c23a03f9e3367d357eeedd0",
-                "sha256:f8404bf61298bb6f8224bb9176c1424548ee1181130818fcd2cbffddc768bed8",
-                "sha256:f9268774428ec173654985ce55fc6caf4c6d11ade0f6f914d48ef4719eb05ebb",
-                "sha256:faa3c142464efec496967359ca99696c896c591c56c53506bac1ad465f66e919"
+                "sha256:ced479f601cd2f8ca1fd7b23925a7e0ad512a56d6e9476f79b8f381d9d37090a"
             ],
             "markers": "python_version >= '3.8'",
             "version": "==2024.9.11"
                 "sha256:55365417734eb18255590a9ff9eb97e9e1da868d4ccd6402399eaf68af20a760",
                 "sha256:70761cfe03c773ceb22aa2f671b4757976145175cdfca038c02654d061d6dcc6"
             ],
-            "markers": "python_version >= '3.8'",
             "version": "==2.32.3"
         },
         "ruff": {
             "hashes": [
-                "sha256:007dee844738c3d2e6c24ab5bc7d43c99ba3e1943bd2d95d598582e9c1b27750",
-                "sha256:1085c455d1b3fdb8021ad534379c60353b81ba079712bce7a900e834859182fa",
-                "sha256:27b87e1801e786cd6ede4ada3faa5e254ce774de835e6723fd94551464c56b8c",
-                "sha256:5fd0d4b7b1457c49e435ee1e437900ced9b35cb8dc5178921dfb7d98d65a08d0",
-                "sha256:677e03c00f37c66cea033274295a983c7c546edea5043d0c798833adf4cf4c6f",
-                "sha256:6cfb227b932ba8ef6e56c9f875d987973cd5e35bc5d05f5abf045af78ad8e098",
-                "sha256:6ef0411eccfc3909269fed47c61ffebdcb84a04504bafa6b6df9b85c27e813b0",
-                "sha256:6f5a2f17c7d32991169195d52a04c95b256378bbf0de8cb98478351eb70d526f",
-                "sha256:70edf6a93b19481affd287d696d9e311388d808671bc209fb8907b46a8c3af44",
-                "sha256:77944bca110ff0a43b768f05a529fecd0706aac7bcce36d7f1eeb4cbfca5f0f2",
-                "sha256:792213f7be25316f9b46b854df80a77e0da87ec66691e8f012f887b4a671ab5a",
-                "sha256:8d3bb2e3fbb9875172119021a13eed38849e762499e3cfde9588e4b4d70968dc",
-                "sha256:9f1476236b3eacfacfc0f66aa9e6cd39f2a624cb73ea99189556015f27c0bdeb",
-                "sha256:a5bf44b1aa0adaf6d9d20f86162b34f7c593bfedabc51239953e446aefc8ce18",
-                "sha256:cd48f945da2a6334f1793d7f701725a76ba93bf3d73c36f6b21fb04d5338dcf5",
-                "sha256:ce60058d3cdd8490e5e5471ef086b3f1e90ab872b548814e35930e21d848c9ce",
-                "sha256:ec0517dc0f37cad14a5319ba7bba6e7e339d03fbf967a6d69b0907d61be7a263",
-                "sha256:f8034b19b993e9601f2ddf2c517451e17a6ab5cdb1c13fdff50c1442a7171d87"
+                "sha256:064df58d84ccc0ac0fcd63bc3090b251d90e2a372558c0f057c3f75ed73e1ccd",
+                "sha256:12edd2af0c60fa61ff31cefb90aef4288ac4d372b4962c2864aeea3a1a2460c0",
+                "sha256:140d4b5c9f5fc7a7b074908a78ab8d384dd7f6510402267bc76c37195c02a7ec",
+                "sha256:3c866b631f5fbce896a74a6e4383407ba7507b815ccc52bcedabb6810fdb3ef7",
+                "sha256:3ef0cc774b00fec123f635ce5c547dac263f6ee9fb9cc83437c5904183b55ceb",
+                "sha256:417b81aa1c9b60b2f8edc463c58363075412866ae4e2b9ab0f690dc1e87ac1b5",
+                "sha256:53fd8ca5e82bdee8da7f506d7b03a261f24cd43d090ea9db9a1dc59d9313914c",
+                "sha256:55bb01caeaf3a60b2b2bba07308a02fca6ab56233302406ed5245180a05c5625",
+                "sha256:645d7d8761f915e48a00d4ecc3686969761df69fb561dd914a773c1a8266e14e",
+                "sha256:785d31851c1ae91f45b3d8fe23b8ae4b5170089021fbb42402d811135f0b7117",
+                "sha256:7b118afbb3202f5911486ad52da86d1d52305b59e7ef2031cea3425142b97d6f",
+                "sha256:7d5ccc9e58112441de8ad4b29dcb7a86dc25c5f770e3c06a9d57e0e5eba48829",
+                "sha256:925d26471fa24b0ce5a6cdfab1bb526fb4159952385f386bdcc643813d472039",
+                "sha256:a67267654edc23c97335586774790cde402fb6bbdb3c2314f1fc087dee320bfa",
+                "sha256:a9641e31476d601f83cd602608739a0840e348bda93fec9f1ee816f8b6798b93",
+                "sha256:b076ef717a8e5bc819514ee1d602bbdca5b4420ae13a9cf61a0c0a4f53a2baa2",
+                "sha256:eae02b700763e3847595b9d2891488989cac00214da7f845f4bcf2989007d577",
+                "sha256:eb61ec9bdb2506cffd492e05ac40e5bc6284873aceb605503d8494180d6fc84d"
             ],
             "index": "pypi",
             "markers": "python_version >= '3.7'",
-            "version": "==0.6.8"
+            "version": "==0.6.9"
         },
         "scipy": {
             "hashes": [
                 "sha256:1e61c37477a1626458e36f7b1d82aa5c9b094fa4802892072e49de9c60c4c926",
                 "sha256:8abb2f1d86890a2dfb989f9a77cfcfd3e47c2a354b01111771326f8aa26e0254"
             ],
-            "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'",
             "version": "==1.16.0"
         },
         "sniffio": {
         },
         "tomli": {
             "hashes": [
-                "sha256:939de3e7a6161af0c887ef91b7d41a53e7c5a1ca976325f429cb46ea9bc30ecc",
-                "sha256:de526c12914f0c550d15924c62d72abc48d6fe7364aa87328337a31007fe8a4f"
+                "sha256:2ebe24485c53d303f690b0ec092806a085f07af5a5aa1464f3931eec36caaa38",
+                "sha256:d46d457a85337051c36524bc5349dd91b1877838e2979ac5ced3e710ed8a60ed"
             ],
             "markers": "python_version < '3.11'",
-            "version": "==2.0.1"
+            "version": "==2.0.2"
         },
         "twisted": {
             "extras": [
                 "sha256:55365417734eb18255590a9ff9eb97e9e1da868d4ccd6402399eaf68af20a760",
                 "sha256:70761cfe03c773ceb22aa2f671b4757976145175cdfca038c02654d061d6dcc6"
             ],
-            "markers": "python_version >= '3.8'",
             "version": "==2.32.3"
         },
         "sqlparse": {
         },
         "tomli": {
             "hashes": [
-                "sha256:939de3e7a6161af0c887ef91b7d41a53e7c5a1ca976325f429cb46ea9bc30ecc",
-                "sha256:de526c12914f0c550d15924c62d72abc48d6fe7364aa87328337a31007fe8a4f"
+                "sha256:2ebe24485c53d303f690b0ec092806a085f07af5a5aa1464f3931eec36caaa38",
+                "sha256:d46d457a85337051c36524bc5349dd91b1877838e2979ac5ced3e710ed8a60ed"
             ],
             "markers": "python_version < '3.11'",
-            "version": "==2.0.1"
+            "version": "==2.0.2"
         },
         "types-bleach": {
             "hashes": [
         },
         "types-docutils": {
             "hashes": [
-                "sha256:5dd2aa5e2e06fcfa090020bc4115479b4dd28da3329ab708563ee29894bd3c0d",
-                "sha256:9c8ed6d90583944af00f6b5fa3aecc2101e20672f6b1a4a299c6bf7d1e47084d"
+                "sha256:0d2ea594576e8d05c4ad83165da64a511e538f6ab405ab8347cd6b636c59f934",
+                "sha256:9816fb4f33067ed22d24c776a411a430bc19318b1af8f373e5581702a07bc4bc"
             ],
             "markers": "python_version >= '3.8'",
-            "version": "==0.21.0.20240907"
+            "version": "==0.21.0.20241004"
         },
         "types-html5lib": {
             "hashes": [
         },
         "types-python-dateutil": {
             "hashes": [
-                "sha256:27c8cc2d058ccb14946eebcaaa503088f4f6dbc4fb6093d3d456a49aef2753f6",
-                "sha256:9706c3b68284c25adffc47319ecc7947e5bb86b3773f843c73906fd598bc176e"
+                "sha256:250e1d8e80e7bbc3a6c99b907762711d1a1cdd00e978ad39cb5940f6f0a87f3d",
+                "sha256:58cb85449b2a56d6684e41aeefb4c4280631246a0da1a719bdbe6f3fb0317446"
             ],
             "index": "pypi",
             "markers": "python_version >= '3.8'",
-            "version": "==2.9.0.20240906"
+            "version": "==2.9.0.20241003"
         },
         "types-pyyaml": {
             "hashes": [
         },
         "types-redis": {
             "hashes": [
-                "sha256:0e7537e5c085fe96b7d468d5edae0cf667b4ba4b62c6e4a5dfc340bd3b868c23",
-                "sha256:4bab1a378dbf23c2c95c370dfdb89a8f033957c4fd1a53fee71b529c182fe008"
+                "sha256:5f17d2b3f9091ab75384153bfa276619ffa1cf6a38da60e10d5e6749cc5b902e",
+                "sha256:ef5da68cb827e5f606c8f9c0b49eeee4c2669d6d97122f301d3a55dc6a63f6ed"
             ],
             "index": "pypi",
             "markers": "python_version >= '3.8'",
-            "version": "==4.6.0.20240903"
+            "version": "==4.6.0.20241004"
         },
         "types-requests": {
             "hashes": [
index fe8d2e305334dbc8db49d221f088ef7bdaaf9730..398176267dded3bd754bebd30aa745e9d0207a7f 100644 (file)
@@ -265,7 +265,7 @@ This variable allows you to configure the filename (folders are allowed)
 using placeholders. For example, configuring this to
 
 ```bash
-PAPERLESS_FILENAME_FORMAT={created_year}/{correspondent}/{title}
+PAPERLESS_FILENAME_FORMAT={{ created_year }}/{{ correspondent }}/{{ title }}
 ```
 
 will create a directory structure as follows:
@@ -298,39 +298,39 @@ will create a directory structure as follows:
     when changing `PAPERLESS_FILENAME_FORMAT` you will need to manually run the
     [`document renamer`](administration.md#renamer) to move any existing documents.
 
-#### Placeholders
+### Placeholders {#filename-format-variables}
 
-Paperless provides the following placeholders within filenames:
+Paperless provides the following variables for use within filenames:
 
-- `{asn}`: The archive serial number of the document, or "none".
-- `{correspondent}`: The name of the correspondent, or "none".
-- `{document_type}`: The name of the document type, or "none".
-- `{tag_list}`: A comma separated list of all tags assigned to the
+- `{{ asn }}`: The archive serial number of the document, or "none".
+- `{{ correspondent }}`: The name of the correspondent, or "none".
+- `{{ document_type }}`: The name of the document type, or "none".
+- `{{ tag_list }}`: A comma separated list of all tags assigned to the
   document.
-- `{title}`: The title of the document.
-- `{created}`: The full date (ISO format) the document was created.
-- `{created_year}`: Year created only, formatted as the year with
+- `{{ title }}`: The title of the document.
+- `{{ created }}`: The full date (ISO format) the document was created.
+- `{{ created_year }}`: Year created only, formatted as the year with
   century.
-- `{created_year_short}`: Year created only, formatted as the year
+- `{{ created_year_short }}`: Year created only, formatted as the year
   without century, zero padded.
-- `{created_month}`: Month created only (number 01-12).
-- `{created_month_name}`: Month created name, as per locale
-- `{created_month_name_short}`: Month created abbreviated name, as per
+- `{{ created_month }}`: Month created only (number 01-12).
+- `{{ created_month_name }}`: Month created name, as per locale
+- `{{ created_month_name_short }}`: Month created abbreviated name, as per
   locale
-- `{created_day}`: Day created only (number 01-31).
-- `{added}`: The full date (ISO format) the document was added to
+- `{{ created_day }}`: Day created only (number 01-31).
+- `{{ added }}`: The full date (ISO format) the document was added to
   paperless.
-- `{added_year}`: Year added only.
-- `{added_year_short}`: Year added only, formatted as the year without
+- `{{ added_year }}`: Year added only.
+- `{{ added_year_short }}`: Year added only, formatted as the year without
   century, zero padded.
-- `{added_month}`: Month added only (number 01-12).
-- `{added_month_name}`: Month added name, as per locale
-- `{added_month_name_short}`: Month added abbreviated name, as per
+- `{{ added_month }}`: Month added only (number 01-12).
+- `{{ added_month_name }}`: Month added name, as per locale
+- `{{ added_month_name_short }}`: Month added abbreviated name, as per
   locale
-- `{added_day}`: Day added only (number 01-31).
-- `{owner_username}`: Username of document owner, if any, or "none"
-- `{original_name}`: Document original filename, minus the extension, if any, or "none"
-- `{doc_pk}`: The paperless identifier (primary key) for the document.
+- `{{ added_day }}`: Day added only (number 01-31).
+- `{{ owner_username }}`: Username of document owner, if any, or "none"
+- `{{ original_name }}`: Document original filename, minus the extension, if any, or "none"
+- `{{ doc_pk }}`: The paperless identifier (primary key) for the document.
 
 !!! warning
 
@@ -338,6 +338,11 @@ Paperless provides the following placeholders within filenames:
     you may run into the limits of your operating system's maximum path lengths.
     In that case, files will retain the previous path instead and the issue logged.
 
+!!! tip
+
+    These variables are all simple strings, but the format can be a full template.
+    See [Filename Templates](#filename-templates) for even more advanced formatting.
+
 Paperless will try to conserve the information from your database as
 much as possible. However, some characters that you can use in document
 titles and correspondent names (such as `: \ /` and a couple more) are
@@ -363,7 +368,7 @@ paperless will fall back to using the default naming scheme instead.
     However, keep in mind that inside docker, if files get stored outside of
     the predefined volumes, they will be lost after a restart.
 
-##### Empty placeholders
+#### Empty placeholders
 
 You can affect how empty placeholders are treated by changing the
 [`PAPERLESS_FILENAME_FORMAT_REMOVE_NONE`](configuration.md#PAPERLESS_FILENAME_FORMAT_REMOVE_NONE) setting.
@@ -390,8 +395,8 @@ For example, you could define the following two storage paths:
     the correspondence.
 
 ```
-By Year = {created_year}/{correspondent}/{title}
-Insurances = Insurances/{correspondent}/{created_year}-{created_month}-{created_day} {title}
+By Year = {{ created_year }}/{{ correspondent }}/{{ title }}
+Insurances = Insurances/{{ correspondent }}/{{ created_year }}-{{ created_month }}-{{ created_day }} {{ title }}
 ```
 
 If you then map these storage paths to the documents, you might get the
@@ -418,6 +423,92 @@ Insurances/                             # Insurances
     Defining a storage path is optional. If no storage path is defined for a
     document, the global [`PAPERLESS_FILENAME_FORMAT`](configuration.md#PAPERLESS_FILENAME_FORMAT) is applied.
 
+### Filename Templates {#filename-templates}
+
+The filename formatting uses [Jinja templates](https://jinja.palletsprojects.com/en/3.1.x/templates/) to build the filename.
+This allows for complex logic to be included in the format, including [logical structures](https://jinja.palletsprojects.com/en/3.1.x/templates/#list-of-control-structures)
+and [filters](https://jinja.palletsprojects.com/en/3.1.x/templates/#id11) to manipulate the [variables](#filename-format-variables)
+provided. The template is provided as a string, potentially multiline, and rendered into a single line.
+
+In addition, the entire Document instance is available to be utilized in a more advanced way, as well as some variables which only make sense to be accessed
+with more complex logic.
+
+#### Additional Variables
+
+- `{{ tag_name_list }}`: A list of tag names applied to the document, ordered by the tag name. Note this is a list, not a single string
+- `{{ custom_fields }}`: A mapping of custom field names to their type and value. A user can access the mapping by field name or check if a field is applied by checking its existence in the variable.
+
+!!! tip
+
+    To access a custom field which has a space in the name, use the `get_cf_value` filter.  See the examples below.
+    This helps get fields by name and handle a default value if the named field is not attached to a Document.
+
+#### Examples
+
+This example will construct a path based on the archive serial number range:
+
+```jinja
+somepath/
+{% if document.archive_serial_number >= 0 and document.archive_serial_number <= 200 %}
+  asn-000-200/{{title}}
+{% elif document.archive_serial_number >= 201 and document.archive_serial_number <= 400 %}
+  asn-201-400
+  {% if document.archive_serial_number >= 201 and document.archive_serial_number < 300 %}
+    /asn-2xx
+  {% elif document.archive_serial_number >= 300 and document.archive_serial_number < 400 %}
+    /asn-3xx
+  {% endif %}
+{% endif %}
+/{{ title }}
+```
+
+For a document with an ASN of 205, it would result in `somepath/asn-201-400/asn-2xx/Title.pdf`, but
+a document with an ASN of 355 would be placed in `somepath/asn-201-400/asn-3xx/Title.pdf`.
+
+```jinja
+{% if document.mime_type == "application/pdf" %}
+  pdfs
+{% elif document.mime_type == "image/png" %}
+  pngs
+{% else %}
+  others
+{% endif %}
+/{{ title }}
+```
+
+For a PDF document, it would result in `pdfs/Title.pdf`, but for a PNG document, the path would be `pngs/Title.pdf`.
+
+To use custom fields:
+
+```jinja
+{% if "Invoice" in custom_fields %}
+  invoices/{{ custom_fields.Invoice.value }}
+{% else %}
+  not-invoices/{{ title }}
+{% endif %}
+```
+
+If the document has a custom field named "Invoice" with a value of 123, it would be filed into the `invoices/123.pdf`, but a document without the custom field
+would be filed to `not-invoices/Title.pdf`
+
+If the custom field is named "Invoice Number", you would access the value of it via the `get_cf_value` filter due to quirks of the Django Template Language:
+
+```jinja
+"invoices/{{ custom_fields|get_cf_value('Invoice Number') }}"
+```
+
+You can also use a custom `datetime` filter to format dates:
+
+```jinja
+invoices/
+{{ custom_fields|get_cf_value("Date Field","2024-01-01")|datetime('%Y') }}/
+{{ custom_fields|get_cf_value("Date Field","2024-01-01")|datetime('%m') }}/
+{{ custom_fields|get_cf_value("Date Field","2024-01-01")|datetime('%d') }}/
+Invoice_{{ custom_fields|get_cf_value("Select Field") }}_{{ custom_fields|get_cf_value("Date Field","2024-01-01")|replace("-", "") }}.pdf
+```
+
+This will create a path like `invoices/2022/01/01/Invoice_OptionTwo_20220101.pdf` if the custom field "Date Field" is set to January 1, 2022 and "Select Field" is set to `OptionTwo`.
+
 ## Automatic recovery of invalid PDFs {#pdf-recovery}
 
 Paperless will attempt to "clean" certain invalid PDFs with `qpdf` before processing if, for example, the mime_type
index 3570a77c1efca66c63e12dc4d24c568d53905225..3d8d89c554fb7778e3a096c6e90b76489f8a10ad 100644 (file)
         </context-group>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/manage/management-list/management-list.component.ts</context>
-          <context context-type="linenumber">208</context>
+          <context context-type="linenumber">210</context>
         </context-group>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/manage/workflows/workflows.component.html</context>
         </context-group>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/manage/management-list/management-list.component.ts</context>
-          <context context-type="linenumber">204</context>
+          <context context-type="linenumber">206</context>
         </context-group>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/manage/management-list/management-list.component.ts</context>
-          <context context-type="linenumber">321</context>
+          <context context-type="linenumber">323</context>
         </context-group>
       </trans-unit>
       <trans-unit id="1373208150912772963" datatype="html">
         </context-group>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/manage/management-list/management-list.component.ts</context>
-          <context context-type="linenumber">323</context>
+          <context context-type="linenumber">325</context>
         </context-group>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/manage/workflows/workflows.component.ts</context>
         </context-group>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/manage/management-list/management-list.component.ts</context>
-          <context context-type="linenumber">325</context>
+          <context context-type="linenumber">327</context>
         </context-group>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/manage/workflows/workflows.component.ts</context>
           <context context-type="sourcefile">src/app/components/common/input/text/text.component.html</context>
           <context context-type="linenumber">9</context>
         </context-group>
+        <context-group purpose="location">
+          <context context-type="sourcefile">src/app/components/common/input/textarea/textarea.component.html</context>
+          <context context-type="linenumber">9</context>
+        </context-group>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/common/input/url/url.component.html</context>
           <context context-type="linenumber">7</context>
         </context-group>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/manage/management-list/management-list.component.ts</context>
-          <context context-type="linenumber">308</context>
+          <context context-type="linenumber">310</context>
         </context-group>
       </trans-unit>
       <trans-unit id="4010735610815226758" datatype="html">
         <source>Automatic</source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/manage/management-list/management-list.component.ts</context>
-          <context context-type="linenumber">115</context>
+          <context context-type="linenumber">117</context>
         </context-group>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/data/matching-model.ts</context>
         <source>None</source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/manage/management-list/management-list.component.ts</context>
-          <context context-type="linenumber">117</context>
+          <context context-type="linenumber">119</context>
         </context-group>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/data/matching-model.ts</context>
         <source>Successfully created <x id="PH" equiv-text="this.typeName"/>.</source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/manage/management-list/management-list.component.ts</context>
-          <context context-type="linenumber">161</context>
+          <context context-type="linenumber">163</context>
         </context-group>
       </trans-unit>
       <trans-unit id="3928835053823658072" datatype="html">
         <source>Error occurred while creating <x id="PH" equiv-text="this.typeName"/>.</source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/manage/management-list/management-list.component.ts</context>
-          <context context-type="linenumber">166</context>
+          <context context-type="linenumber">168</context>
         </context-group>
       </trans-unit>
       <trans-unit id="2541368547549828690" datatype="html">
         <source>Successfully updated <x id="PH" equiv-text="this.typeName"/>.</source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/manage/management-list/management-list.component.ts</context>
-          <context context-type="linenumber">181</context>
+          <context context-type="linenumber">183</context>
         </context-group>
       </trans-unit>
       <trans-unit id="6442673774206210733" datatype="html">
         <source>Error occurred while saving <x id="PH" equiv-text="this.typeName"/>.</source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/manage/management-list/management-list.component.ts</context>
-          <context context-type="linenumber">186</context>
+          <context context-type="linenumber">188</context>
         </context-group>
       </trans-unit>
       <trans-unit id="8371896857609524947" datatype="html">
         <source>Associated documents will not be deleted.</source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/manage/management-list/management-list.component.ts</context>
-          <context context-type="linenumber">206</context>
+          <context context-type="linenumber">208</context>
         </context-group>
       </trans-unit>
       <trans-unit id="6639207128255974941" datatype="html">
         <source>Error while deleting element</source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/manage/management-list/management-list.component.ts</context>
-          <context context-type="linenumber">222</context>
+          <context context-type="linenumber">224</context>
         </context-group>
       </trans-unit>
       <trans-unit id="4863024195229581844" datatype="html">
         <source>Permissions updated successfully</source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/manage/management-list/management-list.component.ts</context>
-          <context context-type="linenumber">301</context>
+          <context context-type="linenumber">303</context>
         </context-group>
       </trans-unit>
       <trans-unit id="1464476612812630086" datatype="html">
         <source>This operation will permanently delete all objects.</source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/manage/management-list/management-list.component.ts</context>
-          <context context-type="linenumber">322</context>
+          <context context-type="linenumber">324</context>
         </context-group>
       </trans-unit>
       <trans-unit id="5897787932098828336" datatype="html">
         <source>Objects deleted successfully</source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/manage/management-list/management-list.component.ts</context>
-          <context context-type="linenumber">336</context>
+          <context context-type="linenumber">338</context>
         </context-group>
       </trans-unit>
       <trans-unit id="8273353839648035634" datatype="html">
         <source>Error deleting objects</source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/manage/management-list/management-list.component.ts</context>
-          <context context-type="linenumber">342</context>
+          <context context-type="linenumber">344</context>
         </context-group>
       </trans-unit>
       <trans-unit id="5101757640976222639" datatype="html">
         <source>Do you really want to delete the storage path &quot;<x id="PH" equiv-text="object.name"/>&quot;?</source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/manage/storage-path-list/storage-path-list.component.ts</context>
-          <context context-type="linenumber">52</context>
+          <context context-type="linenumber">54</context>
         </context-group>
       </trans-unit>
       <trans-unit id="6402703264596649214" datatype="html">
index 93c458ae0318dc878b3de2ea63c5aeec987eade5..5b946061706435b4dfe2714980e1bcb5c05ca0bb 100644 (file)
@@ -41,6 +41,7 @@ import { DocumentCardSmallComponent } from './components/document-list/document-
 import { BulkEditorComponent } from './components/document-list/bulk-editor/bulk-editor.component'
 import { NgxFileDropModule } from 'ngx-file-drop'
 import { TextComponent } from './components/common/input/text/text.component'
+import { TextAreaComponent } from './components/common/input/textarea/textarea.component'
 import { SelectComponent } from './components/common/input/select/select.component'
 import { CheckComponent } from './components/common/input/check/check.component'
 import { UrlComponent } from './components/common/input/url/url.component'
@@ -440,6 +441,7 @@ function initializeApp(settings: SettingsService) {
     DocumentCardSmallComponent,
     BulkEditorComponent,
     TextComponent,
+    TextAreaComponent,
     SelectComponent,
     CheckComponent,
     UrlComponent,
index ecec393c96e3e1d517b94d46f07d882320579159..f8232f9578d52286dfe1c0fb4b81523c60d652ff 100644 (file)
@@ -10,7 +10,7 @@
   <div class="modal-body">
 
     <pngx-input-text i18n-title title="Name" formControlName="name" [error]="error?.name" autocomplete="off"></pngx-input-text>
-    <pngx-input-text i18n-title title="Path" formControlName="path" [error]="error?.path" [hint]="pathHint"></pngx-input-text>
+    <pngx-input-textarea i18n-title title="Path" formControlName="path" [error]="error?.path" [hint]="pathHint" [monospace]="true"></pngx-input-textarea>
     <pngx-input-select i18n-title title="Matching algorithm" [items]="getMatchingAlgorithms()" formControlName="matching_algorithm"></pngx-input-select>
     @if (patternRequired) {
       <pngx-input-text i18n-title title="Matching pattern" formControlName="match" [error]="error?.match"></pngx-input-text>
index a042f2b4e1309b2ba2ad35f9100de20d91fbd8ab..051d21527b0059837553f76df57f6edb886832a4 100644 (file)
@@ -10,6 +10,7 @@ import { SettingsService } from 'src/app/services/settings.service'
 import { PermissionsFormComponent } from '../../input/permissions/permissions-form/permissions-form.component'
 import { SelectComponent } from '../../input/select/select.component'
 import { TextComponent } from '../../input/text/text.component'
+import { TextAreaComponent } from '../../input/textarea/textarea.component'
 import { EditDialogMode } from '../edit-dialog.component'
 import { StoragePathEditDialogComponent } from './storage-path-edit-dialog.component'
 import { provideHttpClient, withInterceptorsFromDi } from '@angular/common/http'
@@ -27,6 +28,7 @@ describe('StoragePathEditDialogComponent', () => {
         IfOwnerDirective,
         SelectComponent,
         TextComponent,
+        TextAreaComponent,
         PermissionsFormComponent,
         SafeHtmlPipe,
       ],
index 4a18605e7d6c3d5f1834a013667fea694ac170ae..0f9cc97117339cdf46ab548b6dee7b4e209a9139 100644 (file)
@@ -26,9 +26,9 @@ export class StoragePathEditDialogComponent extends EditDialogComponent<StorageP
   get pathHint() {
     return (
       $localize`e.g.` +
-      ' <code>{created_year}-{title}</code> ' +
+      ' <code class="text-nowrap">{{ created_year }}-{{ title }}</code> ' +
       $localize`or use slashes to add directories e.g.` +
-      ' <code>{created_year}/{correspondent}/{title}</code>. ' +
+      ' <code class="text-nowrap">{{ created_year }}/{{ title }}</code>. ' +
       $localize`See <a target="_blank" href="https://docs.paperless-ngx.com/advanced_usage/#file-name-handling">documentation</a> for full list.`
     )
   }
diff --git a/src-ui/src/app/components/common/input/textarea/textarea.component.html b/src-ui/src/app/components/common/input/textarea/textarea.component.html
new file mode 100644 (file)
index 0000000..b92bef4
--- /dev/null
@@ -0,0 +1,33 @@
+<div class="mb-3" [class.pb-3]="error">
+  <div class="row">
+    <div class="d-flex align-items-center position-relative hidden-button-container" [class.col-md-3]="horizontal">
+      @if (title) {
+        <label class="form-label" [class.mb-md-0]="horizontal" [for]="inputId">{{title}}</label>
+      }
+      @if (removable) {
+        <button type="button" class="btn btn-sm btn-danger position-absolute left-0" (click)="removed.emit(this)">
+          <i-bs name="x"></i-bs>&nbsp;<ng-container i18n>Remove</ng-container>
+          </button>
+        }
+      </div>
+      <div class="position-relative" [class.col-md-9]="horizontal">
+        <textarea #inputField
+          [id]="inputId"
+          class="form-control"
+          [class.is-invalid]="error"
+          [class.font-monospace]="monospace"
+          [(ngModel)]="value"
+          (change)="onChange(value)"
+          [disabled]="disabled"
+          [placeholder]="placeholder"
+          rows="6">
+        </textarea>
+        @if (hint) {
+          <small class="form-text text-muted" [innerHTML]="hint | safeHtml"></small>
+        }
+        <div class="invalid-feedback position-absolute top-100">
+          {{error}}
+        </div>
+      </div>
+    </div>
+  </div>
diff --git a/src-ui/src/app/components/common/input/textarea/textarea.component.scss b/src-ui/src/app/components/common/input/textarea/textarea.component.scss
new file mode 100644 (file)
index 0000000..e69de29
diff --git a/src-ui/src/app/components/common/input/textarea/textarea.component.spec.ts b/src-ui/src/app/components/common/input/textarea/textarea.component.spec.ts
new file mode 100644 (file)
index 0000000..15113c4
--- /dev/null
@@ -0,0 +1,31 @@
+import { ComponentFixture, TestBed } from '@angular/core/testing'
+import {
+  FormsModule,
+  ReactiveFormsModule,
+  NG_VALUE_ACCESSOR,
+} from '@angular/forms'
+import { TextAreaComponent } from './textarea.component'
+
+describe('TextComponent', () => {
+  let component: TextAreaComponent
+  let fixture: ComponentFixture<TextAreaComponent>
+  let input: HTMLTextAreaElement
+
+  beforeEach(async () => {
+    TestBed.configureTestingModule({
+      declarations: [TextAreaComponent],
+      providers: [],
+      imports: [FormsModule, ReactiveFormsModule],
+    }).compileComponents()
+
+    fixture = TestBed.createComponent(TextAreaComponent)
+    fixture.debugElement.injector.get(NG_VALUE_ACCESSOR)
+    component = fixture.componentInstance
+    fixture.detectChanges()
+    input = component.inputField.nativeElement
+  })
+
+  it('should support use of input field', () => {
+    expect(component.value).toBeUndefined()
+  })
+})
diff --git a/src-ui/src/app/components/common/input/textarea/textarea.component.ts b/src-ui/src/app/components/common/input/textarea/textarea.component.ts
new file mode 100644 (file)
index 0000000..46c2a52
--- /dev/null
@@ -0,0 +1,27 @@
+import { Component, Input, forwardRef } from '@angular/core'
+import { NG_VALUE_ACCESSOR } from '@angular/forms'
+import { AbstractInputComponent } from '../abstract-input'
+
+@Component({
+  providers: [
+    {
+      provide: NG_VALUE_ACCESSOR,
+      useExisting: forwardRef(() => TextAreaComponent),
+      multi: true,
+    },
+  ],
+  selector: 'pngx-input-textarea',
+  templateUrl: './textarea.component.html',
+  styleUrls: ['./textarea.component.scss'],
+})
+export class TextAreaComponent extends AbstractInputComponent<string> {
+  @Input()
+  placeholder: string = ''
+
+  @Input()
+  monospace: boolean = false
+
+  constructor() {
+    super()
+  }
+}
index e9a1818196c72c704fe3a8d5d23256e359fa21a7..da04208b4a331785d6d90f4757b51a0cf930de6a 100644 (file)
@@ -38,7 +38,7 @@
         <th scope="col" class="fw-normal d-none d-sm-table-cell" pngxSortable="matching_algorithm" [currentSortField]="sortField" [currentSortReverse]="sortReverse" (sort)="onSort($event)" i18n>Matching</th>
         <th scope="col" class="fw-normal" pngxSortable="document_count" [currentSortField]="sortField" [currentSortReverse]="sortReverse" (sort)="onSort($event)" i18n>Document count</th>
         @for (column of extraColumns; track column) {
-          <th scope="col" class="fw-normal" pngxSortable="{{column.key}}" [currentSortField]="sortField" [currentSortReverse]="sortReverse" (sort)="onSort($event)">{{column.name}}</th>
+          <th scope="col" class="fw-normal" [ngClass]="{ 'd-none d-sm-table-cell' : column.hideOnMobile }" pngxSortable="{{column.key}}" [currentSortField]="sortField" [currentSortReverse]="sortReverse" (sort)="onSort($event)">{{column.name}}</th>
         }
         <th scope="col" class="fw-normal" i18n>Actions</th>
       </tr>
@@ -64,7 +64,7 @@
           <td scope="row" class="d-none d-sm-table-cell">{{ getMatching(object) }}</td>
           <td scope="row">{{ object.document_count }}</td>
           @for (column of extraColumns; track column) {
-            <td scope="row">
+            <td scope="row" [ngClass]="{ 'd-none d-sm-table-cell' : column.hideOnMobile }">
               @if (column.rendersHtml) {
                 <div [innerHtml]="column.valueFn.call(null, object) | safeHtml"></div>
               } @else {
index 84cee9ddc663c3d9d0755a2cc191ab96d5c88850..27165a8fbd9392cc584df59c3d37f01e105bce97 100644 (file)
@@ -44,6 +44,8 @@ export interface ManagementListColumn {
   valueFn: any
 
   rendersHtml?: boolean
+
+  hideOnMobile?: boolean
 }
 
 @Directive()
index 0816dae7d658a11a25ae5aa482f3f99481580e16..00cb2b037d58ec0c9648d0a9c12ec0949a7cf8f1 100644 (file)
@@ -11,6 +11,8 @@ import { PageHeaderComponent } from '../../common/page-header/page-header.compon
 import { StoragePathListComponent } from './storage-path-list.component'
 import { NgxBootstrapIconsModule, allIcons } from 'ngx-bootstrap-icons'
 import { provideHttpClient, withInterceptorsFromDi } from '@angular/common/http'
+import { SafeHtmlPipe } from 'src/app/pipes/safehtml.pipe'
+import { StoragePath } from 'src/app/data/storage-path'
 
 describe('StoragePathListComponent', () => {
   let component: StoragePathListComponent
@@ -24,6 +26,7 @@ describe('StoragePathListComponent', () => {
         SortableDirective,
         PageHeaderComponent,
         IfPermissionsDirective,
+        SafeHtmlPipe,
       ],
       imports: [
         NgbPaginationModule,
@@ -71,4 +74,15 @@ describe('StoragePathListComponent', () => {
       'Do you really want to delete the storage path "StoragePath1"?'
     )
   })
+
+  it('should truncate path if necessary', () => {
+    const path: StoragePath = {
+      id: 1,
+      name: 'StoragePath1',
+      path: 'a'.repeat(100),
+    }
+    expect(component.extraColumns[0].valueFn(path)).toEqual(
+      `<code>${'a'.repeat(49)}...</code>`
+    )
+  })
 })
index d227f01a52bd8571f032dcd22afacef5c9def63f..66819284dacf129c9e1669d7b6edc4399fef6d8e 100644 (file)
@@ -40,8 +40,10 @@ export class StoragePathListComponent extends ManagementListComponent<StoragePat
         {
           key: 'path',
           name: $localize`Path`,
+          rendersHtml: true,
+          hideOnMobile: true,
           valueFn: (c: StoragePath) => {
-            return c.path
+            return `<code>${c.path?.slice(0, 49)}${c.path?.length > 50 ? '...' : ''}</code>`
           },
         },
       ]
index 69027bf2137178414f17edc04d0ea5caae980b8c..a97c517aa65ab1da6d4496a7b701e8a9d6e8ce73 100644 (file)
@@ -2,12 +2,14 @@ import textwrap
 
 from django.conf import settings
 from django.core.checks import Error
+from django.core.checks import Warning
 from django.core.checks import register
 from django.core.exceptions import FieldError
 from django.db.utils import OperationalError
 from django.db.utils import ProgrammingError
 
 from documents.signals import document_consumer_declaration
+from documents.templating.utils import convert_format_str_to_template_format
 
 
 @register()
@@ -69,3 +71,19 @@ def parser_check(app_configs, **kwargs):
         ]
     else:
         return []
+
+
+@register()
+def filename_format_check(app_configs, **kwargs):
+    if settings.FILENAME_FORMAT:
+        converted_format = convert_format_str_to_template_format(
+            settings.FILENAME_FORMAT,
+        )
+        if converted_format != settings.FILENAME_FORMAT:
+            return [
+                Warning(
+                    f"Filename format {settings.FILENAME_FORMAT} is using the old style, please update to use double curly brackets",
+                    hint=converted_format,
+                ),
+            ]
+    return []
index 700a16d8bfd8768f35aecb0fe403be8103b47641..6d02bf68468079a024b8768f5a708dcd8bec4eb2 100644 (file)
@@ -1,21 +1,10 @@
-import logging
 import os
-from collections import defaultdict
-from pathlib import PurePath
 
-import pathvalidate
 from django.conf import settings
-from django.template.defaultfilters import slugify
-from django.utils import timezone
 
 from documents.models import Document
-
-logger = logging.getLogger("paperless.filehandling")
-
-
-class defaultdictNoStr(defaultdict):
-    def __str__(self):
-        raise ValueError("Don't use {tags} directly.")
+from documents.templating.filepath import validate_filepath_template_and_render
+from documents.templating.utils import convert_format_str_to_template_format
 
 
 def create_source_path_directory(source_path):
@@ -54,32 +43,6 @@ def delete_empty_directories(directory, root):
         directory = os.path.normpath(os.path.dirname(directory))
 
 
-def many_to_dictionary(field):
-    # Converts ManyToManyField to dictionary by assuming, that field
-    # entries contain an _ or - which will be used as a delimiter
-    mydictionary = dict()
-
-    for index, t in enumerate(field.all()):
-        # Populate tag names by index
-        mydictionary[index] = slugify(t.name)
-
-        # Find delimiter
-        delimiter = t.name.find("_")
-
-        if delimiter == -1:
-            delimiter = t.name.find("-")
-
-        if delimiter == -1:
-            continue
-
-        key = t.name[:delimiter]
-        value = t.name[delimiter + 1 :]
-
-        mydictionary[slugify(key)] = slugify(value)
-
-    return mydictionary
-
-
 def generate_unique_filename(doc, archive_filename=False):
     """
     Generates a unique filename for doc in settings.ORIGINALS_DIR.
@@ -134,116 +97,51 @@ def generate_filename(
     archive_filename=False,
 ):
     path = ""
-    filename_format = settings.FILENAME_FORMAT
-
-    try:
-        if doc.storage_path is not None:
-            logger.debug(
-                f"Document has storage_path {doc.storage_path.id} "
-                f"({doc.storage_path.path}) set",
-            )
-            filename_format = doc.storage_path.path
-
-        if filename_format is not None:
-            tags = defaultdictNoStr(
-                lambda: slugify(None),
-                many_to_dictionary(doc.tags),
-            )
-
-            tag_list = pathvalidate.sanitize_filename(
-                ",".join(
-                    sorted(tag.name for tag in doc.tags.all()),
-                ),
-                replacement_text="-",
-            )
-
-            no_value_default = "-none-"
-
-            if doc.correspondent:
-                correspondent = pathvalidate.sanitize_filename(
-                    doc.correspondent.name,
-                    replacement_text="-",
-                )
-            else:
-                correspondent = no_value_default
-
-            if doc.document_type:
-                document_type = pathvalidate.sanitize_filename(
-                    doc.document_type.name,
-                    replacement_text="-",
-                )
-            else:
-                document_type = no_value_default
-
-            if doc.archive_serial_number:
-                asn = str(doc.archive_serial_number)
-            else:
-                asn = no_value_default
-
-            if doc.owner is not None:
-                owner_username_str = str(doc.owner.username)
-            else:
-                owner_username_str = no_value_default
-
-            if doc.original_filename is not None:
-                # No extension
-                original_name = PurePath(doc.original_filename).with_suffix("").name
-            else:
-                original_name = no_value_default
-
-            # Convert UTC database datetime to localized date
-            local_added = timezone.localdate(doc.added)
-            local_created = timezone.localdate(doc.created)
-
-            path = filename_format.format(
-                title=pathvalidate.sanitize_filename(doc.title, replacement_text="-"),
-                correspondent=correspondent,
-                document_type=document_type,
-                created=local_created.isoformat(),
-                created_year=local_created.strftime("%Y"),
-                created_year_short=local_created.strftime("%y"),
-                created_month=local_created.strftime("%m"),
-                created_month_name=local_created.strftime("%B"),
-                created_month_name_short=local_created.strftime("%b"),
-                created_day=local_created.strftime("%d"),
-                added=local_added.isoformat(),
-                added_year=local_added.strftime("%Y"),
-                added_year_short=local_added.strftime("%y"),
-                added_month=local_added.strftime("%m"),
-                added_month_name=local_added.strftime("%B"),
-                added_month_name_short=local_added.strftime("%b"),
-                added_day=local_added.strftime("%d"),
-                asn=asn,
-                tags=tags,
-                tag_list=tag_list,
-                owner_username=owner_username_str,
-                original_name=original_name,
-                doc_pk=f"{doc.pk:07}",
-            ).strip()
-
-            if settings.FILENAME_FORMAT_REMOVE_NONE:
-                path = path.replace("/-none-/", "/")  # remove empty directories
-                path = path.replace(" -none-", "")  # remove when spaced, with space
-                path = path.replace("-none-", "")  # remove rest of the occurrences
-
-            path = path.replace("-none-", "none")  # backward compatibility
-            path = path.strip(os.sep)
-
-    except (ValueError, KeyError, IndexError):
-        logger.warning(
-            f"Invalid filename_format '{filename_format}', falling back to default",
+
+    def format_filename(document: Document, template_str: str) -> str | None:
+        rendered_filename = validate_filepath_template_and_render(
+            template_str,
+            document,
         )
+        if rendered_filename is None:
+            return None
+
+        # Apply this setting.  It could become a filter in the future (or users could use |default)
+        if settings.FILENAME_FORMAT_REMOVE_NONE:
+            rendered_filename = rendered_filename.replace("/-none-/", "/")
+            rendered_filename = rendered_filename.replace(" -none-", "")
+            rendered_filename = rendered_filename.replace("-none-", "")
+
+        rendered_filename = rendered_filename.replace(
+            "-none-",
+            "none",
+        )  # backward compatibility
+
+        return rendered_filename
+
+    # Determine the source of the format string
+    if doc.storage_path is not None:
+        filename_format = doc.storage_path.path
+    elif settings.FILENAME_FORMAT is not None:
+        # Maybe convert old to new style
+        filename_format = convert_format_str_to_template_format(
+            settings.FILENAME_FORMAT,
+        )
+    else:
+        filename_format = None
 
-    counter_str = f"_{counter:02}" if counter else ""
+    # If we have one, render it
+    if filename_format is not None:
+        path = format_filename(doc, filename_format)
 
+    counter_str = f"_{counter:02}" if counter else ""
     filetype_str = ".pdf" if archive_filename else doc.file_type
 
-    if len(path) > 0:
+    if path:
         filename = f"{path}{counter_str}{filetype_str}"
     else:
         filename = f"{doc.pk:07}{counter_str}{filetype_str}"
 
-    # Append .gpg for encrypted files
     if append_gpg and doc.storage_type == doc.STORAGE_TYPE_GPG:
         filename += ".gpg"
 
index 87d6ddc787eab8b18bf121a1209fba71e9b3fc32..1d12c439ba89774f366ec3df53ac9765c71bf093 100644 (file)
@@ -4,6 +4,7 @@ import hashlib
 import logging
 import os
 import shutil
+from collections import defaultdict
 from time import sleep
 
 import pathvalidate
@@ -12,14 +13,41 @@ from django.db import migrations
 from django.db import models
 from django.template.defaultfilters import slugify
 
-from documents.file_handling import defaultdictNoStr
-from documents.file_handling import many_to_dictionary
-
 logger = logging.getLogger("paperless.migrations")
 
+
 ###############################################################################
 # This is code copied straight paperless before the change.
 ###############################################################################
+class defaultdictNoStr(defaultdict):
+    def __str__(self):  # pragma: no cover
+        raise ValueError("Don't use {tags} directly.")
+
+
+def many_to_dictionary(field):  # pragma: no cover
+    # Converts ManyToManyField to dictionary by assuming, that field
+    # entries contain an _ or - which will be used as a delimiter
+    mydictionary = dict()
+
+    for index, t in enumerate(field.all()):
+        # Populate tag names by index
+        mydictionary[index] = slugify(t.name)
+
+        # Find delimiter
+        delimiter = t.name.find("_")
+
+        if delimiter == -1:
+            delimiter = t.name.find("-")
+
+        if delimiter == -1:
+            continue
+
+        key = t.name[:delimiter]
+        value = t.name[delimiter + 1 :]
+
+        mydictionary[slugify(key)] = slugify(value)
+
+    return mydictionary
 
 
 def archive_name_from_filename(filename):
diff --git a/src/documents/migrations/1055_alter_storagepath_path.py b/src/documents/migrations/1055_alter_storagepath_path.py
new file mode 100644 (file)
index 0000000..8231aac
--- /dev/null
@@ -0,0 +1,36 @@
+# Generated by Django 5.1.1 on 2024-10-03 14:47
+
+from django.conf import settings
+from django.db import migrations
+from django.db import models
+from django.db import transaction
+from filelock import FileLock
+
+from documents.templating.utils import convert_format_str_to_template_format
+
+
+def convert_from_format_to_template(apps, schema_editor):
+    StoragePath = apps.get_model("documents", "StoragePath")
+
+    with transaction.atomic(), FileLock(settings.MEDIA_LOCK):
+        for storage_path in StoragePath.objects.all():
+            storage_path.path = convert_format_str_to_template_format(storage_path.path)
+            storage_path.save()
+
+
+class Migration(migrations.Migration):
+    dependencies = [
+        ("documents", "1054_customfieldinstance_value_monetary_amount_and_more"),
+    ]
+
+    operations = [
+        migrations.AlterField(
+            model_name="storagepath",
+            name="path",
+            field=models.CharField(max_length=2048, verbose_name="path"),
+        ),
+        migrations.RunPython(
+            convert_from_format_to_template,
+            migrations.RunPython.noop,
+        ),
+    ]
index 80476bffa8c020d82452ebf0b26b90d0c2ef526e..23325739c89a44bd4c75376857120e915b31f379 100644 (file)
@@ -127,7 +127,7 @@ class DocumentType(MatchingModel):
 class StoragePath(MatchingModel):
     path = models.CharField(
         _("path"),
-        max_length=512,
+        max_length=2048,
     )
 
     class Meta(MatchingModel.Meta):
index f326b4eee8010c2c947dfc39eb5311fd4f38f9ed..7c6e5a3ff9fe16183730f71268ea99c186037385 100644 (file)
@@ -1,4 +1,5 @@
 import datetime
+import logging
 import math
 import re
 import zoneinfo
@@ -52,8 +53,12 @@ from documents.models import WorkflowTrigger
 from documents.parsers import is_mime_type_supported
 from documents.permissions import get_groups_with_only_permission
 from documents.permissions import set_permissions_for_object
+from documents.templating.filepath import validate_filepath_template_and_render
+from documents.templating.utils import convert_format_str_to_template_format
 from documents.validators import uri_validator
 
+logger = logging.getLogger("paperless.serializers")
+
 
 # https://www.django-rest-framework.org/api-guide/serializers/#example
 class DynamicFieldsModelSerializer(serializers.ModelSerializer):
@@ -1482,38 +1487,18 @@ class StoragePathSerializer(MatchingModelSerializer, OwnedObjectSerializer):
             "set_permissions",
         )
 
-    def validate_path(self, path):
-        try:
-            path.format(
-                title="title",
-                correspondent="correspondent",
-                document_type="document_type",
-                created="created",
-                created_year="created_year",
-                created_year_short="created_year_short",
-                created_month="created_month",
-                created_month_name="created_month_name",
-                created_month_name_short="created_month_name_short",
-                created_day="created_day",
-                added="added",
-                added_year="added_year",
-                added_year_short="added_year_short",
-                added_month="added_month",
-                added_month_name="added_month_name",
-                added_month_name_short="added_month_name_short",
-                added_day="added_day",
-                asn="asn",
-                tags="tags",
-                tag_list="tag_list",
-                owner_username="someone",
-                original_name="testfile",
-                doc_pk="doc_pk",
+    def validate_path(self, path: str):
+        converted_path = convert_format_str_to_template_format(path)
+        if converted_path != path:
+            logger.warning(
+                f"Storage path {path} is not using the new style format, consider updating",
             )
+        result = validate_filepath_template_and_render(converted_path)
 
-        except KeyError as err:
-            raise serializers.ValidationError(_("Invalid variable detected.")) from err
+        if result is None:
+            raise serializers.ValidationError(_("Invalid variable detected."))
 
-        return path
+        return converted_path
 
     def update(self, instance, validated_data):
         """
diff --git a/src/documents/templating/__init__.py b/src/documents/templating/__init__.py
new file mode 100644 (file)
index 0000000..e69de29
diff --git a/src/documents/templating/filepath.py b/src/documents/templating/filepath.py
new file mode 100644 (file)
index 0000000..ec902bf
--- /dev/null
@@ -0,0 +1,333 @@
+import logging
+import os
+import re
+from collections.abc import Iterable
+from datetime import datetime
+from pathlib import PurePath
+
+import pathvalidate
+from django.utils import timezone
+from django.utils.dateparse import parse_date
+from jinja2 import StrictUndefined
+from jinja2 import Template
+from jinja2 import TemplateSyntaxError
+from jinja2 import UndefinedError
+from jinja2 import make_logging_undefined
+from jinja2.sandbox import SandboxedEnvironment
+from jinja2.sandbox import SecurityError
+
+from documents.models import Correspondent
+from documents.models import CustomField
+from documents.models import CustomFieldInstance
+from documents.models import Document
+from documents.models import DocumentType
+from documents.models import StoragePath
+from documents.models import Tag
+
+logger = logging.getLogger("paperless.templating")
+
+_LogStrictUndefined = make_logging_undefined(logger, StrictUndefined)
+
+
+class FilePathEnvironment(SandboxedEnvironment):
+    def __init__(self, *args, **kwargs):
+        super().__init__(*args, **kwargs)
+        self.undefined_tracker = None
+
+    def is_safe_callable(self, obj):
+        # Block access to .save() and .delete() methods
+        if callable(obj) and getattr(obj, "__name__", None) in (
+            "save",
+            "delete",
+            "update",
+        ):
+            return False
+        # Call the parent method for other cases
+        return super().is_safe_callable(obj)
+
+
+_template_environment = FilePathEnvironment(
+    trim_blocks=True,
+    lstrip_blocks=True,
+    keep_trailing_newline=False,
+    autoescape=False,
+    extensions=["jinja2.ext.loopcontrols"],
+    undefined=_LogStrictUndefined,
+)
+
+
+class FilePathTemplate(Template):
+    def render(self, *args, **kwargs) -> str:
+        def clean_filepath(value: str) -> str:
+            """
+            Clean up a filepath by:
+            1. Removing newlines and carriage returns
+            2. Removing extra spaces before and after forward slashes
+            3. Preserving spaces in other parts of the path
+            """
+            value = value.replace("\n", "").replace("\r", "")
+            value = re.sub(r"\s*/\s*", "/", value)
+
+            # We remove trailing and leading separators, as these are always relative paths, not absolute, even if the user
+            # tries
+            return value.strip().strip(os.sep)
+
+        original_render = super().render(*args, **kwargs)
+
+        return clean_filepath(original_render)
+
+
+def get_cf_value(
+    custom_field_data: dict[str, dict[str, str]],
+    name: str,
+    default: str | None = None,
+) -> str | None:
+    if name in custom_field_data:
+        return custom_field_data[name]["value"]
+    elif default is not None:
+        return default
+    return None
+
+
+_template_environment.filters["get_cf_value"] = get_cf_value
+
+
+def format_datetime(value: str | datetime, format: str) -> str:
+    if isinstance(value, str):
+        value = parse_date(value)
+    return value.strftime(format=format)
+
+
+_template_environment.filters["datetime"] = format_datetime
+
+
+def create_dummy_document():
+    """
+    Create a dummy Document instance with all possible fields filled
+    """
+    # Populate the document with representative values for every field
+    dummy_doc = Document(
+        pk=1,
+        title="Sample Title",
+        correspondent=Correspondent(name="Sample Correspondent"),
+        storage_path=StoragePath(path="/dummy/path"),
+        document_type=DocumentType(name="Sample Type"),
+        content="This is some sample document content.",
+        mime_type="application/pdf",
+        checksum="dummychecksum12345678901234567890123456789012",
+        archive_checksum="dummyarchivechecksum123456789012345678901234",
+        page_count=5,
+        created=timezone.now(),
+        modified=timezone.now(),
+        storage_type=Document.STORAGE_TYPE_UNENCRYPTED,
+        added=timezone.now(),
+        filename="/dummy/filename.pdf",
+        archive_filename="/dummy/archive_filename.pdf",
+        original_filename="original_file.pdf",
+        archive_serial_number=12345,
+    )
+    return dummy_doc
+
+
+def get_creation_date_context(document: Document) -> dict[str, str]:
+    """
+    Given a Document, localizes the creation date and builds a context dictionary with some common, shorthand
+    formatted values from it
+    """
+    local_created = timezone.localdate(document.created)
+
+    return {
+        "created": local_created.isoformat(),
+        "created_year": local_created.strftime("%Y"),
+        "created_year_short": local_created.strftime("%y"),
+        "created_month": local_created.strftime("%m"),
+        "created_month_name": local_created.strftime("%B"),
+        "created_month_name_short": local_created.strftime("%b"),
+        "created_day": local_created.strftime("%d"),
+    }
+
+
+def get_added_date_context(document: Document) -> dict[str, str]:
+    """
+    Given a Document, localizes the added date and builds a context dictionary with some common, shorthand
+    formatted values from it
+    """
+    local_added = timezone.localdate(document.added)
+
+    return {
+        "added": local_added.isoformat(),
+        "added_year": local_added.strftime("%Y"),
+        "added_year_short": local_added.strftime("%y"),
+        "added_month": local_added.strftime("%m"),
+        "added_month_name": local_added.strftime("%B"),
+        "added_month_name_short": local_added.strftime("%b"),
+        "added_day": local_added.strftime("%d"),
+    }
+
+
+def get_basic_metadata_context(
+    document: Document,
+    *,
+    no_value_default: str,
+) -> dict[str, str]:
+    """
+    Given a Document, constructs some basic information about it.  If certain values are not set,
+    they will be replaced with the no_value_default.
+
+    Regardless of set or not, the values will be sanitized
+    """
+    return {
+        "title": pathvalidate.sanitize_filename(
+            document.title,
+            replacement_text="-",
+        ),
+        "correspondent": pathvalidate.sanitize_filename(
+            document.correspondent.name,
+            replacement_text="-",
+        )
+        if document.correspondent
+        else no_value_default,
+        "document_type": pathvalidate.sanitize_filename(
+            document.document_type.name,
+            replacement_text="-",
+        )
+        if document.document_type
+        else no_value_default,
+        "asn": str(document.archive_serial_number)
+        if document.archive_serial_number
+        else no_value_default,
+        "owner_username": document.owner.username
+        if document.owner
+        else no_value_default,
+        "original_name": PurePath(document.original_filename).with_suffix("").name
+        if document.original_filename
+        else no_value_default,
+        "doc_pk": f"{document.pk:07}",
+    }
+
+
+def get_tags_context(tags: Iterable[Tag]) -> dict[str, str | list[str]]:
+    """
+    Given an Iterable of tags, constructs some context from them for usage
+    """
+    return {
+        "tag_list": pathvalidate.sanitize_filename(
+            ",".join(
+                sorted(tag.name for tag in tags),
+            ),
+            replacement_text="-",
+        ),
+        # Assumed to be ordered, but a template could loop through to find what they want
+        "tag_name_list": [x.name for x in tags],
+    }
+
+
+def get_custom_fields_context(
+    custom_fields: Iterable[CustomFieldInstance],
+) -> dict[str, dict[str, dict[str, str]]]:
+    """
+    Given an Iterable of CustomFieldInstance, builds a dictionary mapping the field name
+    to its type and value
+    """
+    field_data = {"custom_fields": {}}
+    for field_instance in custom_fields:
+        type_ = pathvalidate.sanitize_filename(
+            field_instance.field.data_type,
+            replacement_text="-",
+        )
+        # String types need to be sanitized
+        if field_instance.field.data_type in {
+            CustomField.FieldDataType.DOCUMENTLINK,
+            CustomField.FieldDataType.MONETARY,
+            CustomField.FieldDataType.STRING,
+            CustomField.FieldDataType.URL,
+        }:
+            value = pathvalidate.sanitize_filename(
+                field_instance.value,
+                replacement_text="-",
+            )
+        elif (
+            field_instance.field.data_type == CustomField.FieldDataType.SELECT
+            and field_instance.field.extra_data["select_options"] is not None
+        ):
+            options = field_instance.field.extra_data["select_options"]
+            value = pathvalidate.sanitize_filename(
+                options[int(field_instance.value)],
+                replacement_text="-",
+            )
+        else:
+            value = field_instance.value
+        field_data["custom_fields"][
+            pathvalidate.sanitize_filename(
+                field_instance.field.name,
+                replacement_text="-",
+            )
+        ] = {
+            "type": type_,
+            "value": value,
+        }
+    return field_data
+
+
+def validate_filepath_template_and_render(
+    template_string: str,
+    document: Document | None = None,
+) -> str | None:
+    """
+    Renders the given template string using either the given Document or using a dummy Document and data
+
+    Returns None if the string is not valid or an error occurred, otherwise
+    """
+
+    # Create the dummy document object with all fields filled in for validation purposes
+    if document is None:
+        document = create_dummy_document()
+        tags_list = [Tag(name="Test Tag 1"), Tag(name="Another Test Tag")]
+        custom_fields = [
+            CustomFieldInstance(
+                field=CustomField(
+                    name="Text Custom Field",
+                    data_type=CustomField.FieldDataType.STRING,
+                ),
+                value_text="Some String Text",
+            ),
+        ]
+    else:
+        # or use the real document information
+        tags_list = document.tags.order_by("name").all()
+        custom_fields = document.custom_fields.all()
+
+    # Build the context dictionary
+    context = (
+        {"document": document}
+        | get_basic_metadata_context(document, no_value_default="-none-")
+        | get_creation_date_context(document)
+        | get_added_date_context(document)
+        | get_tags_context(tags_list)
+        | get_custom_fields_context(custom_fields)
+    )
+
+    # Try rendering the template
+    try:
+        # We load the custom tag used to remove spaces and newlines from the final string around the user string
+        template = _template_environment.from_string(
+            template_string,
+            template_class=FilePathTemplate,
+        )
+        rendered_template = template.render(context)
+
+        # We're good!
+        return rendered_template
+    except UndefinedError:
+        # The undefined class logs this already for us
+        pass
+    except TemplateSyntaxError as e:
+        logger.warning(f"Template syntax error in filename generation: {e}")
+    except SecurityError as e:
+        logger.warning(f"Template attempted restricted operation: {e}")
+    except Exception as e:
+        logger.warning(f"Unknown error in filename generation: {e}")
+        logger.warning(
+            f"Invalid filename_format '{template_string}', falling back to default",
+        )
+    return None
diff --git a/src/documents/templating/utils.py b/src/documents/templating/utils.py
new file mode 100644 (file)
index 0000000..894fda0
--- /dev/null
@@ -0,0 +1,24 @@
+import re
+
+
+def convert_format_str_to_template_format(old_format: str) -> str:
+    """
+    Converts old Python string format (with {}) to Jinja2 template style (with {{ }}),
+    while ignoring existing {{ ... }} placeholders.
+
+    :param old_format: The old style format string (e.g., "{title} by {author}")
+    :return: Converted string in Django Template style (e.g., "{{ title }} by {{ author }}")
+    """
+
+    # Step 1: Match placeholders with single curly braces but not those with double braces
+    pattern = r"(?<!\{)\{(\w*)\}(?!\})"  # Matches {var} but not {{var}}
+
+    # Step 2: Replace the placeholders with {{ var }} or {{ }}
+    def replace_with_django(match):
+        variable = match.group(1)  # The variable inside the braces
+        return f"{{{{ {variable} }}}}"  # Convert to {{ variable }}
+
+    # Apply the substitution
+    converted_format = re.sub(pattern, replace_with_django, old_format)
+
+    return converted_format
index 1a55a936c2203a7ab60e35809a8d3ef45ac4b22f..c74248b9ae15275946a996b2ba71fb7fac8f21d4 100644 (file)
@@ -239,7 +239,7 @@ class TestApiStoragePaths(DirectoriesMixin, APITestCase):
                     "/{created_year_short}/{created_month}/{created_month_name}"
                     "/{created_month_name_short}/{created_day}/{added}/{added_year}"
                     "/{added_year_short}/{added_month}/{added_month_name}"
-                    "/{added_month_name_short}/{added_day}/{asn}/{tags}"
+                    "/{added_month_name_short}/{added_day}/{asn}"
                     "/{tag_list}/{owner_username}/{original_name}/{doc_pk}/",
                 },
             ),
index 3214903967348d618965f66e53acdd500db98f2f..4af05746fde4a332da3203c78a251f21190b7449 100644 (file)
@@ -2,10 +2,12 @@ import textwrap
 from unittest import mock
 
 from django.core.checks import Error
+from django.core.checks import Warning
 from django.test import TestCase
 from django.test import override_settings
 
 from documents.checks import changed_password_check
+from documents.checks import filename_format_check
 from documents.checks import parser_check
 from documents.models import Document
 from documents.tests.factories import DocumentFactory
@@ -73,3 +75,17 @@ class TestDocumentChecks(TestCase):
                     ),
                 ],
             )
+
+    def test_filename_format_check(self):
+        self.assertEqual(filename_format_check(None), [])
+
+        with override_settings(FILENAME_FORMAT="{created}/{title}"):
+            self.assertEqual(
+                filename_format_check(None),
+                [
+                    Warning(
+                        "Filename format {created}/{title} is using the old style, please update to use double curly brackets",
+                        hint="{{ created }}/{{ title }}",
+                    ),
+                ],
+            )
index e13cd866d4393dc42aa99d578640c82c8014cab8..3a0700e1ca9d914b5fcfa4585a4eb6ea1bc441be 100644 (file)
@@ -1,4 +1,5 @@
 import datetime
+import logging
 import os
 import tempfile
 from pathlib import Path
@@ -16,6 +17,8 @@ from documents.file_handling import create_source_path_directory
 from documents.file_handling import delete_empty_directories
 from documents.file_handling import generate_filename
 from documents.models import Correspondent
+from documents.models import CustomField
+from documents.models import CustomFieldInstance
 from documents.models import Document
 from documents.models import DocumentType
 from documents.models import StoragePath
@@ -290,88 +293,6 @@ class TestFileHandling(DirectoriesMixin, FileSystemAssertsMixin, TestCase):
         self.assertEqual(generate_filename(d1), "652 - the_doc.pdf")
         self.assertEqual(generate_filename(d2), "none - the_doc.pdf")
 
-    @override_settings(FILENAME_FORMAT="{tags[type]}")
-    def test_tags_with_underscore(self):
-        document = Document()
-        document.mime_type = "application/pdf"
-        document.storage_type = Document.STORAGE_TYPE_UNENCRYPTED
-        document.save()
-
-        # Add tag to document
-        document.tags.create(name="type_demo")
-        document.tags.create(name="foo_bar")
-        document.save()
-
-        # Ensure that filename is properly generated
-        self.assertEqual(generate_filename(document), "demo.pdf")
-
-    @override_settings(FILENAME_FORMAT="{tags[type]}")
-    def test_tags_with_dash(self):
-        document = Document()
-        document.mime_type = "application/pdf"
-        document.storage_type = Document.STORAGE_TYPE_UNENCRYPTED
-        document.save()
-
-        # Add tag to document
-        document.tags.create(name="type-demo")
-        document.tags.create(name="foo-bar")
-        document.save()
-
-        # Ensure that filename is properly generated
-        self.assertEqual(generate_filename(document), "demo.pdf")
-
-    @override_settings(FILENAME_FORMAT="{tags[type]}")
-    def test_tags_malformed(self):
-        document = Document()
-        document.mime_type = "application/pdf"
-        document.storage_type = Document.STORAGE_TYPE_UNENCRYPTED
-        document.save()
-
-        # Add tag to document
-        document.tags.create(name="type:demo")
-        document.tags.create(name="foo:bar")
-        document.save()
-
-        # Ensure that filename is properly generated
-        self.assertEqual(generate_filename(document), "none.pdf")
-
-    @override_settings(FILENAME_FORMAT="{tags[0]}")
-    def test_tags_all(self):
-        document = Document()
-        document.mime_type = "application/pdf"
-        document.storage_type = Document.STORAGE_TYPE_UNENCRYPTED
-        document.save()
-
-        # Add tag to document
-        document.tags.create(name="demo")
-        document.save()
-
-        # Ensure that filename is properly generated
-        self.assertEqual(generate_filename(document), "demo.pdf")
-
-    @override_settings(FILENAME_FORMAT="{tags[1]}")
-    def test_tags_out_of_bounds(self):
-        document = Document()
-        document.mime_type = "application/pdf"
-        document.storage_type = Document.STORAGE_TYPE_UNENCRYPTED
-        document.save()
-
-        # Add tag to document
-        document.tags.create(name="demo")
-        document.save()
-
-        # Ensure that filename is properly generated
-        self.assertEqual(generate_filename(document), "none.pdf")
-
-    @override_settings(FILENAME_FORMAT="{tags}")
-    def test_tags_without_args(self):
-        document = Document()
-        document.mime_type = "application/pdf"
-        document.storage_type = Document.STORAGE_TYPE_UNENCRYPTED
-        document.save()
-
-        self.assertEqual(generate_filename(document), f"{document.pk:07}.pdf")
-
     @override_settings(FILENAME_FORMAT="{title} {tag_list}")
     def test_tag_list(self):
         doc = Document.objects.create(title="doc1", mime_type="application/pdf")
@@ -501,7 +422,7 @@ class TestFileHandling(DirectoriesMixin, FileSystemAssertsMixin, TestCase):
         self.assertIsFile(os.path.join(tmp, "notempty", "file"))
         self.assertIsNotDir(os.path.join(tmp, "notempty", "empty"))
 
-    @override_settings(FILENAME_FORMAT="{created/[title]")
+    @override_settings(FILENAME_FORMAT="{% if x is None %}/{title]")
     def test_invalid_format(self):
         document = Document()
         document.pk = 1
@@ -957,7 +878,7 @@ class TestFilenameGeneration(DirectoriesMixin, TestCase):
             mime_type="application/pdf",
             pk=2,
             checksum="2",
-            storage_path=StoragePath.objects.create(path="TestFolder/{created}"),
+            storage_path=StoragePath.objects.create(path="TestFolder/{{created}}"),
         )
         self.assertEqual(generate_filename(doc), "TestFolder/2020-06-25.pdf")
 
@@ -978,7 +899,7 @@ class TestFilenameGeneration(DirectoriesMixin, TestCase):
             mime_type="application/pdf",
             pk=2,
             checksum="2",
-            storage_path=StoragePath.objects.create(path="{asn} - {created}"),
+            storage_path=StoragePath.objects.create(path="{{asn}} - {{created}}"),
         )
         self.assertEqual(generate_filename(doc), "none - 2020-06-25.pdf")
 
@@ -1003,7 +924,9 @@ class TestFilenameGeneration(DirectoriesMixin, TestCase):
             mime_type="application/pdf",
             pk=2,
             checksum="2",
-            storage_path=StoragePath.objects.create(path="TestFolder/{asn}/{created}"),
+            storage_path=StoragePath.objects.create(
+                path="TestFolder/{{asn}}/{{created}}",
+            ),
         )
         self.assertEqual(generate_filename(doc), "TestFolder/2020-06-25.pdf")
 
@@ -1025,7 +948,7 @@ class TestFilenameGeneration(DirectoriesMixin, TestCase):
             archive_serial_number=4,
             storage_path=StoragePath.objects.create(
                 name="sp1",
-                path="ThisIsAFolder/{asn}/{created}",
+                path="ThisIsAFolder/{{asn}}/{{created}}",
             ),
         )
         doc_b = Document.objects.create(
@@ -1036,7 +959,7 @@ class TestFilenameGeneration(DirectoriesMixin, TestCase):
             checksum="abcde",
             storage_path=StoragePath.objects.create(
                 name="sp2",
-                path="SomeImportantNone/{created}",
+                path="SomeImportantNone/{{created}}",
             ),
         )
 
@@ -1072,7 +995,7 @@ class TestFilenameGeneration(DirectoriesMixin, TestCase):
             checksum="abcde",
             storage_path=StoragePath.objects.create(
                 name="sp2",
-                path="SomeImportantNone/{created}",
+                path="SomeImportantNone/{{created}}",
             ),
         )
 
@@ -1221,3 +1144,296 @@ class TestFilenameGeneration(DirectoriesMixin, TestCase):
         # Ensure that filename is properly generated
         document.filename = generate_filename(document)
         self.assertEqual(document.filename, "XX/doc1.pdf")
+
+    def test_complex_template_strings(self):
+        """
+        GIVEN:
+            - Storage paths with complex conditionals and logic
+        WHEN:
+            - Filepath for a document with this storage path is called
+        THEN:
+            - The filepath is rendered without error
+            - The filepath is rendered as a single line string
+        """
+        sp = StoragePath.objects.create(
+            name="sp1",
+            path="""
+                 somepath/
+                 {% if document.checksum == '2' %}
+                   some where/{{created}}
+                 {% else %}
+                   {{added}}
+                 {% endif %}
+                 /{{ title }}
+                 """,
+        )
+
+        doc_a = Document.objects.create(
+            title="Does Matter",
+            created=timezone.make_aware(datetime.datetime(2020, 6, 25, 7, 36, 51, 153)),
+            added=timezone.make_aware(datetime.datetime(2024, 10, 1, 7, 36, 51, 153)),
+            mime_type="application/pdf",
+            pk=2,
+            checksum="2",
+            archive_serial_number=25,
+            storage_path=sp,
+        )
+
+        self.assertEqual(
+            generate_filename(doc_a),
+            "somepath/some where/2020-06-25/Does Matter.pdf",
+        )
+        doc_a.checksum = "5"
+
+        self.assertEqual(
+            generate_filename(doc_a),
+            "somepath/2024-10-01/Does Matter.pdf",
+        )
+
+        sp.path = "{{ document.title|lower }}{{ document.archive_serial_number - 2 }}"
+        sp.save()
+
+        self.assertEqual(generate_filename(doc_a), "does matter23.pdf")
+
+        sp.path = """
+                 somepath/
+                 {% if document.archive_serial_number >= 0 and document.archive_serial_number <= 200 %}
+                   asn-000-200/{{title}}
+                 {% elif document.archive_serial_number >= 201 and document.archive_serial_number <= 400 %}
+                   asn-201-400
+                   {% if document.archive_serial_number >= 201 and document.archive_serial_number < 300 %}
+                     /asn-2xx
+                   {% elif document.archive_serial_number >= 300 and document.archive_serial_number < 400 %}
+                     /asn-3xx
+                   {% endif %}
+                 {% endif %}
+                 /{{ title }}
+                 """
+        sp.save()
+        self.assertEqual(
+            generate_filename(doc_a),
+            "somepath/asn-000-200/Does Matter/Does Matter.pdf",
+        )
+        doc_a.archive_serial_number = 301
+        doc_a.save()
+        self.assertEqual(
+            generate_filename(doc_a),
+            "somepath/asn-201-400/asn-3xx/Does Matter.pdf",
+        )
+
+    @override_settings(
+        FILENAME_FORMAT="{{creation_date}}/{{ title_name_str }}",
+    )
+    def test_template_with_undefined_var(self):
+        """
+        GIVEN:
+            - Filename format with one or more undefined variables
+        WHEN:
+            - Filepath for a document with this format is called
+        THEN:
+            - The first undefined variable is logged
+            - The default format is used
+        """
+        doc_a = Document.objects.create(
+            title="Does Matter",
+            created=timezone.make_aware(datetime.datetime(2020, 6, 25, 7, 36, 51, 153)),
+            added=timezone.make_aware(datetime.datetime(2024, 10, 1, 7, 36, 51, 153)),
+            mime_type="application/pdf",
+            pk=2,
+            checksum="2",
+            archive_serial_number=25,
+        )
+
+        with self.assertLogs(level=logging.WARNING) as capture:
+            self.assertEqual(
+                generate_filename(doc_a),
+                "0000002.pdf",
+            )
+
+            self.assertEqual(len(capture.output), 1)
+            self.assertEqual(
+                capture.output[0],
+                "WARNING:paperless.templating:Template variable warning: 'creation_date' is undefined",
+            )
+
+    @override_settings(
+        FILENAME_FORMAT="{{created}}/{{ document.save() }}",
+    )
+    def test_template_with_security(self):
+        """
+        GIVEN:
+            - Filename format with one or more undefined variables
+        WHEN:
+            - Filepath for a document with this format is called
+        THEN:
+            - The first undefined variable is logged
+            - The default format is used
+        """
+        doc_a = Document.objects.create(
+            title="Does Matter",
+            created=timezone.make_aware(datetime.datetime(2020, 6, 25, 7, 36, 51, 153)),
+            added=timezone.make_aware(datetime.datetime(2024, 10, 1, 7, 36, 51, 153)),
+            mime_type="application/pdf",
+            pk=2,
+            checksum="2",
+            archive_serial_number=25,
+        )
+
+        with self.assertLogs(level=logging.WARNING) as capture:
+            self.assertEqual(
+                generate_filename(doc_a),
+                "0000002.pdf",
+            )
+
+            self.assertEqual(len(capture.output), 1)
+            self.assertEqual(
+                capture.output[0],
+                "WARNING:paperless.templating:Template attempted restricted operation: <bound method Model.save of <Document: 2020-06-25 Does Matter>> is not safely callable",
+            )
+
+    def test_template_with_custom_fields(self):
+        """
+        GIVEN:
+            - Filename format which accesses custom field data
+        WHEN:
+            - Filepath for a document with this format is called
+        THEN:
+            - The custom field data is rendered
+            - If the field name is not defined, the default value is rendered, if any
+        """
+        doc_a = Document.objects.create(
+            title="Some Title",
+            created=timezone.make_aware(datetime.datetime(2020, 6, 25, 7, 36, 51, 153)),
+            added=timezone.make_aware(datetime.datetime(2024, 10, 1, 7, 36, 51, 153)),
+            mime_type="application/pdf",
+            pk=2,
+            checksum="2",
+            archive_serial_number=25,
+        )
+
+        cf = CustomField.objects.create(
+            name="Invoice",
+            data_type=CustomField.FieldDataType.INT,
+        )
+
+        cf2 = CustomField.objects.create(
+            name="Select Field",
+            data_type=CustomField.FieldDataType.SELECT,
+            extra_data={"select_options": ["ChoiceOne", "ChoiceTwo"]},
+        )
+
+        CustomFieldInstance.objects.create(
+            document=doc_a,
+            field=cf2,
+            value_select=0,
+        )
+
+        cfi = CustomFieldInstance.objects.create(
+            document=doc_a,
+            field=cf,
+            value_int=1234,
+        )
+
+        with override_settings(
+            FILENAME_FORMAT="""
+                 {% if "Invoice" in custom_fields %}
+                   invoices/{{ custom_fields | get_cf_value('Invoice') }}
+                 {% else %}
+                   not-invoices/{{ title }}
+                 {% endif %}
+                 """,
+        ):
+            self.assertEqual(
+                generate_filename(doc_a),
+                "invoices/1234.pdf",
+            )
+
+        with override_settings(
+            FILENAME_FORMAT="""
+                 {% if "Select Field" in custom_fields %}
+                   {{ title }}_{{ custom_fields | get_cf_value('Select Field') }}
+                 {% else %}
+                   {{ title }}
+                 {% endif %}
+                 """,
+        ):
+            self.assertEqual(
+                generate_filename(doc_a),
+                "Some Title_ChoiceOne.pdf",
+            )
+
+        cf.name = "Invoice Number"
+        cfi.value_int = 4567
+        cfi.save()
+        cf.save()
+
+        with override_settings(
+            FILENAME_FORMAT="invoices/{{ custom_fields | get_cf_value('Invoice Number') }}",
+        ):
+            self.assertEqual(
+                generate_filename(doc_a),
+                "invoices/4567.pdf",
+            )
+
+        with override_settings(
+            FILENAME_FORMAT="invoices/{{ custom_fields | get_cf_value('Ince Number', 0) }}",
+        ):
+            self.assertEqual(
+                generate_filename(doc_a),
+                "invoices/0.pdf",
+            )
+
+    def test_datetime_filter(self):
+        """
+        GIVEN:
+            - Filename format with datetime filter
+        WHEN:
+            - Filepath for a document with this format is called
+        THEN:
+            - The datetime filter is rendered
+        """
+        doc_a = Document.objects.create(
+            title="Some Title",
+            created=timezone.make_aware(datetime.datetime(2020, 6, 25, 7, 36, 51, 153)),
+            added=timezone.make_aware(datetime.datetime(2024, 10, 1, 7, 36, 51, 153)),
+            mime_type="application/pdf",
+            pk=2,
+            checksum="2",
+            archive_serial_number=25,
+        )
+
+        CustomField.objects.create(
+            name="Invoice Date",
+            data_type=CustomField.FieldDataType.DATE,
+        )
+        CustomFieldInstance.objects.create(
+            document=doc_a,
+            field=CustomField.objects.get(name="Invoice Date"),
+            value_date=timezone.make_aware(
+                datetime.datetime(2024, 10, 1, 7, 36, 51, 153),
+            ),
+        )
+
+        with override_settings(
+            FILENAME_FORMAT="{{ created | datetime('%Y') }}/{{ title }}",
+        ):
+            self.assertEqual(
+                generate_filename(doc_a),
+                "2020/Some Title.pdf",
+            )
+
+        with override_settings(
+            FILENAME_FORMAT="{{ created | datetime('%Y-%m-%d') }}/{{ title }}",
+        ):
+            self.assertEqual(
+                generate_filename(doc_a),
+                "2020-06-25/Some Title.pdf",
+            )
+
+        with override_settings(
+            FILENAME_FORMAT="{{ custom_fields | get_cf_value('Invoice Date') | datetime('%Y-%m-%d') }}/{{ title }}",
+        ):
+            self.assertEqual(
+                generate_filename(doc_a),
+                "2024-10-01/Some Title.pdf",
+            )
diff --git a/src/documents/tests/test_migration_storage_path_template.py b/src/documents/tests/test_migration_storage_path_template.py
new file mode 100644 (file)
index 0000000..37b87a1
--- /dev/null
@@ -0,0 +1,30 @@
+from documents.models import StoragePath
+from documents.tests.utils import TestMigrations
+
+
+class TestMigrateStoragePathToTemplate(TestMigrations):
+    migrate_from = "1054_customfieldinstance_value_monetary_amount_and_more"
+    migrate_to = "1055_alter_storagepath_path"
+
+    def setUpBeforeMigration(self, apps):
+        self.old_format = StoragePath.objects.create(
+            name="sp1",
+            path="Something/{title}",
+        )
+        self.new_format = StoragePath.objects.create(
+            name="sp2",
+            path="{{asn}}/{{title}}",
+        )
+        self.no_formatting = StoragePath.objects.create(
+            name="sp3",
+            path="Some/Fixed/Path",
+        )
+
+    def test_migrate_old_to_new_storage_path(self):
+        self.old_format.refresh_from_db()
+        self.new_format.refresh_from_db()
+        self.no_formatting.refresh_from_db()
+
+        self.assertEqual(self.old_format.path, "Something/{{ title }}")
+        self.assertEqual(self.new_format.path, "{{asn}}/{{title}}")
+        self.assertEqual(self.no_formatting.path, "Some/Fixed/Path")