mirror of
				https://github.com/paperless-ngx/paperless-ngx.git
				synced 2025-10-30 03:56:23 -05:00 
			
		
		
		
	Feature: Enhanced templating for filename format (#7836)
Co-authored-by: shamoon <4887959+shamoon@users.noreply.github.com>
This commit is contained in:
		
							
								
								
									
										1
									
								
								Pipfile
									
									
									
									
									
								
							
							
						
						
									
										1
									
								
								Pipfile
									
									
									
									
									
								
							| @@ -57,6 +57,7 @@ watchdog = "~=4.0" | |||||||
| whitenoise = "~=6.7" | whitenoise = "~=6.7" | ||||||
| whoosh = "~=2.7" | whoosh = "~=2.7" | ||||||
| zxing-cpp = {version = "*", platform_machine = "== 'x86_64'"} | zxing-cpp = {version = "*", platform_machine = "== 'x86_64'"} | ||||||
|  | jinja2 = "~=3.1" | ||||||
|  |  | ||||||
| [dev-packages] | [dev-packages] | ||||||
| # Linting | # Linting | ||||||
|   | |||||||
							
								
								
									
										491
									
								
								Pipfile.lock
									
									
									
										generated
									
									
									
								
							
							
						
						
									
										491
									
								
								Pipfile.lock
									
									
									
										generated
									
									
									
								
							| @@ -1,7 +1,7 @@ | |||||||
| { | { | ||||||
|     "_meta": { |     "_meta": { | ||||||
|         "hash": { |         "hash": { | ||||||
|             "sha256": "1be8ddf875b6aa77fcf61f5c065c9dc3941cad4b9285ce64da60b5684357dade" |             "sha256": "1e113d0879e4e0bc3c384115057647ac8d9be05252dd7c708a1fc873f294ef28" | ||||||
|         }, |         }, | ||||||
|         "pipfile-spec": 6, |         "pipfile-spec": 6, | ||||||
|         "requires": {}, |         "requires": {}, | ||||||
| @@ -544,12 +544,12 @@ | |||||||
|         }, |         }, | ||||||
|         "django-soft-delete": { |         "django-soft-delete": { | ||||||
|             "hashes": [ |             "hashes": [ | ||||||
|                 "sha256:428df56ea4fbb13f42d4f752f11f2a517aa31ac3d1b450e6b78c4c5d5d9dfc3b", |                 "sha256:36cf26a9eaa5f4c0fdb5cb6367ea183e91b7f73783cad173e4071a4747dd1277", | ||||||
|                 "sha256:558821ea988fd69a3a7008cdb33a06ded491af828bdffa5b287fa0fb72b52a09" |                 "sha256:fc16c870020984b7f58254adead12fdfb637a6c2f4bd8a93a3a636b18b1463e0" | ||||||
|             ], |             ], | ||||||
|             "index": "pypi", |             "index": "pypi", | ||||||
|             "markers": "python_version >= '3.6'", |             "markers": "python_version >= '3.6'", | ||||||
|             "version": "==1.0.14" |             "version": "==1.0.15" | ||||||
|         }, |         }, | ||||||
|         "djangorestframework": { |         "djangorestframework": { | ||||||
|             "hashes": [ |             "hashes": [ | ||||||
| @@ -744,11 +744,11 @@ | |||||||
|         }, |         }, | ||||||
|         "httpcore": { |         "httpcore": { | ||||||
|             "hashes": [ |             "hashes": [ | ||||||
|                 "sha256:34a38e2f9291467ee3b44e89dd52615370e152954ba21721378a87b2960f7a61", |                 "sha256:27b59625743b85577a8c0e10e55b50b5368a4f2cfe8cc7bcfa9cf00829c2682f", | ||||||
|                 "sha256:421f18bac248b25d310f3cacd198d55b8e6125c107797b609ff9b7a6ba7991b5" |                 "sha256:73f6dbd6eb8c21bbf7ef8efad555481853f5f6acdeaff1edb0694289269ee17f" | ||||||
|             ], |             ], | ||||||
|             "markers": "python_version >= '3.8'", |             "markers": "python_version >= '3.8'", | ||||||
|             "version": "==1.0.5" |             "version": "==1.0.6" | ||||||
|         }, |         }, | ||||||
|         "httptools": { |         "httptools": { | ||||||
|             "hashes": [ |             "hashes": [ | ||||||
| @@ -828,11 +828,11 @@ | |||||||
|         }, |         }, | ||||||
|         "imap-tools": { |         "imap-tools": { | ||||||
|             "hashes": [ |             "hashes": [ | ||||||
|                 "sha256:218ea6495d73275ecc2fa4a34717c137bacf2c4a3d34c9d10a9581a6af1ac94f", |                 "sha256:bd84d0f40fbd7be27f6ff5c3908e74d96e99d6b5f44f19cd6e928d308c811916", | ||||||
|                 "sha256:4c31e9df1d28149436a86871cf84a0b37221a91521fc1a57897e0a152ee3f6d1" |                 "sha256:e657df2f62c1b263c0fd1610cfcd9f8cde26de6b696ae25c401ba75d91a5fd93" | ||||||
|             ], |             ], | ||||||
|             "index": "pypi", |             "index": "pypi", | ||||||
|             "version": "==1.7.2" |             "version": "==1.7.3" | ||||||
|         }, |         }, | ||||||
|         "img2pdf": { |         "img2pdf": { | ||||||
|             "hashes": [ |             "hashes": [ | ||||||
| @@ -844,7 +844,7 @@ | |||||||
|             "hashes": [ |             "hashes": [ | ||||||
|                 "sha256:8440ffe49c4ae81a8df57c1ae1eb4b6bfa7acb830099bfb3e305b383005cc128" |                 "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" |             "version": "==1.3.5" | ||||||
|         }, |         }, | ||||||
|         "inotifyrecursive": { |         "inotifyrecursive": { | ||||||
| @@ -853,9 +853,18 @@ | |||||||
|                 "sha256:a2c450b317693e4538416f90eb1d7858506dafe6b8b885037bd2dd9ae2dafa1e" |                 "sha256:a2c450b317693e4538416f90eb1d7858506dafe6b8b885037bd2dd9ae2dafa1e" | ||||||
|             ], |             ], | ||||||
|             "index": "pypi", |             "index": "pypi", | ||||||
|             "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", |             "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1'", | ||||||
|             "version": "==0.3.5" |             "version": "==0.3.5" | ||||||
|         }, |         }, | ||||||
|  |         "jinja2": { | ||||||
|  |             "hashes": [ | ||||||
|  |                 "sha256:4a3aee7acbbe7303aede8e9648d13b8bf88a429282aa6122a993f0ac800cb369", | ||||||
|  |                 "sha256:bc5dd2abb727a5319567b7a813e6a2e7318c39f4f487cfe6c89c6f9c7d25197d" | ||||||
|  |             ], | ||||||
|  |             "index": "pypi", | ||||||
|  |             "markers": "python_version >= '3.7'", | ||||||
|  |             "version": "==3.1.4" | ||||||
|  |         }, | ||||||
|         "joblib": { |         "joblib": { | ||||||
|             "hashes": [ |             "hashes": [ | ||||||
|                 "sha256:06d478d5674cbc267e7496a410ee875abd68e4340feff4490bcb7afb88060ae6", |                 "sha256:06d478d5674cbc267e7496a410ee875abd68e4340feff4490bcb7afb88060ae6", | ||||||
| @@ -1032,6 +1041,72 @@ | |||||||
|             "markers": "python_version >= '3.8'", |             "markers": "python_version >= '3.8'", | ||||||
|             "version": "==3.0.0" |             "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": { |         "mdurl": { | ||||||
|             "hashes": [ |             "hashes": [ | ||||||
|                 "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", |                 "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", | ||||||
| @@ -1304,54 +1379,49 @@ | |||||||
|         }, |         }, | ||||||
|         "pikepdf": { |         "pikepdf": { | ||||||
|             "hashes": [ |             "hashes": [ | ||||||
|                 "sha256:01be001988ce0f6a5a89319f37fc14f27df75c4e332222ed8e993d14405acb02", |                 "sha256:08d0c72ba70cbe9f45772168e0c922b8d7625899cbfbcbd0dfd1316acff90258", | ||||||
|                 "sha256:0759842e47369fe5fa0d61de2ac9ff073895c75567f3efbc4aebc6c1cafee17e", |                 "sha256:0da5ebba4a31e257ca86a93657a4d47afffeda2ee48cde25227ce43d6dabae13", | ||||||
|                 "sha256:127e94632eb1ccd5d4d859511f084a0a314555cba621595a135915fc9e1710c5", |                 "sha256:0f74ba40a3c6f450d19b0958df5c92f84965f4160fd973d4a00f00492093f01c", | ||||||
|                 "sha256:163600dcd8d158e9287934b65a516b469b153859ab029e40fb3a0eff16c7dd7a", |                 "sha256:180e7423f3b517688cf14d6c5537e97a1a9b047421915bb28d3198f881b46f14", | ||||||
|                 "sha256:1dd707e6159af953f5560138f695b3a1ae2e1a0750535be70a3b75a720279330", |                 "sha256:18e48cc0359f29b5083bad94237b53d928d8491f7ba5d4a389ca5c366226d766", | ||||||
|                 "sha256:1e6b3083ef2e3c29af33fcdb73a9a61a8e4dbe540edb474c19b9866194c6bf25", |                 "sha256:287206055d2543ee768f85c24146e267c2465c1b2024e37ccf80b5a16674d2a2", | ||||||
|                 "sha256:3c7e5c3a425de7db1fc13583883d2fa10119ce85071cc1d53344383498739254", |                 "sha256:344602b23ae6852180587c8e3280719ac31c78a4ca6cf08d8a51467d5f1741ba", | ||||||
|                 "sha256:3efff6ffda819d4193dd8e63c6f304bf85f9ae961c0247dc0b716b7c74fb7094", |                 "sha256:363d01aa89f871c12fdc3d08c677456d693028cfb865e314cebe679273a7ebcb", | ||||||
|                 "sha256:4a5c5ccccb5812a5be5b5cb66c8c8a6f796910ab89932a3048a4e66e5436bd01", |                 "sha256:38b3f882351d17f65d38d43d24772cfe471b63dc8c09dad52434c4fe02693e33", | ||||||
|                 "sha256:4b9e9416da42da43f386244b2bab2a236830ccb11598b73fcd43d32fd234aaff", |                 "sha256:3afa0ea7b57a125a7744313b08062e59ecca15b2b3b31d13431244ec99b4d683", | ||||||
|                 "sha256:4c8bf24b8bf933f4022c6ace5ee757453e3dacb806a8e826461fd5f33ce15a70", |                 "sha256:3ffc14ad4172f7acd7c1c7eb22eeac66f92c93c83941c63a3b56961602af67d7", | ||||||
|                 "sha256:531b6685912eb630a7fe57c527c9b5636c50c543eb0cdb5807b139e0d7712696", |                 "sha256:40724cb905ce682c97f048e4eb3a728eade6dd1bc64425f3b7bb9872688964ea", | ||||||
|                 "sha256:5e31aeb15ab21ba340a9013c1665e7ce85bd1f8167e6710c455d51f82c2e64e0", |                 "sha256:4a56b7ccf13817689adb977ba92efa8d567d42a307154acff156179ddb76668b", | ||||||
|                 "sha256:61bb9dfe58ee3ee2a286ea4cd21af87e1853a2d1433b550e3f58faa005b6ea3a", |                 "sha256:53202d816838e87ee80c28af695b554e3cbfd5cb3598d7bcfba533f9dbd411e9", | ||||||
|                 "sha256:6275467b7eacb6fb04f16727e90e6562c6bbf449ece4e57273956beb8f1cdacd", |                 "sha256:58e256aec46ee13256e264bae949e23a98707833fc27a3e3c7172c034d0ab870", | ||||||
|                 "sha256:6e15689fd715e83ff555cbdb939a0453c6c94af9975ae9b3292dd68231014653", |                 "sha256:5eef37caae6ad7a4baa4a6cdb35690945ee1a83bc0da5bbbf0023bc27d113f9c", | ||||||
|                 "sha256:755f559c206de5b3de0e35430ad28e50f37866d96a41b3ad41d7114660e1c58b", |                 "sha256:663ddb129d823f9e1d1e5b4118906c508b801bf1d86fd8583938f96588bf8dda", | ||||||
|                 "sha256:7fa15e5ff3e17dc6295d676d673787c79fec67cca59261a22ccf7604914170b1", |                 "sha256:689fcd1e89857ddc31191d4cc7a1fab2dbb5ce88c347f4de0db41abb176a11fb", | ||||||
|                 "sha256:8a50c58bee394f69561ab2861f77ce763f91cf7af6c8a1919109bb33fe8ca669", |                 "sha256:6b905b05fc32c4e279aceb1578d7d917ed9a4e70a8a8e8d1b40ee8afff9d6bfc", | ||||||
|                 "sha256:9699fe058b44e59cdcd05bcadf9cfa8f5242b48e44f9a4772bb321cd74d8e339", |                 "sha256:7a9a738186b07a1177369713e8003371d0393808e5a62b2af86751dad6684a92", | ||||||
|                 "sha256:96ea92374d25481a2213403ae06c990ea41a1f35b0404dd072b7070dac76f41b", |                 "sha256:7a9feafdb688e64e4017b4596c3cf90793cd658b53e915e6c5a2668d1b3eb0c9", | ||||||
|                 "sha256:98ff348c97c7c641c2d2b741d60c8edf22e0fe76fa5c386cb351a3abd3f2a9b9", |                 "sha256:7ac65c0ace97d995dc7263d2912208ac5310c2f84f42f1fdf043b47d77c01852", | ||||||
|                 "sha256:a32ef219737e53b48754acb45ad7840aee8403d97fc79539c26501a2d9089c91", |                 "sha256:7dd4166bb14db7d0711f2a32b21cf479217e34828af435b7ece0fab6ea02664d", | ||||||
|                 "sha256:aefa94f8ea6371fc3cbf78f55f669efec6e28e317927e8dd8a237e19a7be50fb", |                 "sha256:8022a925cb2c67a1de3736c19de5d280d43241e1b118f1188b94df07e84c8b8f", | ||||||
|                 "sha256:baaf78ed49e3cecfc4d30f2c7291d9b19bebe8a5f8e5940d7e7c93683b47a6f9", |                 "sha256:80630a897d4203be10861e4e7fca8774cf1a85a1abcc41f978984564fb729ef6", | ||||||
|                 "sha256:c1b883e1ebe28fbc318ce5c971b3dca9b30621bc2fe1642c99cda76cf442c4a2", |                 "sha256:8422a3944187a8d24626812044b6b09c865426e2bf8d0b2ead80f56f609b3345", | ||||||
|                 "sha256:c2c21c6a3d7ec96c7f9627ad61195eadff12659e3e00abe7156c34503189db47", |                 "sha256:84555d4039ea10935fa2d0084577de5b81b508b9716ce482163e2dc65db1b180", | ||||||
|                 "sha256:c4eb22efae62b057a31ee4cb5574db8edfe15b185c8e89500eca8157fda15974", |                 "sha256:8dbab43c6a6fa2737df6cfccd049bbe5b762c39809a0b14484d0154f403be4fb", | ||||||
|                 "sha256:c6ea5f623629478abaf1e25b1d0edcaee3d0408fd9061fb4f7dc24fb78a25302", |                 "sha256:8f1153d3f7be818ba0f9f0875f37ed5203c3d500c33a4058a4d2d0f978d3ce29", | ||||||
|                 "sha256:cd73d828799e41ee778606e30efd0c27be1e2420b1ed0c9cbc39299872ceed76", |                 "sha256:906d8afc1aa4f2f7409381a58e158207170f3aeba8ad2aec40072a648e8a2914", | ||||||
|                 "sha256:ceeac42bfb7227310e617e871d8f7ae6f304cf2783ca0131f3063c54ee1ecb73", |                 "sha256:98e546120b0d5707836a5ced43b09c086f5866f6eed93cfe4a0555c987fcba6f", | ||||||
|                 "sha256:d1a1314e4c4b2a28a1af1e700570b3c32c074cf363425768e8bc9f031438aee3", |                 "sha256:9e5bb5e40394d6a15c494469be5026c063676918cbabf48345c7fdf8b2f776f5", | ||||||
|                 "sha256:d209e4a9ba99a4460cf987f6cd8703a8723d8a62fc51451c4c1233eff07db02f", |                 "sha256:a09688758168a86585bb0baeae0a704349285ef40a02da8739be4ad8f4b1aee7", | ||||||
|                 "sha256:d360e64c31f73b16b78ca1e10e9d96f758b4a3fac195cd35f88a5f213808852e", |                 "sha256:cd796a039cbaddb6106127f210d5f2160654c0e629c1b663f2d9e6f67bba96b8", | ||||||
|                 "sha256:d37ce8a4ade0cddf3827e13867208ffc8c161d38fdb12250b31e1b8cfa58ab1b", |                 "sha256:d0d6b11da16d280f83c5406ae0db03521e613c7758212b9104bad3dbf9bf2098", | ||||||
|                 "sha256:d6f240b0c1da5b6656efa3daa087394ddce5b3ecc411b85efcfd7e7228a1bc26", |                 "sha256:d96804a7e26e2ff37a9c2d796042754b7cae0668ed118a9185169fe1fc3b18d6", | ||||||
|                 "sha256:d9ba6c639faac47a85817854d002e2f57683ffe65388a746af580c4a6521646c", |                 "sha256:dbe7d9930789ea56e8b38b3b6b2b0b4e1090509825ceb572b906a1d23dea0282", | ||||||
|                 "sha256:e199833ef11a64f22945a9a98d56a98968e988e407cb20d9fa8b6081075c9604", |                 "sha256:e6bb3466f92b7a741a58fe348285d7bec69ea6102bbe3b2a3f49af0e6f2f3327", | ||||||
|                 "sha256:e1e47e80ecfd77dbfc6c7e807e78e5cce0c10d5bd7804c0d9064429d72af981c", |                 "sha256:ecb8ab93305f07f806399101858ab9ff350c3e1de819d6043b5d54220cf81e71", | ||||||
|                 "sha256:e863185d6abadab140a7c3e152d9227afe495cf97d4738efc280896660249180", |                 "sha256:f54ad2d6d3e4c564bf1f9c33e4165b4c36aea62c49654f356a5570f99b89c647" | ||||||
|                 "sha256:eb65a84fff25295707250b49f9e2d1186e9f6b4b7f828a0d9e7e2b65a7af6311", |  | ||||||
|                 "sha256:f2e4d5632dc03a41d901e4feee474557145c4906d96cf6e7ae8106a85142d2eb", |  | ||||||
|                 "sha256:f3ecbc250254b61de2ca973e3d57acb07720e5a810ee0c81d33b051c76d22208", |  | ||||||
|                 "sha256:f6b1ee86850fddaea15afdde394109332f7dc63a156e52fb131f9b647b16f920", |  | ||||||
|                 "sha256:fc0deac6dd356ef95fcf42db917cfe2c5375640295609924d4825052c2124509" |  | ||||||
|             ], |             ], | ||||||
|             "markers": "python_version >= '3.8'", |             "markers": "python_version >= '3.9'", | ||||||
|             "version": "==9.2.1" |             "version": "==9.3.0" | ||||||
|         }, |         }, | ||||||
|         "pillow": { |         "pillow": { | ||||||
|             "hashes": [ |             "hashes": [ | ||||||
| @@ -1520,7 +1590,7 @@ | |||||||
|                 "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427" |                 "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427" | ||||||
|             ], |             ], | ||||||
|             "index": "pypi", |             "index": "pypi", | ||||||
|             "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", |             "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2'", | ||||||
|             "version": "==2.9.0.post0" |             "version": "==2.9.0.post0" | ||||||
|         }, |         }, | ||||||
|         "python-dotenv": { |         "python-dotenv": { | ||||||
| @@ -1732,108 +1802,17 @@ | |||||||
|                 "hiredis" |                 "hiredis" | ||||||
|             ], |             ], | ||||||
|             "hashes": [ |             "hashes": [ | ||||||
|                 "sha256:b756df1e4a3858fcc0ef861f3fc53623a96c41e2b1f5304e09e0fe758d333d40", |                 "sha256:f6c997521fedbae53387307c5d0bf784d9acc28d9f1d058abeac566ec4dbed72", | ||||||
|                 "sha256:fd4fccba0d7f6aa48c58a78d76ddb4afc698f5da4a2c1d03d916e4fd7ab88cdd" |                 "sha256:f8ea06b7482a668c6475ae202ed8d9bcaa409f6e87fb77ed1043d912afd62e24" | ||||||
|             ], |             ], | ||||||
|             "markers": "python_version >= '3.8'", |             "markers": "python_version >= '3.8'", | ||||||
|             "version": "==5.1.0" |             "version": "==5.1.1" | ||||||
|         }, |         }, | ||||||
|         "regex": { |         "regex": { | ||||||
|             "hashes": [ |             "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:5ddcd9a179c0a6fa8add279a4444015acddcd7f232a49071ae57fa6e278f1f71", | ||||||
|                 "sha256:6113c008a7780792efc80f9dfe10ba0cd043cbf8dc9a76ef757850f51b4edc50", |  | ||||||
|                 "sha256:635a1d96665f84b292e401c3d62775851aedc31d4f8784117b3c68c4fcd4118d", |  | ||||||
|                 "sha256:64ce2799bd75039b480cc0360907c4fb2f50022f030bf9e7a8705b636e408fad", |  | ||||||
|                 "sha256:69dee6a020693d12a3cf892aba4808fe168d2a4cef368eb9bf74f5398bfd4ee8", |  | ||||||
|                 "sha256:6a2644a93da36c784e546de579ec1806bfd2763ef47babc1b03d765fe560c9f8", |  | ||||||
|                 "sha256:6b41e1adc61fa347662b09398e31ad446afadff932a24807d3ceb955ed865cc8", |  | ||||||
|                 "sha256:6c188c307e8433bcb63dc1915022deb553b4203a70722fc542c363bf120a01fd", |                 "sha256:6c188c307e8433bcb63dc1915022deb553b4203a70722fc542c363bf120a01fd", | ||||||
|                 "sha256:6edd623bae6a737f10ce853ea076f56f507fd7726bee96a41ee3d68d347e4d16", |                 "sha256:ced479f601cd2f8ca1fd7b23925a7e0ad512a56d6e9476f79b8f381d9d37090a" | ||||||
|                 "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" |  | ||||||
|             ], |             ], | ||||||
|             "markers": "python_version >= '3.8'", |             "markers": "python_version >= '3.8'", | ||||||
|             "version": "==2024.9.11" |             "version": "==2024.9.11" | ||||||
| @@ -1843,7 +1822,6 @@ | |||||||
|                 "sha256:55365417734eb18255590a9ff9eb97e9e1da868d4ccd6402399eaf68af20a760", |                 "sha256:55365417734eb18255590a9ff9eb97e9e1da868d4ccd6402399eaf68af20a760", | ||||||
|                 "sha256:70761cfe03c773ceb22aa2f671b4757976145175cdfca038c02654d061d6dcc6" |                 "sha256:70761cfe03c773ceb22aa2f671b4757976145175cdfca038c02654d061d6dcc6" | ||||||
|             ], |             ], | ||||||
|             "markers": "python_version >= '3.8'", |  | ||||||
|             "version": "==2.32.3" |             "version": "==2.32.3" | ||||||
|         }, |         }, | ||||||
|         "requests-oauthlib": { |         "requests-oauthlib": { | ||||||
| @@ -1855,15 +1833,16 @@ | |||||||
|         }, |         }, | ||||||
|         "rich": { |         "rich": { | ||||||
|             "hashes": [ |             "hashes": [ | ||||||
|                 "sha256:1760a3c0848469b97b558fc61c85233e3dafb69c7a071b4d60c38099d3cd4c06", |                 "sha256:51a2c62057461aaf7152b4d611168f93a9fc73068f8ded2790f29fe2b5366d0c", | ||||||
|                 "sha256:8260cda28e3db6bf04d2d1ef4dbc03ba80a824c88b0e7668a0f23126a424844a" |                 "sha256:8c82a3d3f8dcfe9e734771313e606b39d8247bb6b826e196f4914b333b743cf1" | ||||||
|             ], |             ], | ||||||
|             "markers": "python_full_version >= '3.7.0'", |             "markers": "python_full_version >= '3.8.0'", | ||||||
|             "version": "==13.8.1" |             "version": "==13.9.2" | ||||||
|         }, |         }, | ||||||
|         "scikit-learn": { |         "scikit-learn": { | ||||||
|             "hashes": [ |             "hashes": [ | ||||||
|                 "sha256:03b6158efa3faaf1feea3faa884c840ebd61b6484167c711548fce208ea09445", |                 "sha256:03b6158efa3faaf1feea3faa884c840ebd61b6484167c711548fce208ea09445", | ||||||
|  |                 "sha256:178ddd0a5cb0044464fc1bfc4cca5b1833bfc7bb022d70b05db8530da4bb3dd3", | ||||||
|                 "sha256:1ff45e26928d3b4eb767a8f14a9a6efbf1cbff7c05d1fb0f95f211a89fd4f5de", |                 "sha256:1ff45e26928d3b4eb767a8f14a9a6efbf1cbff7c05d1fb0f95f211a89fd4f5de", | ||||||
|                 "sha256:299406827fb9a4f862626d0fe6c122f5f87f8910b86fe5daa4c32dcd742139b6", |                 "sha256:299406827fb9a4f862626d0fe6c122f5f87f8910b86fe5daa4c32dcd742139b6", | ||||||
|                 "sha256:2d4cad1119c77930b235579ad0dc25e65c917e756fe80cab96aa3b9428bd3fb0", |                 "sha256:2d4cad1119c77930b235579ad0dc25e65c917e756fe80cab96aa3b9428bd3fb0", | ||||||
| @@ -1877,10 +1856,14 @@ | |||||||
|                 "sha256:6c16d84a0d45e4894832b3c4d0bf73050939e21b99b01b6fd59cbb0cf39163b6", |                 "sha256:6c16d84a0d45e4894832b3c4d0bf73050939e21b99b01b6fd59cbb0cf39163b6", | ||||||
|                 "sha256:757c7d514ddb00ae249832fe87100d9c73c6ea91423802872d9e74970a0e40b9", |                 "sha256:757c7d514ddb00ae249832fe87100d9c73c6ea91423802872d9e74970a0e40b9", | ||||||
|                 "sha256:8c412ccc2ad9bf3755915e3908e677b367ebc8d010acbb3f182814524f2e5540", |                 "sha256:8c412ccc2ad9bf3755915e3908e677b367ebc8d010acbb3f182814524f2e5540", | ||||||
|  |                 "sha256:b0768ad641981f5d3a198430a1d31c3e044ed2e8a6f22166b4d546a5116d7908", | ||||||
|                 "sha256:b4237ed7b3fdd0a4882792e68ef2545d5baa50aca3bb45aa7df468138ad8f94d", |                 "sha256:b4237ed7b3fdd0a4882792e68ef2545d5baa50aca3bb45aa7df468138ad8f94d", | ||||||
|  |                 "sha256:b7b0f9a0b1040830d38c39b91b3a44e1b643f4b36e36567b80b7c6bd2202a27f", | ||||||
|                 "sha256:c15b1ca23d7c5f33cc2cb0a0d6aaacf893792271cddff0edbd6a40e8319bc113", |                 "sha256:c15b1ca23d7c5f33cc2cb0a0d6aaacf893792271cddff0edbd6a40e8319bc113", | ||||||
|                 "sha256:ca64b3089a6d9b9363cd3546f8978229dcbb737aceb2c12144ee3f70f95684b7", |                 "sha256:ca64b3089a6d9b9363cd3546f8978229dcbb737aceb2c12144ee3f70f95684b7", | ||||||
|  |                 "sha256:e9a702e2de732bbb20d3bad29ebd77fc05a6b427dc49964300340e4c9328b3f5", | ||||||
|                 "sha256:f60021ec1574e56632be2a36b946f8143bf4e5e6af4a06d85281adc22938e0dd", |                 "sha256:f60021ec1574e56632be2a36b946f8143bf4e5e6af4a06d85281adc22938e0dd", | ||||||
|  |                 "sha256:f7284ade780084d94505632241bf78c44ab3b6f1e8ccab3d2af58e0e950f9c12", | ||||||
|                 "sha256:f763897fe92d0e903aa4847b0aec0e68cadfff77e8a0687cabd946c89d17e675", |                 "sha256:f763897fe92d0e903aa4847b0aec0e68cadfff77e8a0687cabd946c89d17e675", | ||||||
|                 "sha256:f8b0ccd4a902836493e026c03256e8b206656f91fbcc4fde28c57a5b752561f1", |                 "sha256:f8b0ccd4a902836493e026c03256e8b206656f91fbcc4fde28c57a5b752561f1", | ||||||
|                 "sha256:f932a02c3f4956dfb981391ab24bda1dbd90fe3d628e4b42caef3e041c67707a" |                 "sha256:f932a02c3f4956dfb981391ab24bda1dbd90fe3d628e4b42caef3e041c67707a" | ||||||
| @@ -2028,7 +2011,7 @@ | |||||||
|                 "sha256:1e61c37477a1626458e36f7b1d82aa5c9b094fa4802892072e49de9c60c4c926", |                 "sha256:1e61c37477a1626458e36f7b1d82aa5c9b094fa4802892072e49de9c60c4c926", | ||||||
|                 "sha256:8abb2f1d86890a2dfb989f9a77cfcfd3e47c2a354b01111771326f8aa26e0254" |                 "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" |             "version": "==1.16.0" | ||||||
|         }, |         }, | ||||||
|         "sniffio": { |         "sniffio": { | ||||||
| @@ -3057,11 +3040,11 @@ | |||||||
|         }, |         }, | ||||||
|         "faker": { |         "faker": { | ||||||
|             "hashes": [ |             "hashes": [ | ||||||
|                 "sha256:bf0207af5777950054a2a3b43f4b5bdc33b585918d2b28f1dab52ac0ffe2bac0", |                 "sha256:dbf81295c948270a9e96cd48a9a3ebec73acac9a153d0c854fbbd0294557609f", | ||||||
|                 "sha256:f0a60009150736c1c033bea31aa19ae63071c9dcf10adfaf9f1a87a3add84bc8" |                 "sha256:e0593931bd7be9a9ea984b5d8c302ef1cec19392585d1e90d444199271d0a94d" | ||||||
|             ], |             ], | ||||||
|             "markers": "python_version >= '3.8'", |             "markers": "python_version >= '3.8'", | ||||||
|             "version": "==30.0.0" |             "version": "==30.1.0" | ||||||
|         }, |         }, | ||||||
|         "filelock": { |         "filelock": { | ||||||
|             "hashes": [ |             "hashes": [ | ||||||
| @@ -3089,11 +3072,11 @@ | |||||||
|         }, |         }, | ||||||
|         "httpcore": { |         "httpcore": { | ||||||
|             "hashes": [ |             "hashes": [ | ||||||
|                 "sha256:34a38e2f9291467ee3b44e89dd52615370e152954ba21721378a87b2960f7a61", |                 "sha256:27b59625743b85577a8c0e10e55b50b5368a4f2cfe8cc7bcfa9cf00829c2682f", | ||||||
|                 "sha256:421f18bac248b25d310f3cacd198d55b8e6125c107797b609ff9b7a6ba7991b5" |                 "sha256:73f6dbd6eb8c21bbf7ef8efad555481853f5f6acdeaff1edb0694289269ee17f" | ||||||
|             ], |             ], | ||||||
|             "markers": "python_version >= '3.8'", |             "markers": "python_version >= '3.8'", | ||||||
|             "version": "==1.0.5" |             "version": "==1.0.6" | ||||||
|         }, |         }, | ||||||
|         "httpx": { |         "httpx": { | ||||||
|             "extras": [ |             "extras": [ | ||||||
| @@ -3158,6 +3141,7 @@ | |||||||
|                 "sha256:4a3aee7acbbe7303aede8e9648d13b8bf88a429282aa6122a993f0ac800cb369", |                 "sha256:4a3aee7acbbe7303aede8e9648d13b8bf88a429282aa6122a993f0ac800cb369", | ||||||
|                 "sha256:bc5dd2abb727a5319567b7a813e6a2e7318c39f4f487cfe6c89c6f9c7d25197d" |                 "sha256:bc5dd2abb727a5319567b7a813e6a2e7318c39f4f487cfe6c89c6f9c7d25197d" | ||||||
|             ], |             ], | ||||||
|  |             "index": "pypi", | ||||||
|             "markers": "python_version >= '3.7'", |             "markers": "python_version >= '3.7'", | ||||||
|             "version": "==3.1.4" |             "version": "==3.1.4" | ||||||
|         }, |         }, | ||||||
| @@ -3519,11 +3503,11 @@ | |||||||
|         }, |         }, | ||||||
|         "pymdown-extensions": { |         "pymdown-extensions": { | ||||||
|             "hashes": [ |             "hashes": [ | ||||||
|                 "sha256:2653fb658bca5f278029f8c67a67f0f08b7bd3c657e2630d261ad542e97c4192", |                 "sha256:41cdde0a77290e480cf53892f5c5e50921a7ee3e5cd60ba91bf19837b33badcf", | ||||||
|                 "sha256:e68080eac44634406b31f4aec58fbad17b0ec5fca6b086e29008616d54c3906b" |                 "sha256:bc8847ecc9e784a098efd35e20cba772bc5a1b529dfcef9dc1972db9021a1049" | ||||||
|             ], |             ], | ||||||
|             "markers": "python_version >= '3.8'", |             "markers": "python_version >= '3.8'", | ||||||
|             "version": "==10.11" |             "version": "==10.11.2" | ||||||
|         }, |         }, | ||||||
|         "pyopenssl": { |         "pyopenssl": { | ||||||
|             "hashes": [ |             "hashes": [ | ||||||
| @@ -3618,7 +3602,7 @@ | |||||||
|                 "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427" |                 "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427" | ||||||
|             ], |             ], | ||||||
|             "index": "pypi", |             "index": "pypi", | ||||||
|             "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", |             "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2'", | ||||||
|             "version": "==2.9.0.post0" |             "version": "==2.9.0.post0" | ||||||
|         }, |         }, | ||||||
|         "pywavelets": { |         "pywavelets": { | ||||||
| @@ -3731,100 +3715,9 @@ | |||||||
|         }, |         }, | ||||||
|         "regex": { |         "regex": { | ||||||
|             "hashes": [ |             "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:5ddcd9a179c0a6fa8add279a4444015acddcd7f232a49071ae57fa6e278f1f71", | ||||||
|                 "sha256:6113c008a7780792efc80f9dfe10ba0cd043cbf8dc9a76ef757850f51b4edc50", |  | ||||||
|                 "sha256:635a1d96665f84b292e401c3d62775851aedc31d4f8784117b3c68c4fcd4118d", |  | ||||||
|                 "sha256:64ce2799bd75039b480cc0360907c4fb2f50022f030bf9e7a8705b636e408fad", |  | ||||||
|                 "sha256:69dee6a020693d12a3cf892aba4808fe168d2a4cef368eb9bf74f5398bfd4ee8", |  | ||||||
|                 "sha256:6a2644a93da36c784e546de579ec1806bfd2763ef47babc1b03d765fe560c9f8", |  | ||||||
|                 "sha256:6b41e1adc61fa347662b09398e31ad446afadff932a24807d3ceb955ed865cc8", |  | ||||||
|                 "sha256:6c188c307e8433bcb63dc1915022deb553b4203a70722fc542c363bf120a01fd", |                 "sha256:6c188c307e8433bcb63dc1915022deb553b4203a70722fc542c363bf120a01fd", | ||||||
|                 "sha256:6edd623bae6a737f10ce853ea076f56f507fd7726bee96a41ee3d68d347e4d16", |                 "sha256:ced479f601cd2f8ca1fd7b23925a7e0ad512a56d6e9476f79b8f381d9d37090a" | ||||||
|                 "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" |  | ||||||
|             ], |             ], | ||||||
|             "markers": "python_version >= '3.8'", |             "markers": "python_version >= '3.8'", | ||||||
|             "version": "==2024.9.11" |             "version": "==2024.9.11" | ||||||
| @@ -3834,33 +3727,32 @@ | |||||||
|                 "sha256:55365417734eb18255590a9ff9eb97e9e1da868d4ccd6402399eaf68af20a760", |                 "sha256:55365417734eb18255590a9ff9eb97e9e1da868d4ccd6402399eaf68af20a760", | ||||||
|                 "sha256:70761cfe03c773ceb22aa2f671b4757976145175cdfca038c02654d061d6dcc6" |                 "sha256:70761cfe03c773ceb22aa2f671b4757976145175cdfca038c02654d061d6dcc6" | ||||||
|             ], |             ], | ||||||
|             "markers": "python_version >= '3.8'", |  | ||||||
|             "version": "==2.32.3" |             "version": "==2.32.3" | ||||||
|         }, |         }, | ||||||
|         "ruff": { |         "ruff": { | ||||||
|             "hashes": [ |             "hashes": [ | ||||||
|                 "sha256:007dee844738c3d2e6c24ab5bc7d43c99ba3e1943bd2d95d598582e9c1b27750", |                 "sha256:064df58d84ccc0ac0fcd63bc3090b251d90e2a372558c0f057c3f75ed73e1ccd", | ||||||
|                 "sha256:1085c455d1b3fdb8021ad534379c60353b81ba079712bce7a900e834859182fa", |                 "sha256:12edd2af0c60fa61ff31cefb90aef4288ac4d372b4962c2864aeea3a1a2460c0", | ||||||
|                 "sha256:27b87e1801e786cd6ede4ada3faa5e254ce774de835e6723fd94551464c56b8c", |                 "sha256:140d4b5c9f5fc7a7b074908a78ab8d384dd7f6510402267bc76c37195c02a7ec", | ||||||
|                 "sha256:5fd0d4b7b1457c49e435ee1e437900ced9b35cb8dc5178921dfb7d98d65a08d0", |                 "sha256:3c866b631f5fbce896a74a6e4383407ba7507b815ccc52bcedabb6810fdb3ef7", | ||||||
|                 "sha256:677e03c00f37c66cea033274295a983c7c546edea5043d0c798833adf4cf4c6f", |                 "sha256:3ef0cc774b00fec123f635ce5c547dac263f6ee9fb9cc83437c5904183b55ceb", | ||||||
|                 "sha256:6cfb227b932ba8ef6e56c9f875d987973cd5e35bc5d05f5abf045af78ad8e098", |                 "sha256:417b81aa1c9b60b2f8edc463c58363075412866ae4e2b9ab0f690dc1e87ac1b5", | ||||||
|                 "sha256:6ef0411eccfc3909269fed47c61ffebdcb84a04504bafa6b6df9b85c27e813b0", |                 "sha256:53fd8ca5e82bdee8da7f506d7b03a261f24cd43d090ea9db9a1dc59d9313914c", | ||||||
|                 "sha256:6f5a2f17c7d32991169195d52a04c95b256378bbf0de8cb98478351eb70d526f", |                 "sha256:55bb01caeaf3a60b2b2bba07308a02fca6ab56233302406ed5245180a05c5625", | ||||||
|                 "sha256:70edf6a93b19481affd287d696d9e311388d808671bc209fb8907b46a8c3af44", |                 "sha256:645d7d8761f915e48a00d4ecc3686969761df69fb561dd914a773c1a8266e14e", | ||||||
|                 "sha256:77944bca110ff0a43b768f05a529fecd0706aac7bcce36d7f1eeb4cbfca5f0f2", |                 "sha256:785d31851c1ae91f45b3d8fe23b8ae4b5170089021fbb42402d811135f0b7117", | ||||||
|                 "sha256:792213f7be25316f9b46b854df80a77e0da87ec66691e8f012f887b4a671ab5a", |                 "sha256:7b118afbb3202f5911486ad52da86d1d52305b59e7ef2031cea3425142b97d6f", | ||||||
|                 "sha256:8d3bb2e3fbb9875172119021a13eed38849e762499e3cfde9588e4b4d70968dc", |                 "sha256:7d5ccc9e58112441de8ad4b29dcb7a86dc25c5f770e3c06a9d57e0e5eba48829", | ||||||
|                 "sha256:9f1476236b3eacfacfc0f66aa9e6cd39f2a624cb73ea99189556015f27c0bdeb", |                 "sha256:925d26471fa24b0ce5a6cdfab1bb526fb4159952385f386bdcc643813d472039", | ||||||
|                 "sha256:a5bf44b1aa0adaf6d9d20f86162b34f7c593bfedabc51239953e446aefc8ce18", |                 "sha256:a67267654edc23c97335586774790cde402fb6bbdb3c2314f1fc087dee320bfa", | ||||||
|                 "sha256:cd48f945da2a6334f1793d7f701725a76ba93bf3d73c36f6b21fb04d5338dcf5", |                 "sha256:a9641e31476d601f83cd602608739a0840e348bda93fec9f1ee816f8b6798b93", | ||||||
|                 "sha256:ce60058d3cdd8490e5e5471ef086b3f1e90ab872b548814e35930e21d848c9ce", |                 "sha256:b076ef717a8e5bc819514ee1d602bbdca5b4420ae13a9cf61a0c0a4f53a2baa2", | ||||||
|                 "sha256:ec0517dc0f37cad14a5319ba7bba6e7e339d03fbf967a6d69b0907d61be7a263", |                 "sha256:eae02b700763e3847595b9d2891488989cac00214da7f845f4bcf2989007d577", | ||||||
|                 "sha256:f8034b19b993e9601f2ddf2c517451e17a6ab5cdb1c13fdff50c1442a7171d87" |                 "sha256:eb61ec9bdb2506cffd492e05ac40e5bc6284873aceb605503d8494180d6fc84d" | ||||||
|             ], |             ], | ||||||
|             "index": "pypi", |             "index": "pypi", | ||||||
|             "markers": "python_version >= '3.7'", |             "markers": "python_version >= '3.7'", | ||||||
|             "version": "==0.6.8" |             "version": "==0.6.9" | ||||||
|         }, |         }, | ||||||
|         "scipy": { |         "scipy": { | ||||||
|             "hashes": [ |             "hashes": [ | ||||||
| @@ -3921,7 +3813,7 @@ | |||||||
|                 "sha256:1e61c37477a1626458e36f7b1d82aa5c9b094fa4802892072e49de9c60c4c926", |                 "sha256:1e61c37477a1626458e36f7b1d82aa5c9b094fa4802892072e49de9c60c4c926", | ||||||
|                 "sha256:8abb2f1d86890a2dfb989f9a77cfcfd3e47c2a354b01111771326f8aa26e0254" |                 "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" |             "version": "==1.16.0" | ||||||
|         }, |         }, | ||||||
|         "sniffio": { |         "sniffio": { | ||||||
| @@ -3942,11 +3834,11 @@ | |||||||
|         }, |         }, | ||||||
|         "tomli": { |         "tomli": { | ||||||
|             "hashes": [ |             "hashes": [ | ||||||
|                 "sha256:939de3e7a6161af0c887ef91b7d41a53e7c5a1ca976325f429cb46ea9bc30ecc", |                 "sha256:2ebe24485c53d303f690b0ec092806a085f07af5a5aa1464f3931eec36caaa38", | ||||||
|                 "sha256:de526c12914f0c550d15924c62d72abc48d6fe7364aa87328337a31007fe8a4f" |                 "sha256:d46d457a85337051c36524bc5349dd91b1877838e2979ac5ced3e710ed8a60ed" | ||||||
|             ], |             ], | ||||||
|             "markers": "python_version < '3.11'", |             "markers": "python_version < '3.11'", | ||||||
|             "version": "==2.0.1" |             "version": "==2.0.2" | ||||||
|         }, |         }, | ||||||
|         "twisted": { |         "twisted": { | ||||||
|             "extras": [ |             "extras": [ | ||||||
| @@ -4412,7 +4304,6 @@ | |||||||
|                 "sha256:55365417734eb18255590a9ff9eb97e9e1da868d4ccd6402399eaf68af20a760", |                 "sha256:55365417734eb18255590a9ff9eb97e9e1da868d4ccd6402399eaf68af20a760", | ||||||
|                 "sha256:70761cfe03c773ceb22aa2f671b4757976145175cdfca038c02654d061d6dcc6" |                 "sha256:70761cfe03c773ceb22aa2f671b4757976145175cdfca038c02654d061d6dcc6" | ||||||
|             ], |             ], | ||||||
|             "markers": "python_version >= '3.8'", |  | ||||||
|             "version": "==2.32.3" |             "version": "==2.32.3" | ||||||
|         }, |         }, | ||||||
|         "sqlparse": { |         "sqlparse": { | ||||||
| @@ -4425,11 +4316,11 @@ | |||||||
|         }, |         }, | ||||||
|         "tomli": { |         "tomli": { | ||||||
|             "hashes": [ |             "hashes": [ | ||||||
|                 "sha256:939de3e7a6161af0c887ef91b7d41a53e7c5a1ca976325f429cb46ea9bc30ecc", |                 "sha256:2ebe24485c53d303f690b0ec092806a085f07af5a5aa1464f3931eec36caaa38", | ||||||
|                 "sha256:de526c12914f0c550d15924c62d72abc48d6fe7364aa87328337a31007fe8a4f" |                 "sha256:d46d457a85337051c36524bc5349dd91b1877838e2979ac5ced3e710ed8a60ed" | ||||||
|             ], |             ], | ||||||
|             "markers": "python_version < '3.11'", |             "markers": "python_version < '3.11'", | ||||||
|             "version": "==2.0.1" |             "version": "==2.0.2" | ||||||
|         }, |         }, | ||||||
|         "types-bleach": { |         "types-bleach": { | ||||||
|             "hashes": [ |             "hashes": [ | ||||||
| @@ -4468,11 +4359,11 @@ | |||||||
|         }, |         }, | ||||||
|         "types-docutils": { |         "types-docutils": { | ||||||
|             "hashes": [ |             "hashes": [ | ||||||
|                 "sha256:5dd2aa5e2e06fcfa090020bc4115479b4dd28da3329ab708563ee29894bd3c0d", |                 "sha256:0d2ea594576e8d05c4ad83165da64a511e538f6ab405ab8347cd6b636c59f934", | ||||||
|                 "sha256:9c8ed6d90583944af00f6b5fa3aecc2101e20672f6b1a4a299c6bf7d1e47084d" |                 "sha256:9816fb4f33067ed22d24c776a411a430bc19318b1af8f373e5581702a07bc4bc" | ||||||
|             ], |             ], | ||||||
|             "markers": "python_version >= '3.8'", |             "markers": "python_version >= '3.8'", | ||||||
|             "version": "==0.21.0.20240907" |             "version": "==0.21.0.20241004" | ||||||
|         }, |         }, | ||||||
|         "types-html5lib": { |         "types-html5lib": { | ||||||
|             "hashes": [ |             "hashes": [ | ||||||
| @@ -4519,12 +4410,12 @@ | |||||||
|         }, |         }, | ||||||
|         "types-python-dateutil": { |         "types-python-dateutil": { | ||||||
|             "hashes": [ |             "hashes": [ | ||||||
|                 "sha256:27c8cc2d058ccb14946eebcaaa503088f4f6dbc4fb6093d3d456a49aef2753f6", |                 "sha256:250e1d8e80e7bbc3a6c99b907762711d1a1cdd00e978ad39cb5940f6f0a87f3d", | ||||||
|                 "sha256:9706c3b68284c25adffc47319ecc7947e5bb86b3773f843c73906fd598bc176e" |                 "sha256:58cb85449b2a56d6684e41aeefb4c4280631246a0da1a719bdbe6f3fb0317446" | ||||||
|             ], |             ], | ||||||
|             "index": "pypi", |             "index": "pypi", | ||||||
|             "markers": "python_version >= '3.8'", |             "markers": "python_version >= '3.8'", | ||||||
|             "version": "==2.9.0.20240906" |             "version": "==2.9.0.20241003" | ||||||
|         }, |         }, | ||||||
|         "types-pyyaml": { |         "types-pyyaml": { | ||||||
|             "hashes": [ |             "hashes": [ | ||||||
| @@ -4536,12 +4427,12 @@ | |||||||
|         }, |         }, | ||||||
|         "types-redis": { |         "types-redis": { | ||||||
|             "hashes": [ |             "hashes": [ | ||||||
|                 "sha256:0e7537e5c085fe96b7d468d5edae0cf667b4ba4b62c6e4a5dfc340bd3b868c23", |                 "sha256:5f17d2b3f9091ab75384153bfa276619ffa1cf6a38da60e10d5e6749cc5b902e", | ||||||
|                 "sha256:4bab1a378dbf23c2c95c370dfdb89a8f033957c4fd1a53fee71b529c182fe008" |                 "sha256:ef5da68cb827e5f606c8f9c0b49eeee4c2669d6d97122f301d3a55dc6a63f6ed" | ||||||
|             ], |             ], | ||||||
|             "index": "pypi", |             "index": "pypi", | ||||||
|             "markers": "python_version >= '3.8'", |             "markers": "python_version >= '3.8'", | ||||||
|             "version": "==4.6.0.20240903" |             "version": "==4.6.0.20241004" | ||||||
|         }, |         }, | ||||||
|         "types-requests": { |         "types-requests": { | ||||||
|             "hashes": [ |             "hashes": [ | ||||||
|   | |||||||
| @@ -265,7 +265,7 @@ This variable allows you to configure the filename (folders are allowed) | |||||||
| using placeholders. For example, configuring this to | using placeholders. For example, configuring this to | ||||||
|  |  | ||||||
| ```bash | ```bash | ||||||
| PAPERLESS_FILENAME_FORMAT={created_year}/{correspondent}/{title} | PAPERLESS_FILENAME_FORMAT={{ created_year }}/{{ correspondent }}/{{ title }} | ||||||
| ``` | ``` | ||||||
|  |  | ||||||
| will create a directory structure as follows: | 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 |     when changing `PAPERLESS_FILENAME_FORMAT` you will need to manually run the | ||||||
|     [`document renamer`](administration.md#renamer) to move any existing documents. |     [`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". | - `{{ asn }}`: The archive serial number of the document, or "none". | ||||||
| - `{correspondent}`: The name of the correspondent, or "none". | - `{{ correspondent }}`: The name of the correspondent, or "none". | ||||||
| - `{document_type}`: The name of the document type, or "none". | - `{{ document_type }}`: The name of the document type, or "none". | ||||||
| - `{tag_list}`: A comma separated list of all tags assigned to the | - `{{ tag_list }}`: A comma separated list of all tags assigned to the | ||||||
|   document. |   document. | ||||||
| - `{title}`: The title of the document. | - `{{ title }}`: The title of the document. | ||||||
| - `{created}`: The full date (ISO format) the document was created. | - `{{ created }}`: The full date (ISO format) the document was created. | ||||||
| - `{created_year}`: Year created only, formatted as the year with | - `{{ created_year }}`: Year created only, formatted as the year with | ||||||
|   century. |   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. |   without century, zero padded. | ||||||
| - `{created_month}`: Month created only (number 01-12). | - `{{ created_month }}`: Month created only (number 01-12). | ||||||
| - `{created_month_name}`: Month created name, as per locale | - `{{ created_month_name }}`: Month created name, as per locale | ||||||
| - `{created_month_name_short}`: Month created abbreviated name, as per | - `{{ created_month_name_short }}`: Month created abbreviated name, as per | ||||||
|   locale |   locale | ||||||
| - `{created_day}`: Day created only (number 01-31). | - `{{ created_day }}`: Day created only (number 01-31). | ||||||
| - `{added}`: The full date (ISO format) the document was added to | - `{{ added }}`: The full date (ISO format) the document was added to | ||||||
|   paperless. |   paperless. | ||||||
| - `{added_year}`: Year added only. | - `{{ added_year }}`: Year added only. | ||||||
| - `{added_year_short}`: Year added only, formatted as the year without | - `{{ added_year_short }}`: Year added only, formatted as the year without | ||||||
|   century, zero padded. |   century, zero padded. | ||||||
| - `{added_month}`: Month added only (number 01-12). | - `{{ added_month }}`: Month added only (number 01-12). | ||||||
| - `{added_month_name}`: Month added name, as per locale | - `{{ added_month_name }}`: Month added name, as per locale | ||||||
| - `{added_month_name_short}`: Month added abbreviated name, as per | - `{{ added_month_name_short }}`: Month added abbreviated name, as per | ||||||
|   locale |   locale | ||||||
| - `{added_day}`: Day added only (number 01-31). | - `{{ added_day }}`: Day added only (number 01-31). | ||||||
| - `{owner_username}`: Username of document owner, if any, or "none" | - `{{ owner_username }}`: Username of document owner, if any, or "none" | ||||||
| - `{original_name}`: Document original filename, minus the extension, 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. | - `{{ doc_pk }}`: The paperless identifier (primary key) for the document. | ||||||
|  |  | ||||||
| !!! warning | !!! 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. |     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. |     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 | Paperless will try to conserve the information from your database as | ||||||
| much as possible. However, some characters that you can use in document | much as possible. However, some characters that you can use in document | ||||||
| titles and correspondent names (such as `: \ /` and a couple more) are | 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 |     However, keep in mind that inside docker, if files get stored outside of | ||||||
|     the predefined volumes, they will be lost after a restart. |     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 | You can affect how empty placeholders are treated by changing the | ||||||
| [`PAPERLESS_FILENAME_FORMAT_REMOVE_NONE`](configuration.md#PAPERLESS_FILENAME_FORMAT_REMOVE_NONE) setting. | [`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. |     the correspondence. | ||||||
|  |  | ||||||
| ``` | ``` | ||||||
| By Year = {created_year}/{correspondent}/{title} | By Year = {{ created_year }}/{{ correspondent }}/{{ title }} | ||||||
| Insurances = Insurances/{correspondent}/{created_year}-{created_month}-{created_day} {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 | 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 |     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. |     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} | ## 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 | 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> | ||||||
|         <context-group purpose="location"> |         <context-group purpose="location"> | ||||||
|           <context context-type="sourcefile">src/app/components/manage/management-list/management-list.component.ts</context> |           <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> | ||||||
|         <context-group purpose="location"> |         <context-group purpose="location"> | ||||||
|           <context context-type="sourcefile">src/app/components/manage/workflows/workflows.component.html</context> |           <context context-type="sourcefile">src/app/components/manage/workflows/workflows.component.html</context> | ||||||
| @@ -2193,11 +2193,11 @@ | |||||||
|         </context-group> |         </context-group> | ||||||
|         <context-group purpose="location"> |         <context-group purpose="location"> | ||||||
|           <context context-type="sourcefile">src/app/components/manage/management-list/management-list.component.ts</context> |           <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> | ||||||
|         <context-group purpose="location"> |         <context-group purpose="location"> | ||||||
|           <context context-type="sourcefile">src/app/components/manage/management-list/management-list.component.ts</context> |           <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> |         </context-group> | ||||||
|       </trans-unit> |       </trans-unit> | ||||||
|       <trans-unit id="1373208150912772963" datatype="html"> |       <trans-unit id="1373208150912772963" datatype="html"> | ||||||
| @@ -2239,7 +2239,7 @@ | |||||||
|         </context-group> |         </context-group> | ||||||
|         <context-group purpose="location"> |         <context-group purpose="location"> | ||||||
|           <context context-type="sourcefile">src/app/components/manage/management-list/management-list.component.ts</context> |           <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> | ||||||
|         <context-group purpose="location"> |         <context-group purpose="location"> | ||||||
|           <context context-type="sourcefile">src/app/components/manage/workflows/workflows.component.ts</context> |           <context context-type="sourcefile">src/app/components/manage/workflows/workflows.component.ts</context> | ||||||
| @@ -2594,7 +2594,7 @@ | |||||||
|         </context-group> |         </context-group> | ||||||
|         <context-group purpose="location"> |         <context-group purpose="location"> | ||||||
|           <context context-type="sourcefile">src/app/components/manage/management-list/management-list.component.ts</context> |           <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> | ||||||
|         <context-group purpose="location"> |         <context-group purpose="location"> | ||||||
|           <context context-type="sourcefile">src/app/components/manage/workflows/workflows.component.ts</context> |           <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="sourcefile">src/app/components/common/input/text/text.component.html</context> | ||||||
|           <context context-type="linenumber">9</context> |           <context context-type="linenumber">9</context> | ||||||
|         </context-group> |         </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-group purpose="location"> | ||||||
|           <context context-type="sourcefile">src/app/components/common/input/url/url.component.html</context> |           <context context-type="sourcefile">src/app/components/common/input/url/url.component.html</context> | ||||||
|           <context context-type="linenumber">7</context> |           <context context-type="linenumber">7</context> | ||||||
| @@ -7774,7 +7778,7 @@ | |||||||
|         </context-group> |         </context-group> | ||||||
|         <context-group purpose="location"> |         <context-group purpose="location"> | ||||||
|           <context context-type="sourcefile">src/app/components/manage/management-list/management-list.component.ts</context> |           <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> |         </context-group> | ||||||
|       </trans-unit> |       </trans-unit> | ||||||
|       <trans-unit id="4010735610815226758" datatype="html"> |       <trans-unit id="4010735610815226758" datatype="html"> | ||||||
| @@ -7857,7 +7861,7 @@ | |||||||
|         <source>Automatic</source> |         <source>Automatic</source> | ||||||
|         <context-group purpose="location"> |         <context-group purpose="location"> | ||||||
|           <context context-type="sourcefile">src/app/components/manage/management-list/management-list.component.ts</context> |           <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> | ||||||
|         <context-group purpose="location"> |         <context-group purpose="location"> | ||||||
|           <context context-type="sourcefile">src/app/data/matching-model.ts</context> |           <context context-type="sourcefile">src/app/data/matching-model.ts</context> | ||||||
| @@ -7868,7 +7872,7 @@ | |||||||
|         <source>None</source> |         <source>None</source> | ||||||
|         <context-group purpose="location"> |         <context-group purpose="location"> | ||||||
|           <context context-type="sourcefile">src/app/components/manage/management-list/management-list.component.ts</context> |           <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> | ||||||
|         <context-group purpose="location"> |         <context-group purpose="location"> | ||||||
|           <context context-type="sourcefile">src/app/data/matching-model.ts</context> |           <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> |         <source>Successfully created <x id="PH" equiv-text="this.typeName"/>.</source> | ||||||
|         <context-group purpose="location"> |         <context-group purpose="location"> | ||||||
|           <context context-type="sourcefile">src/app/components/manage/management-list/management-list.component.ts</context> |           <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> |         </context-group> | ||||||
|       </trans-unit> |       </trans-unit> | ||||||
|       <trans-unit id="3928835053823658072" datatype="html"> |       <trans-unit id="3928835053823658072" datatype="html"> | ||||||
|         <source>Error occurred while creating <x id="PH" equiv-text="this.typeName"/>.</source> |         <source>Error occurred while creating <x id="PH" equiv-text="this.typeName"/>.</source> | ||||||
|         <context-group purpose="location"> |         <context-group purpose="location"> | ||||||
|           <context context-type="sourcefile">src/app/components/manage/management-list/management-list.component.ts</context> |           <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> |         </context-group> | ||||||
|       </trans-unit> |       </trans-unit> | ||||||
|       <trans-unit id="2541368547549828690" datatype="html"> |       <trans-unit id="2541368547549828690" datatype="html"> | ||||||
|         <source>Successfully updated <x id="PH" equiv-text="this.typeName"/>.</source> |         <source>Successfully updated <x id="PH" equiv-text="this.typeName"/>.</source> | ||||||
|         <context-group purpose="location"> |         <context-group purpose="location"> | ||||||
|           <context context-type="sourcefile">src/app/components/manage/management-list/management-list.component.ts</context> |           <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> |         </context-group> | ||||||
|       </trans-unit> |       </trans-unit> | ||||||
|       <trans-unit id="6442673774206210733" datatype="html"> |       <trans-unit id="6442673774206210733" datatype="html"> | ||||||
|         <source>Error occurred while saving <x id="PH" equiv-text="this.typeName"/>.</source> |         <source>Error occurred while saving <x id="PH" equiv-text="this.typeName"/>.</source> | ||||||
|         <context-group purpose="location"> |         <context-group purpose="location"> | ||||||
|           <context context-type="sourcefile">src/app/components/manage/management-list/management-list.component.ts</context> |           <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> |         </context-group> | ||||||
|       </trans-unit> |       </trans-unit> | ||||||
|       <trans-unit id="8371896857609524947" datatype="html"> |       <trans-unit id="8371896857609524947" datatype="html"> | ||||||
|         <source>Associated documents will not be deleted.</source> |         <source>Associated documents will not be deleted.</source> | ||||||
|         <context-group purpose="location"> |         <context-group purpose="location"> | ||||||
|           <context context-type="sourcefile">src/app/components/manage/management-list/management-list.component.ts</context> |           <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> |         </context-group> | ||||||
|       </trans-unit> |       </trans-unit> | ||||||
|       <trans-unit id="6639207128255974941" datatype="html"> |       <trans-unit id="6639207128255974941" datatype="html"> | ||||||
|         <source>Error while deleting element</source> |         <source>Error while deleting element</source> | ||||||
|         <context-group purpose="location"> |         <context-group purpose="location"> | ||||||
|           <context context-type="sourcefile">src/app/components/manage/management-list/management-list.component.ts</context> |           <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> |         </context-group> | ||||||
|       </trans-unit> |       </trans-unit> | ||||||
|       <trans-unit id="4863024195229581844" datatype="html"> |       <trans-unit id="4863024195229581844" datatype="html"> | ||||||
|         <source>Permissions updated successfully</source> |         <source>Permissions updated successfully</source> | ||||||
|         <context-group purpose="location"> |         <context-group purpose="location"> | ||||||
|           <context context-type="sourcefile">src/app/components/manage/management-list/management-list.component.ts</context> |           <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> |         </context-group> | ||||||
|       </trans-unit> |       </trans-unit> | ||||||
|       <trans-unit id="1464476612812630086" datatype="html"> |       <trans-unit id="1464476612812630086" datatype="html"> | ||||||
|         <source>This operation will permanently delete all objects.</source> |         <source>This operation will permanently delete all objects.</source> | ||||||
|         <context-group purpose="location"> |         <context-group purpose="location"> | ||||||
|           <context context-type="sourcefile">src/app/components/manage/management-list/management-list.component.ts</context> |           <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> |         </context-group> | ||||||
|       </trans-unit> |       </trans-unit> | ||||||
|       <trans-unit id="5897787932098828336" datatype="html"> |       <trans-unit id="5897787932098828336" datatype="html"> | ||||||
|         <source>Objects deleted successfully</source> |         <source>Objects deleted successfully</source> | ||||||
|         <context-group purpose="location"> |         <context-group purpose="location"> | ||||||
|           <context context-type="sourcefile">src/app/components/manage/management-list/management-list.component.ts</context> |           <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> |         </context-group> | ||||||
|       </trans-unit> |       </trans-unit> | ||||||
|       <trans-unit id="8273353839648035634" datatype="html"> |       <trans-unit id="8273353839648035634" datatype="html"> | ||||||
|         <source>Error deleting objects</source> |         <source>Error deleting objects</source> | ||||||
|         <context-group purpose="location"> |         <context-group purpose="location"> | ||||||
|           <context context-type="sourcefile">src/app/components/manage/management-list/management-list.component.ts</context> |           <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> |         </context-group> | ||||||
|       </trans-unit> |       </trans-unit> | ||||||
|       <trans-unit id="5101757640976222639" datatype="html"> |       <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> |         <source>Do you really want to delete the storage path "<x id="PH" equiv-text="object.name"/>"?</source> | ||||||
|         <context-group purpose="location"> |         <context-group purpose="location"> | ||||||
|           <context context-type="sourcefile">src/app/components/manage/storage-path-list/storage-path-list.component.ts</context> |           <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> |         </context-group> | ||||||
|       </trans-unit> |       </trans-unit> | ||||||
|       <trans-unit id="6402703264596649214" datatype="html"> |       <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 { BulkEditorComponent } from './components/document-list/bulk-editor/bulk-editor.component' | ||||||
| import { NgxFileDropModule } from 'ngx-file-drop' | import { NgxFileDropModule } from 'ngx-file-drop' | ||||||
| import { TextComponent } from './components/common/input/text/text.component' | 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 { SelectComponent } from './components/common/input/select/select.component' | ||||||
| import { CheckComponent } from './components/common/input/check/check.component' | import { CheckComponent } from './components/common/input/check/check.component' | ||||||
| import { UrlComponent } from './components/common/input/url/url.component' | import { UrlComponent } from './components/common/input/url/url.component' | ||||||
| @@ -440,6 +441,7 @@ function initializeApp(settings: SettingsService) { | |||||||
|     DocumentCardSmallComponent, |     DocumentCardSmallComponent, | ||||||
|     BulkEditorComponent, |     BulkEditorComponent, | ||||||
|     TextComponent, |     TextComponent, | ||||||
|  |     TextAreaComponent, | ||||||
|     SelectComponent, |     SelectComponent, | ||||||
|     CheckComponent, |     CheckComponent, | ||||||
|     UrlComponent, |     UrlComponent, | ||||||
|   | |||||||
| @@ -10,7 +10,7 @@ | |||||||
|   <div class="modal-body"> |   <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="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> |     <pngx-input-select i18n-title title="Matching algorithm" [items]="getMatchingAlgorithms()" formControlName="matching_algorithm"></pngx-input-select> | ||||||
|     @if (patternRequired) { |     @if (patternRequired) { | ||||||
|       <pngx-input-text i18n-title title="Matching pattern" formControlName="match" [error]="error?.match"></pngx-input-text> |       <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 { PermissionsFormComponent } from '../../input/permissions/permissions-form/permissions-form.component' | ||||||
| import { SelectComponent } from '../../input/select/select.component' | import { SelectComponent } from '../../input/select/select.component' | ||||||
| import { TextComponent } from '../../input/text/text.component' | import { TextComponent } from '../../input/text/text.component' | ||||||
|  | import { TextAreaComponent } from '../../input/textarea/textarea.component' | ||||||
| import { EditDialogMode } from '../edit-dialog.component' | import { EditDialogMode } from '../edit-dialog.component' | ||||||
| import { StoragePathEditDialogComponent } from './storage-path-edit-dialog.component' | import { StoragePathEditDialogComponent } from './storage-path-edit-dialog.component' | ||||||
| import { provideHttpClient, withInterceptorsFromDi } from '@angular/common/http' | import { provideHttpClient, withInterceptorsFromDi } from '@angular/common/http' | ||||||
| @@ -27,6 +28,7 @@ describe('StoragePathEditDialogComponent', () => { | |||||||
|         IfOwnerDirective, |         IfOwnerDirective, | ||||||
|         SelectComponent, |         SelectComponent, | ||||||
|         TextComponent, |         TextComponent, | ||||||
|  |         TextAreaComponent, | ||||||
|         PermissionsFormComponent, |         PermissionsFormComponent, | ||||||
|         SafeHtmlPipe, |         SafeHtmlPipe, | ||||||
|       ], |       ], | ||||||
|   | |||||||
| @@ -26,9 +26,9 @@ export class StoragePathEditDialogComponent extends EditDialogComponent<StorageP | |||||||
|   get pathHint() { |   get pathHint() { | ||||||
|     return ( |     return ( | ||||||
|       $localize`e.g.` + |       $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.` + |       $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.` |       $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 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> |         <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) { |         @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> |         <th scope="col" class="fw-normal" i18n>Actions</th> | ||||||
|       </tr> |       </tr> | ||||||
| @@ -64,7 +64,7 @@ | |||||||
|           <td scope="row" class="d-none d-sm-table-cell">{{ getMatching(object) }}</td> |           <td scope="row" class="d-none d-sm-table-cell">{{ getMatching(object) }}</td> | ||||||
|           <td scope="row">{{ object.document_count }}</td> |           <td scope="row">{{ object.document_count }}</td> | ||||||
|           @for (column of extraColumns; track column) { |           @for (column of extraColumns; track column) { | ||||||
|             <td scope="row"> |             <td scope="row" [ngClass]="{ 'd-none d-sm-table-cell' : column.hideOnMobile }"> | ||||||
|               @if (column.rendersHtml) { |               @if (column.rendersHtml) { | ||||||
|                 <div [innerHtml]="column.valueFn.call(null, object) | safeHtml"></div> |                 <div [innerHtml]="column.valueFn.call(null, object) | safeHtml"></div> | ||||||
|               } @else { |               } @else { | ||||||
|   | |||||||
| @@ -44,6 +44,8 @@ export interface ManagementListColumn { | |||||||
|   valueFn: any |   valueFn: any | ||||||
|  |  | ||||||
|   rendersHtml?: boolean |   rendersHtml?: boolean | ||||||
|  |  | ||||||
|  |   hideOnMobile?: boolean | ||||||
| } | } | ||||||
|  |  | ||||||
| @Directive() | @Directive() | ||||||
|   | |||||||
| @@ -11,6 +11,8 @@ import { PageHeaderComponent } from '../../common/page-header/page-header.compon | |||||||
| import { StoragePathListComponent } from './storage-path-list.component' | import { StoragePathListComponent } from './storage-path-list.component' | ||||||
| import { NgxBootstrapIconsModule, allIcons } from 'ngx-bootstrap-icons' | import { NgxBootstrapIconsModule, allIcons } from 'ngx-bootstrap-icons' | ||||||
| import { provideHttpClient, withInterceptorsFromDi } from '@angular/common/http' | 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', () => { | describe('StoragePathListComponent', () => { | ||||||
|   let component: StoragePathListComponent |   let component: StoragePathListComponent | ||||||
| @@ -24,6 +26,7 @@ describe('StoragePathListComponent', () => { | |||||||
|         SortableDirective, |         SortableDirective, | ||||||
|         PageHeaderComponent, |         PageHeaderComponent, | ||||||
|         IfPermissionsDirective, |         IfPermissionsDirective, | ||||||
|  |         SafeHtmlPipe, | ||||||
|       ], |       ], | ||||||
|       imports: [ |       imports: [ | ||||||
|         NgbPaginationModule, |         NgbPaginationModule, | ||||||
| @@ -71,4 +74,15 @@ describe('StoragePathListComponent', () => { | |||||||
|       'Do you really want to delete the storage path "StoragePath1"?' |       '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', |           key: 'path', | ||||||
|           name: $localize`Path`, |           name: $localize`Path`, | ||||||
|  |           rendersHtml: true, | ||||||
|  |           hideOnMobile: true, | ||||||
|           valueFn: (c: StoragePath) => { |           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.conf import settings | ||||||
| from django.core.checks import Error | from django.core.checks import Error | ||||||
|  | from django.core.checks import Warning | ||||||
| from django.core.checks import register | from django.core.checks import register | ||||||
| from django.core.exceptions import FieldError | from django.core.exceptions import FieldError | ||||||
| from django.db.utils import OperationalError | from django.db.utils import OperationalError | ||||||
| from django.db.utils import ProgrammingError | from django.db.utils import ProgrammingError | ||||||
|  |  | ||||||
| from documents.signals import document_consumer_declaration | from documents.signals import document_consumer_declaration | ||||||
|  | from documents.templating.utils import convert_format_str_to_template_format | ||||||
|  |  | ||||||
|  |  | ||||||
| @register() | @register() | ||||||
| @@ -69,3 +71,19 @@ def parser_check(app_configs, **kwargs): | |||||||
|         ] |         ] | ||||||
|     else: |     else: | ||||||
|         return [] |         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 | import os | ||||||
| from collections import defaultdict |  | ||||||
| from pathlib import PurePath |  | ||||||
|  |  | ||||||
| import pathvalidate |  | ||||||
| from django.conf import settings | from django.conf import settings | ||||||
| from django.template.defaultfilters import slugify |  | ||||||
| from django.utils import timezone |  | ||||||
|  |  | ||||||
| from documents.models import Document | from documents.models import Document | ||||||
|  | from documents.templating.filepath import validate_filepath_template_and_render | ||||||
| logger = logging.getLogger("paperless.filehandling") | from documents.templating.utils import convert_format_str_to_template_format | ||||||
|  |  | ||||||
|  |  | ||||||
| class defaultdictNoStr(defaultdict): |  | ||||||
|     def __str__(self): |  | ||||||
|         raise ValueError("Don't use {tags} directly.") |  | ||||||
|  |  | ||||||
|  |  | ||||||
| def create_source_path_directory(source_path): | 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)) |         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): | def generate_unique_filename(doc, archive_filename=False): | ||||||
|     """ |     """ | ||||||
|     Generates a unique filename for doc in settings.ORIGINALS_DIR. |     Generates a unique filename for doc in settings.ORIGINALS_DIR. | ||||||
| @@ -134,116 +97,51 @@ def generate_filename( | |||||||
|     archive_filename=False, |     archive_filename=False, | ||||||
| ): | ): | ||||||
|     path = "" |     path = "" | ||||||
|     filename_format = settings.FILENAME_FORMAT |  | ||||||
|  |  | ||||||
|     try: |     def format_filename(document: Document, template_str: str) -> str | None: | ||||||
|         if doc.storage_path is not None: |         rendered_filename = validate_filepath_template_and_render( | ||||||
|             logger.debug( |             template_str, | ||||||
|                 f"Document has storage_path {doc.storage_path.id} " |             document, | ||||||
|                 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", |  | ||||||
|         ) |         ) | ||||||
|  |         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 "" |     counter_str = f"_{counter:02}" if counter else "" | ||||||
|  |  | ||||||
|     filetype_str = ".pdf" if archive_filename else doc.file_type |     filetype_str = ".pdf" if archive_filename else doc.file_type | ||||||
|  |  | ||||||
|     if len(path) > 0: |     if path: | ||||||
|         filename = f"{path}{counter_str}{filetype_str}" |         filename = f"{path}{counter_str}{filetype_str}" | ||||||
|     else: |     else: | ||||||
|         filename = f"{doc.pk:07}{counter_str}{filetype_str}" |         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: |     if append_gpg and doc.storage_type == doc.STORAGE_TYPE_GPG: | ||||||
|         filename += ".gpg" |         filename += ".gpg" | ||||||
|  |  | ||||||
|   | |||||||
| @@ -4,6 +4,7 @@ import hashlib | |||||||
| import logging | import logging | ||||||
| import os | import os | ||||||
| import shutil | import shutil | ||||||
|  | from collections import defaultdict | ||||||
| from time import sleep | from time import sleep | ||||||
|  |  | ||||||
| import pathvalidate | import pathvalidate | ||||||
| @@ -12,14 +13,41 @@ from django.db import migrations | |||||||
| from django.db import models | from django.db import models | ||||||
| from django.template.defaultfilters import slugify | 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") | logger = logging.getLogger("paperless.migrations") | ||||||
|  |  | ||||||
|  |  | ||||||
| ############################################################################### | ############################################################################### | ||||||
| # This is code copied straight paperless before the change. | # 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): | 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): | class StoragePath(MatchingModel): | ||||||
|     path = models.CharField( |     path = models.CharField( | ||||||
|         _("path"), |         _("path"), | ||||||
|         max_length=512, |         max_length=2048, | ||||||
|     ) |     ) | ||||||
|  |  | ||||||
|     class Meta(MatchingModel.Meta): |     class Meta(MatchingModel.Meta): | ||||||
|   | |||||||
| @@ -1,4 +1,5 @@ | |||||||
| import datetime | import datetime | ||||||
|  | import logging | ||||||
| import math | import math | ||||||
| import re | import re | ||||||
| import zoneinfo | import zoneinfo | ||||||
| @@ -52,8 +53,12 @@ from documents.models import WorkflowTrigger | |||||||
| from documents.parsers import is_mime_type_supported | from documents.parsers import is_mime_type_supported | ||||||
| from documents.permissions import get_groups_with_only_permission | from documents.permissions import get_groups_with_only_permission | ||||||
| from documents.permissions import set_permissions_for_object | 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 | from documents.validators import uri_validator | ||||||
|  |  | ||||||
|  | logger = logging.getLogger("paperless.serializers") | ||||||
|  |  | ||||||
|  |  | ||||||
| # https://www.django-rest-framework.org/api-guide/serializers/#example | # https://www.django-rest-framework.org/api-guide/serializers/#example | ||||||
| class DynamicFieldsModelSerializer(serializers.ModelSerializer): | class DynamicFieldsModelSerializer(serializers.ModelSerializer): | ||||||
| @@ -1482,38 +1487,18 @@ class StoragePathSerializer(MatchingModelSerializer, OwnedObjectSerializer): | |||||||
|             "set_permissions", |             "set_permissions", | ||||||
|         ) |         ) | ||||||
|  |  | ||||||
|     def validate_path(self, path): |     def validate_path(self, path: str): | ||||||
|         try: |         converted_path = convert_format_str_to_template_format(path) | ||||||
|             path.format( |         if converted_path != path: | ||||||
|                 title="title", |             logger.warning( | ||||||
|                 correspondent="correspondent", |                 f"Storage path {path} is not using the new style format, consider updating", | ||||||
|                 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", |  | ||||||
|             ) |             ) | ||||||
|  |         result = validate_filepath_template_and_render(converted_path) | ||||||
|  |  | ||||||
|         except KeyError as err: |         if result is None: | ||||||
|             raise serializers.ValidationError(_("Invalid variable detected.")) from err |             raise serializers.ValidationError(_("Invalid variable detected.")) | ||||||
|  |  | ||||||
|         return path |         return converted_path | ||||||
|  |  | ||||||
|     def update(self, instance, validated_data): |     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_year_short}/{created_month}/{created_month_name}" | ||||||
|                     "/{created_month_name_short}/{created_day}/{added}/{added_year}" |                     "/{created_month_name_short}/{created_day}/{added}/{added_year}" | ||||||
|                     "/{added_year_short}/{added_month}/{added_month_name}" |                     "/{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}/", |                     "/{tag_list}/{owner_username}/{original_name}/{doc_pk}/", | ||||||
|                 }, |                 }, | ||||||
|             ), |             ), | ||||||
|   | |||||||
| @@ -2,10 +2,12 @@ import textwrap | |||||||
| from unittest import mock | from unittest import mock | ||||||
|  |  | ||||||
| from django.core.checks import Error | from django.core.checks import Error | ||||||
|  | from django.core.checks import Warning | ||||||
| from django.test import TestCase | from django.test import TestCase | ||||||
| from django.test import override_settings | from django.test import override_settings | ||||||
|  |  | ||||||
| from documents.checks import changed_password_check | from documents.checks import changed_password_check | ||||||
|  | from documents.checks import filename_format_check | ||||||
| from documents.checks import parser_check | from documents.checks import parser_check | ||||||
| from documents.models import Document | from documents.models import Document | ||||||
| from documents.tests.factories import DocumentFactory | 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 datetime | ||||||
|  | import logging | ||||||
| import os | import os | ||||||
| import tempfile | import tempfile | ||||||
| from pathlib import Path | 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 delete_empty_directories | ||||||
| from documents.file_handling import generate_filename | from documents.file_handling import generate_filename | ||||||
| from documents.models import Correspondent | from documents.models import Correspondent | ||||||
|  | from documents.models import CustomField | ||||||
|  | from documents.models import CustomFieldInstance | ||||||
| from documents.models import Document | from documents.models import Document | ||||||
| from documents.models import DocumentType | from documents.models import DocumentType | ||||||
| from documents.models import StoragePath | 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(d1), "652 - the_doc.pdf") | ||||||
|         self.assertEqual(generate_filename(d2), "none - 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}") |     @override_settings(FILENAME_FORMAT="{title} {tag_list}") | ||||||
|     def test_tag_list(self): |     def test_tag_list(self): | ||||||
|         doc = Document.objects.create(title="doc1", mime_type="application/pdf") |         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.assertIsFile(os.path.join(tmp, "notempty", "file")) | ||||||
|         self.assertIsNotDir(os.path.join(tmp, "notempty", "empty")) |         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): |     def test_invalid_format(self): | ||||||
|         document = Document() |         document = Document() | ||||||
|         document.pk = 1 |         document.pk = 1 | ||||||
| @@ -957,7 +878,7 @@ class TestFilenameGeneration(DirectoriesMixin, TestCase): | |||||||
|             mime_type="application/pdf", |             mime_type="application/pdf", | ||||||
|             pk=2, |             pk=2, | ||||||
|             checksum="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") |         self.assertEqual(generate_filename(doc), "TestFolder/2020-06-25.pdf") | ||||||
|  |  | ||||||
| @@ -978,7 +899,7 @@ class TestFilenameGeneration(DirectoriesMixin, TestCase): | |||||||
|             mime_type="application/pdf", |             mime_type="application/pdf", | ||||||
|             pk=2, |             pk=2, | ||||||
|             checksum="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") |         self.assertEqual(generate_filename(doc), "none - 2020-06-25.pdf") | ||||||
|  |  | ||||||
| @@ -1003,7 +924,9 @@ class TestFilenameGeneration(DirectoriesMixin, TestCase): | |||||||
|             mime_type="application/pdf", |             mime_type="application/pdf", | ||||||
|             pk=2, |             pk=2, | ||||||
|             checksum="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") |         self.assertEqual(generate_filename(doc), "TestFolder/2020-06-25.pdf") | ||||||
|  |  | ||||||
| @@ -1025,7 +948,7 @@ class TestFilenameGeneration(DirectoriesMixin, TestCase): | |||||||
|             archive_serial_number=4, |             archive_serial_number=4, | ||||||
|             storage_path=StoragePath.objects.create( |             storage_path=StoragePath.objects.create( | ||||||
|                 name="sp1", |                 name="sp1", | ||||||
|                 path="ThisIsAFolder/{asn}/{created}", |                 path="ThisIsAFolder/{{asn}}/{{created}}", | ||||||
|             ), |             ), | ||||||
|         ) |         ) | ||||||
|         doc_b = Document.objects.create( |         doc_b = Document.objects.create( | ||||||
| @@ -1036,7 +959,7 @@ class TestFilenameGeneration(DirectoriesMixin, TestCase): | |||||||
|             checksum="abcde", |             checksum="abcde", | ||||||
|             storage_path=StoragePath.objects.create( |             storage_path=StoragePath.objects.create( | ||||||
|                 name="sp2", |                 name="sp2", | ||||||
|                 path="SomeImportantNone/{created}", |                 path="SomeImportantNone/{{created}}", | ||||||
|             ), |             ), | ||||||
|         ) |         ) | ||||||
|  |  | ||||||
| @@ -1072,7 +995,7 @@ class TestFilenameGeneration(DirectoriesMixin, TestCase): | |||||||
|             checksum="abcde", |             checksum="abcde", | ||||||
|             storage_path=StoragePath.objects.create( |             storage_path=StoragePath.objects.create( | ||||||
|                 name="sp2", |                 name="sp2", | ||||||
|                 path="SomeImportantNone/{created}", |                 path="SomeImportantNone/{{created}}", | ||||||
|             ), |             ), | ||||||
|         ) |         ) | ||||||
|  |  | ||||||
| @@ -1221,3 +1144,296 @@ class TestFilenameGeneration(DirectoriesMixin, TestCase): | |||||||
|         # Ensure that filename is properly generated |         # Ensure that filename is properly generated | ||||||
|         document.filename = generate_filename(document) |         document.filename = generate_filename(document) | ||||||
|         self.assertEqual(document.filename, "XX/doc1.pdf") |         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") | ||||||
		Reference in New Issue
	
	Block a user
	 Trenton H
					Trenton H