mirror of
https://github.com/paperless-ngx/paperless-ngx.git
synced 2025-04-02 13:45:10 -05:00
Feature: Enhanced templating for filename format (#7836)
Co-authored-by: shamoon <4887959+shamoon@users.noreply.github.com>
This commit is contained in:
parent
e49ed58f1a
commit
7c11a37150
1
Pipfile
1
Pipfile
@ -57,6 +57,7 @@ watchdog = "~=4.0"
|
||||
whitenoise = "~=6.7"
|
||||
whoosh = "~=2.7"
|
||||
zxing-cpp = {version = "*", platform_machine = "== 'x86_64'"}
|
||||
jinja2 = "~=3.1"
|
||||
|
||||
[dev-packages]
|
||||
# Linting
|
||||
|
491
Pipfile.lock
generated
491
Pipfile.lock
generated
@ -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": [
|
||||
|
@ -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
|
||||
|
@ -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 "<x id="PH" equiv-text="object.name"/>"?</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">
|
||||
|
@ -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,
|
||||
|
@ -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>
|
||||
|
@ -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,
|
||||
],
|
||||
|
@ -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.`
|
||||
)
|
||||
}
|
||||
|
@ -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> <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>
|
@ -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()
|
||||
})
|
||||
})
|
@ -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()
|
||||
}
|
||||
}
|
@ -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 {
|
||||
|
@ -44,6 +44,8 @@ export interface ManagementListColumn {
|
||||
valueFn: any
|
||||
|
||||
rendersHtml?: boolean
|
||||
|
||||
hideOnMobile?: boolean
|
||||
}
|
||||
|
||||
@Directive()
|
||||
|
@ -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>`
|
||||
)
|
||||
})
|
||||
})
|
||||
|
@ -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>`
|
||||
},
|
||||
},
|
||||
]
|
||||
|
@ -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 []
|
||||
|
@ -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"
|
||||
|
||||
|
@ -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):
|
||||
|
36
src/documents/migrations/1055_alter_storagepath_path.py
Normal file
36
src/documents/migrations/1055_alter_storagepath_path.py
Normal 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,
|
||||
),
|
||||
]
|
@ -127,7 +127,7 @@ class DocumentType(MatchingModel):
|
||||
class StoragePath(MatchingModel):
|
||||
path = models.CharField(
|
||||
_("path"),
|
||||
max_length=512,
|
||||
max_length=2048,
|
||||
)
|
||||
|
||||
class Meta(MatchingModel.Meta):
|
||||
|
@ -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):
|
||||
"""
|
||||
|
0
src/documents/templating/__init__.py
Normal file
0
src/documents/templating/__init__.py
Normal file
333
src/documents/templating/filepath.py
Normal file
333
src/documents/templating/filepath.py
Normal 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
|
24
src/documents/templating/utils.py
Normal file
24
src/documents/templating/utils.py
Normal 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
|
@ -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}/",
|
||||
},
|
||||
),
|
||||
|
@ -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 }}",
|
||||
),
|
||||
],
|
||||
)
|
||||
|
@ -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",
|
||||
)
|
||||
|
30
src/documents/tests/test_migration_storage_path_template.py
Normal file
30
src/documents/tests/test_migration_storage_path_template.py
Normal 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")
|
Loading…
x
Reference in New Issue
Block a user