Feature: Enhanced templating for filename format (#7836)

Co-authored-by: shamoon <4887959+shamoon@users.noreply.github.com>
This commit is contained in:
Trenton H 2024-10-06 12:54:01 -07:00 committed by GitHub
parent e49ed58f1a
commit 7c11a37150
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
29 changed files with 1299 additions and 615 deletions

View File

@ -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

491
Pipfile.lock generated
View File

@ -1,7 +1,7 @@
{
"_meta": {
"hash": {
"sha256": "1be8ddf875b6aa77fcf61f5c065c9dc3941cad4b9285ce64da60b5684357dade"
"sha256": "1e113d0879e4e0bc3c384115057647ac8d9be05252dd7c708a1fc873f294ef28"
},
"pipfile-spec": 6,
"requires": {},
@ -544,12 +544,12 @@
},
"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": [
@ -744,11 +744,11 @@
},
"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": [
@ -828,11 +828,11 @@
},
"imap-tools": {
"hashes": [
"sha256:218ea6495d73275ecc2fa4a34717c137bacf2c4a3d34c9d10a9581a6af1ac94f",
"sha256:4c31e9df1d28149436a86871cf84a0b37221a91521fc1a57897e0a152ee3f6d1"
"sha256:bd84d0f40fbd7be27f6ff5c3908e74d96e99d6b5f44f19cd6e928d308c811916",
"sha256:e657df2f62c1b263c0fd1610cfcd9f8cde26de6b696ae25c401ba75d91a5fd93"
],
"index": "pypi",
"version": "==1.7.2"
"version": "==1.7.3"
},
"img2pdf": {
"hashes": [
@ -844,7 +844,7 @@
"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": {
@ -853,9 +853,18 @@
"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",
@ -1032,6 +1041,72 @@
"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",
@ -1304,54 +1379,49 @@
},
"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": [
@ -1520,7 +1590,7 @@
"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": {
@ -1732,108 +1802,17 @@
"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"
@ -1843,7 +1822,6 @@
"sha256:55365417734eb18255590a9ff9eb97e9e1da868d4ccd6402399eaf68af20a760",
"sha256:70761cfe03c773ceb22aa2f671b4757976145175cdfca038c02654d061d6dcc6"
],
"markers": "python_version >= '3.8'",
"version": "==2.32.3"
},
"requests-oauthlib": {
@ -1855,15 +1833,16 @@
},
"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",
@ -1877,10 +1856,14 @@
"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"
@ -2028,7 +2011,7 @@
"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": {
@ -3057,11 +3040,11 @@
},
"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": [
@ -3089,11 +3072,11 @@
},
"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": [
@ -3158,6 +3141,7 @@
"sha256:4a3aee7acbbe7303aede8e9648d13b8bf88a429282aa6122a993f0ac800cb369",
"sha256:bc5dd2abb727a5319567b7a813e6a2e7318c39f4f487cfe6c89c6f9c7d25197d"
],
"index": "pypi",
"markers": "python_version >= '3.7'",
"version": "==3.1.4"
},
@ -3519,11 +3503,11 @@
},
"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": [
@ -3618,7 +3602,7 @@
"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": {
@ -3731,100 +3715,9 @@
},
"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"
@ -3834,33 +3727,32 @@
"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": [
@ -3921,7 +3813,7 @@
"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": {
@ -3942,11 +3834,11 @@
},
"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": [
@ -4412,7 +4304,6 @@
"sha256:55365417734eb18255590a9ff9eb97e9e1da868d4ccd6402399eaf68af20a760",
"sha256:70761cfe03c773ceb22aa2f671b4757976145175cdfca038c02654d061d6dcc6"
],
"markers": "python_version >= '3.8'",
"version": "==2.32.3"
},
"sqlparse": {
@ -4425,11 +4316,11 @@
},
"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": [
@ -4468,11 +4359,11 @@
},
"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": [
@ -4519,12 +4410,12 @@
},
"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": [
@ -4536,12 +4427,12 @@
},
"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": [

View 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

View File

@ -1569,7 +1569,7 @@
</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>
@ -2193,11 +2193,11 @@
</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">
@ -2239,7 +2239,7 @@
</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>
@ -2594,7 +2594,7 @@
</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>
@ -4776,6 +4776,10 @@
<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>
@ -7774,7 +7778,7 @@
</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">
@ -7857,7 +7861,7 @@
<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>
@ -7868,7 +7872,7 @@
<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>
@ -7879,70 +7883,70 @@
<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">
@ -7963,7 +7967,7 @@
<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">

View 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,

View 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>

View 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,
],

View 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.`
)
}

View File

@ -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>

View File

@ -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()
})
})

View File

@ -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()
}
}

View 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 {

View File

@ -44,6 +44,8 @@ export interface ManagementListColumn {
valueFn: any
rendersHtml?: boolean
hideOnMobile?: boolean
}
@Directive()

View 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>`
)
})
})

View 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>`
},
},
]

View 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 []

View 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
# 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"

View 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):

View File

@ -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,
),
]

View 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):

View 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):
"""

View File

View File

@ -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

View File

@ -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

View 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}/",
},
),

View 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 }}",
),
],
)

View 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",
)

View File

@ -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")