mirror of
				https://github.com/paperless-ngx/paperless-ngx.git
				synced 2025-10-30 03:56:23 -05:00 
			
		
		
		
	Feature: Dynamic document storage pathes (#916)
* Added devcontainer * Add feature storage pathes * Exclude tests and add versioning * Check escaping * Check escaping * Check quoting * Echo * Escape * Escape : * Double escape \ * Escaping * Remove if * Escape colon * Missing \ * Esacpe : * Escape all * test * Remove sed * Fix exclude * Remove SED command * Add LD_LIBRARY_PATH * Adjusted to v1.7 * Updated test-cases * Remove devcontainer * Removed internal build-file * Run pre-commit * Corrected flak8 error * Adjusted to v1.7 * Updated test-cases * Corrected flak8 error * Adjusted to new plural translations * Small adjustments due to code-review backend * Adjusted line-break * Removed PAPERLESS prefix from settings variables * Corrected style change due to search+replace * First documentation draft * Revert changes to Pipfile * Add sphinx-autobuild with keep-outdated * Revert merge error that results in wrong storage path is evaluated * Adjust styles of generated files ... * Adds additional testing to cover dynamic storage path functionality * Remove unnecessary condition * Add hint to edit storage path dialog * Correct spelling of pathes to paths * Minor documentation tweaks * Minor typo * improving wrapping of filter editor buttons with new storage path button * Update .gitignore * Fix select border radius in non input-groups * Better storage path edit hint * Add note to edit storage path dialog re document_renamer * Add note to bulk edit storage path re document_renamer * Rename FILTER_STORAGE_DIRECTORY to PATH * Fix broken filter rule parsing * Show default storage if unspecified * Remove note re storage path on bulk edit * Add basic validation of filename variables Co-authored-by: Markus Kling <markus@markus-kling.net> Co-authored-by: Trenton Holmes <holmes.trenton@gmail.com> Co-authored-by: Michael Shamoon <4887959+shamoon@users.noreply.github.com> Co-authored-by: Quinn Casey <quinn@quinncasey.com>
This commit is contained in:
		
							
								
								
									
										3
									
								
								.gitignore
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										3
									
								
								.gitignore
									
									
									
									
										vendored
									
									
								
							| @@ -89,3 +89,6 @@ scripts/nuke | ||||
|  | ||||
| # this is where the compiled frontend is moved to. | ||||
| /src/documents/static/frontend/ | ||||
|  | ||||
| # mac os | ||||
| .DS_Store | ||||
|   | ||||
							
								
								
									
										1
									
								
								Pipfile
									
									
									
									
									
								
							
							
						
						
									
										1
									
								
								Pipfile
									
									
									
									
									
								
							| @@ -69,4 +69,5 @@ sphinx_rtd_theme = "*" | ||||
| tox = "*" | ||||
| black = "*" | ||||
| pre-commit = "*" | ||||
| sphinx-autobuild = "*" | ||||
| myst-parser = "*" | ||||
|   | ||||
							
								
								
									
										77
									
								
								Pipfile.lock
									
									
									
										generated
									
									
									
								
							
							
						
						
									
										77
									
								
								Pipfile.lock
									
									
									
										generated
									
									
									
								
							| @@ -1,7 +1,7 @@ | ||||
| { | ||||
|     "_meta": { | ||||
|         "hash": { | ||||
|             "sha256": "edaf53125fd5a0dc3aff5b75e188523ef3b7bc29bda792ee78ee67506e0b831d" | ||||
|             "sha256": "818f3513df4a757e6302baf5a17ce61e85c7d69a7666e7d49e7e50e78e064ae3" | ||||
|         }, | ||||
|         "pipfile-spec": 6, | ||||
|         "requires": {}, | ||||
| @@ -466,7 +466,7 @@ | ||||
|                 "sha256:84d9dd047ffa80596e0f246e2eab0b391788b0503584e8945f2368256d2735ff", | ||||
|                 "sha256:9d643ff0a55b762d5cdb124b8eaa99c66322e2157b69160bc32796e824360e6d" | ||||
|             ], | ||||
|             "markers": "python_version >= '3'", | ||||
|             "markers": "python_version >= '3.5'", | ||||
|             "version": "==3.3" | ||||
|         }, | ||||
|         "imap-tools": { | ||||
| @@ -1587,6 +1587,14 @@ | ||||
|             "markers": "python_version >= '3.7'", | ||||
|             "version": "==8.1.3" | ||||
|         }, | ||||
|         "colorama": { | ||||
|             "hashes": [ | ||||
|                 "sha256:5941b2b48a20143d2267e95b1c2a7603ce057ee39fd88e7329b0c292aa16869b", | ||||
|                 "sha256:9f47eda37229f68eee03b24b9748937c7dc3868f906e8ba69fbcbdd3bc5dc3e2" | ||||
|             ], | ||||
|             "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4'", | ||||
|             "version": "==0.4.4" | ||||
|         }, | ||||
|         "coverage": { | ||||
|             "extras": [ | ||||
|                 "toml" | ||||
| @@ -1711,7 +1719,7 @@ | ||||
|                 "sha256:84d9dd047ffa80596e0f246e2eab0b391788b0503584e8945f2368256d2735ff", | ||||
|                 "sha256:9d643ff0a55b762d5cdb124b8eaa99c66322e2157b69160bc32796e824360e6d" | ||||
|             ], | ||||
|             "markers": "python_version >= '3'", | ||||
|             "markers": "python_version >= '3.5'", | ||||
|             "version": "==3.3" | ||||
|         }, | ||||
|         "imagesize": { | ||||
| @@ -1745,6 +1753,12 @@ | ||||
|             "markers": "python_version >= '3.7'", | ||||
|             "version": "==3.1.2" | ||||
|         }, | ||||
|         "livereload": { | ||||
|             "hashes": [ | ||||
|                 "sha256:776f2f865e59fde56490a56bcc6773b6917366bce0c267c60ee8aaf1a0959869" | ||||
|             ], | ||||
|             "version": "==2.6.3" | ||||
|         }, | ||||
|         "markdown-it-py": { | ||||
|             "hashes": [ | ||||
|                 "sha256:93de681e5c021a432c63147656fe21790bc01231e0cd2da73626f1aa3ac0fe27", | ||||
| @@ -2046,6 +2060,14 @@ | ||||
|             "index": "pypi", | ||||
|             "version": "==4.5.0" | ||||
|         }, | ||||
|         "sphinx-autobuild": { | ||||
|             "hashes": [ | ||||
|                 "sha256:8fe8cbfdb75db04475232f05187c776f46f6e9e04cacf1e49ce81bdac649ccac", | ||||
|                 "sha256:de1ca3b66e271d2b5b5140c35034c89e47f263f2cd5db302c9217065f7443f05" | ||||
|             ], | ||||
|             "index": "pypi", | ||||
|             "version": "==2021.3.14" | ||||
|         }, | ||||
|         "sphinx-rtd-theme": { | ||||
|             "hashes": [ | ||||
|                 "sha256:4d35a56f4508cfee4c4fb604373ede6feae2a306731d533f409ef5c3496fdbd8", | ||||
| @@ -2121,9 +2143,56 @@ | ||||
|                 "sha256:939de3e7a6161af0c887ef91b7d41a53e7c5a1ca976325f429cb46ea9bc30ecc", | ||||
|                 "sha256:de526c12914f0c550d15924c62d72abc48d6fe7364aa87328337a31007fe8a4f" | ||||
|             ], | ||||
|             "markers": "python_version < '3.11'", | ||||
|             "markers": "python_version >= '3.7'", | ||||
|             "version": "==2.0.1" | ||||
|         }, | ||||
|         "tornado": { | ||||
|             "hashes": [ | ||||
|                 "sha256:0a00ff4561e2929a2c37ce706cb8233b7907e0cdc22eab98888aca5dd3775feb", | ||||
|                 "sha256:0d321a39c36e5f2c4ff12b4ed58d41390460f798422c4504e09eb5678e09998c", | ||||
|                 "sha256:1e8225a1070cd8eec59a996c43229fe8f95689cb16e552d130b9793cb570a288", | ||||
|                 "sha256:20241b3cb4f425e971cb0a8e4ffc9b0a861530ae3c52f2b0434e6c1b57e9fd95", | ||||
|                 "sha256:25ad220258349a12ae87ede08a7b04aca51237721f63b1808d39bdb4b2164558", | ||||
|                 "sha256:33892118b165401f291070100d6d09359ca74addda679b60390b09f8ef325ffe", | ||||
|                 "sha256:33c6e81d7bd55b468d2e793517c909b139960b6c790a60b7991b9b6b76fb9791", | ||||
|                 "sha256:3447475585bae2e77ecb832fc0300c3695516a47d46cefa0528181a34c5b9d3d", | ||||
|                 "sha256:34ca2dac9e4d7afb0bed4677512e36a52f09caa6fded70b4e3e1c89dbd92c326", | ||||
|                 "sha256:3e63498f680547ed24d2c71e6497f24bca791aca2fe116dbc2bd0ac7f191691b", | ||||
|                 "sha256:548430be2740e327b3fe0201abe471f314741efcb0067ec4f2d7dcfb4825f3e4", | ||||
|                 "sha256:6196a5c39286cc37c024cd78834fb9345e464525d8991c21e908cc046d1cc02c", | ||||
|                 "sha256:61b32d06ae8a036a6607805e6720ef00a3c98207038444ba7fd3d169cd998910", | ||||
|                 "sha256:6286efab1ed6e74b7028327365cf7346b1d777d63ab30e21a0f4d5b275fc17d5", | ||||
|                 "sha256:65d98939f1a2e74b58839f8c4dab3b6b3c1ce84972ae712be02845e65391ac7c", | ||||
|                 "sha256:66324e4e1beede9ac79e60f88de548da58b1f8ab4b2f1354d8375774f997e6c0", | ||||
|                 "sha256:6c77c9937962577a6a76917845d06af6ab9197702a42e1346d8ae2e76b5e3675", | ||||
|                 "sha256:70dec29e8ac485dbf57481baee40781c63e381bebea080991893cd297742b8fd", | ||||
|                 "sha256:7250a3fa399f08ec9cb3f7b1b987955d17e044f1ade821b32e5f435130250d7f", | ||||
|                 "sha256:748290bf9112b581c525e6e6d3820621ff020ed95af6f17fedef416b27ed564c", | ||||
|                 "sha256:7da13da6f985aab7f6f28debab00c67ff9cbacd588e8477034c0652ac141feea", | ||||
|                 "sha256:8f959b26f2634a091bb42241c3ed8d3cedb506e7c27b8dd5c7b9f745318ddbb6", | ||||
|                 "sha256:9de9e5188a782be6b1ce866e8a51bc76a0fbaa0e16613823fc38e4fc2556ad05", | ||||
|                 "sha256:a48900ecea1cbb71b8c71c620dee15b62f85f7c14189bdeee54966fbd9a0c5bd", | ||||
|                 "sha256:b87936fd2c317b6ee08a5741ea06b9d11a6074ef4cc42e031bc6403f82a32575", | ||||
|                 "sha256:c77da1263aa361938476f04c4b6c8916001b90b2c2fdd92d8d535e1af48fba5a", | ||||
|                 "sha256:cb5ec8eead331e3bb4ce8066cf06d2dfef1bfb1b2a73082dfe8a161301b76e37", | ||||
|                 "sha256:cc0ee35043162abbf717b7df924597ade8e5395e7b66d18270116f8745ceb795", | ||||
|                 "sha256:d14d30e7f46a0476efb0deb5b61343b1526f73ebb5ed84f23dc794bdb88f9d9f", | ||||
|                 "sha256:d371e811d6b156d82aa5f9a4e08b58debf97c302a35714f6f45e35139c332e32", | ||||
|                 "sha256:d3d20ea5782ba63ed13bc2b8c291a053c8d807a8fa927d941bd718468f7b950c", | ||||
|                 "sha256:d3f7594930c423fd9f5d1a76bee85a2c36fd8b4b16921cae7e965f22575e9c01", | ||||
|                 "sha256:dcef026f608f678c118779cd6591c8af6e9b4155c44e0d1bc0c87c036fb8c8c4", | ||||
|                 "sha256:e0791ac58d91ac58f694d8d2957884df8e4e2f6687cdf367ef7eb7497f79eaa2", | ||||
|                 "sha256:e385b637ac3acaae8022e7e47dfa7b83d3620e432e3ecb9a3f7f58f150e50921", | ||||
|                 "sha256:e519d64089b0876c7b467274468709dadf11e41d65f63bba207e04217f47c085", | ||||
|                 "sha256:e7229e60ac41a1202444497ddde70a48d33909e484f96eb0da9baf8dc68541df", | ||||
|                 "sha256:ed3ad863b1b40cd1d4bd21e7498329ccaece75db5a5bf58cd3c9f130843e7102", | ||||
|                 "sha256:f0ba29bafd8e7e22920567ce0d232c26d4d47c8b5cf4ed7b562b5db39fa199c5", | ||||
|                 "sha256:fa2ba70284fa42c2a5ecb35e322e68823288a4251f9ba9cc77be04ae15eada68", | ||||
|                 "sha256:fba85b6cd9c39be262fcd23865652920832b61583de2a2ca907dbd8e8a8c81e5" | ||||
|             ], | ||||
|             "markers": "python_version >= '3.5'", | ||||
|             "version": "==6.1" | ||||
|         }, | ||||
|         "tox": { | ||||
|             "hashes": [ | ||||
|                 "sha256:0805727eb4d6b049de304977dfc9ce315a1938e6619c3ab9f38682bb04662a5a", | ||||
|   | ||||
| @@ -24,6 +24,7 @@ I18NSPHINXOPTS  = $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) . | ||||
| help: | ||||
| 	@echo "Please use \`make <target>' where <target> is one of" | ||||
| 	@echo "  html       to make standalone HTML files" | ||||
| 	@echo "  livehtml   to preview changes with live reload in your browser" | ||||
| 	@echo "  dirhtml    to make HTML files named index.html in directories" | ||||
| 	@echo "  singlehtml to make a single large HTML file" | ||||
| 	@echo "  pickle     to make pickle files" | ||||
| @@ -54,6 +55,9 @@ html: | ||||
| 	@echo | ||||
| 	@echo "Build finished. The HTML pages are in $(BUILDDIR)/html." | ||||
|  | ||||
| livehtml: | ||||
| 	sphinx-autobuild "./" "$(BUILDDIR)" $(O) | ||||
|  | ||||
| dirhtml: | ||||
| 	$(SPHINXBUILD) -b dirhtml $(ALLSPHINXOPTS) $(BUILDDIR)/dirhtml | ||||
| 	@echo | ||||
|   | ||||
							
								
								
									
										52
									
								
								docs/_static/js/darkmode.js
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										52
									
								
								docs/_static/js/darkmode.js
									
									
									
									
										vendored
									
									
								
							| @@ -1,47 +1,47 @@ | ||||
| let toggleButton; | ||||
| let icon; | ||||
| let toggleButton | ||||
| let icon | ||||
|  | ||||
| function load() { | ||||
| 	"use strict"; | ||||
| 	'use strict' | ||||
|  | ||||
| 	toggleButton = document.createElement("button"); | ||||
| 	toggleButton.setAttribute("title", "Toggle dark mode"); | ||||
| 	toggleButton.classList.add("dark-mode-toggle"); | ||||
| 	icon = document.createElement("i"); | ||||
| 	icon.classList.add("fa", darkModeState ? "fa-sun-o" : "fa-moon-o"); | ||||
| 	toggleButton.appendChild(icon); | ||||
| 	document.body.prepend(toggleButton); | ||||
| 	toggleButton = document.createElement('button') | ||||
| 	toggleButton.setAttribute('title', 'Toggle dark mode') | ||||
| 	toggleButton.classList.add('dark-mode-toggle') | ||||
| 	icon = document.createElement('i') | ||||
| 	icon.classList.add('fa', darkModeState ? 'fa-sun-o' : 'fa-moon-o') | ||||
| 	toggleButton.appendChild(icon) | ||||
| 	document.body.prepend(toggleButton) | ||||
|  | ||||
| 	// Listen for changes in the OS settings | ||||
| 	// addListener is used because older versions of Safari don't support addEventListener | ||||
| 	// prefersDarkQuery set in <head> | ||||
| 	if (prefersDarkQuery) { | ||||
| 		prefersDarkQuery.addListener(function (evt) { | ||||
| 			toggleDarkMode(evt.matches); | ||||
| 		}); | ||||
| 			toggleDarkMode(evt.matches) | ||||
| 		}) | ||||
| 	} | ||||
|  | ||||
| 	// Initial setting depending on the prefers-color-mode or localstorage | ||||
| 	// darkModeState should be set in the document <head> to prevent flash | ||||
| 	if (darkModeState == undefined) darkModeState = false; | ||||
| 	toggleDarkMode(darkModeState); | ||||
| 	if (darkModeState == undefined) darkModeState = false | ||||
| 	toggleDarkMode(darkModeState) | ||||
|  | ||||
| 	// Toggles the "dark-mode" class on click and sets localStorage state | ||||
| 	toggleButton.addEventListener("click", () => { | ||||
| 		darkModeState = !darkModeState; | ||||
| 	toggleButton.addEventListener('click', () => { | ||||
| 		darkModeState = !darkModeState | ||||
|  | ||||
| 		toggleDarkMode(darkModeState); | ||||
| 		localStorage.setItem("dark-mode", darkModeState); | ||||
| 	}); | ||||
| 		toggleDarkMode(darkModeState) | ||||
| 		localStorage.setItem('dark-mode', darkModeState) | ||||
| 	}) | ||||
| } | ||||
|  | ||||
| function toggleDarkMode(state) { | ||||
| 	document.documentElement.classList.toggle("dark-mode", state); | ||||
| 	document.documentElement.classList.toggle("light-mode", !state); | ||||
| 	icon.classList.remove("fa-sun-o"); | ||||
| 	icon.classList.remove("fa-moon-o"); | ||||
| 	icon.classList.add(state ? "fa-sun-o" : "fa-moon-o"); | ||||
| 	darkModeState = state; | ||||
| 	document.documentElement.classList.toggle('dark-mode', state) | ||||
| 	document.documentElement.classList.toggle('light-mode', !state) | ||||
| 	icon.classList.remove('fa-sun-o') | ||||
| 	icon.classList.remove('fa-moon-o') | ||||
| 	icon.classList.add(state ? 'fa-sun-o' : 'fa-moon-o') | ||||
| 	darkModeState = state | ||||
| } | ||||
|  | ||||
| document.addEventListener("DOMContentLoaded", load); | ||||
| document.addEventListener('DOMContentLoaded', load) | ||||
|   | ||||
| @@ -7,12 +7,12 @@ easier. | ||||
|  | ||||
| .. _advanced-matching: | ||||
|  | ||||
| Matching tags, correspondents and document types | ||||
| ################################################ | ||||
| Matching tags, correspondents, document types, and storage paths | ||||
| ################################################################ | ||||
|  | ||||
| Paperless will compare the matching algorithms defined by every tag and | ||||
| correspondent already set in your database to see if they apply to the text in | ||||
| a document.  In other words, if you defined a tag called ``Home Utility`` | ||||
| Paperless will compare the matching algorithms defined by every tag, correspondent, | ||||
| document type, and storage path in your database to see if they apply to the text | ||||
| in a document. In other words, if you define a tag called ``Home Utility`` | ||||
| that had a ``match`` property of ``bc hydro`` and a ``matching_algorithm`` of | ||||
| ``literal``, Paperless will automatically tag your newly-consumed document with | ||||
| your ``Home Utility`` tag so long as the text ``bc hydro`` appears in the body | ||||
| @@ -22,10 +22,10 @@ The matching logic is quite powerful. It supports searching the text of your | ||||
| document with different algorithms, and as such, some experimentation may be | ||||
| necessary to get things right. | ||||
|  | ||||
| In order to have a tag, correspondent, or type assigned automatically to newly | ||||
| consumed documents, assign a match and matching algorithm using the web | ||||
| interface. These settings define when to assign correspondents, tags, and types | ||||
| to documents. | ||||
| In order to have a tag, correspondent, document type, or storage path assigned | ||||
| automatically to newly consumed documents, assign a match and matching algorithm | ||||
| using the web interface. These settings define when to assign tags, correspondents, | ||||
| document types, and storage paths to documents. | ||||
|  | ||||
| The following algorithms are available: | ||||
|  | ||||
| @@ -37,7 +37,7 @@ The following algorithms are available: | ||||
| * **Literal:** Matches only if the match appears exactly as provided (i.e. preserve ordering) in the PDF. | ||||
| * **Regular expression:** Parses the match as a regular expression and tries to | ||||
|   find a match within the document. | ||||
| * **Fuzzy match:** I dont know. Look at the source. | ||||
| * **Fuzzy match:** I don't know. Look at the source. | ||||
| * **Auto:** Tries to automatically match new documents. This does not require you | ||||
|   to set a match. See the notes below. | ||||
|  | ||||
| @@ -47,9 +47,9 @@ defining a match text of ``"Bank of America" BofA`` using the *any* algorithm, | ||||
| will match documents that contain either "Bank of America" or "BofA", but will | ||||
| not match documents containing "Bank of South America". | ||||
|  | ||||
| Then just save your tag/correspondent and run another document through the | ||||
| consumer.  Once complete, you should see the newly-created document, | ||||
| automatically tagged with the appropriate data. | ||||
| Then just save your tag, correspondent, document type, or storage path and run | ||||
| another document through the consumer.  Once complete, you should see the | ||||
| newly-created document, automatically tagged with the appropriate data. | ||||
|  | ||||
|  | ||||
| .. _advanced-automatic_matching: | ||||
| @@ -58,9 +58,9 @@ Automatic matching | ||||
| ================== | ||||
|  | ||||
| Paperless-ngx comes with a new matching algorithm called *Auto*. This matching | ||||
| algorithm tries to assign tags, correspondents, and document types to your | ||||
| documents based on how you have already assigned these on existing documents. It | ||||
| uses a neural network under the hood. | ||||
| algorithm tries to assign tags, correspondents, document types, and storage paths | ||||
| to your documents based on how you have already assigned these on existing documents. | ||||
| It uses a neural network under the hood. | ||||
|  | ||||
| If, for example, all your bank statements of your account 123 at the Bank of | ||||
| America are tagged with the tag "bofa_123" and the matching algorithm of this | ||||
| @@ -80,20 +80,21 @@ feature: | ||||
|   that the neural network only learns from documents which you have correctly | ||||
|   tagged before. | ||||
| * The matching algorithm can only work if there is a correlation between the | ||||
|   tag, correspondent, or document type and the document itself. Your bank | ||||
|   statements usually contain your bank account number and the name of the bank, | ||||
|   so this works reasonably well, However, tags such as "TODO" cannot be | ||||
|   automatically assigned. | ||||
|   tag, correspondent, document type, or storage path and the document itself. | ||||
|   Your bank statements usually contain your bank account number and the name | ||||
|   of the bank, so this works reasonably well, However, tags such as "TODO" | ||||
|   cannot be automatically assigned. | ||||
| * The matching algorithm needs a reasonable number of documents to identify when | ||||
|   to assign tags, correspondents, and types. If one out of a thousand documents | ||||
|   has the correspondent "Very obscure web shop I bought something five years | ||||
|   ago", it will probably not assign this correspondent automatically if you buy | ||||
|   something from them again. The more documents, the better. | ||||
|   to assign tags, correspondents, storage paths, and types. If one out of a | ||||
|   thousand documents has the correspondent "Very obscure web shop I bought | ||||
|   something five years ago", it will probably not assign this correspondent | ||||
|   automatically if you buy something from them again. The more documents, the better. | ||||
| * Paperless also needs a reasonable amount of negative examples to decide when | ||||
|   not to assign a certain tag, correspondent or type. This will usually be the | ||||
|   case as you start filling up paperless with documents. Example: If all your | ||||
|   documents are either from "Webshop" and "Bank", paperless will assign one of | ||||
|   these correspondents to ANY new document, if both are set to automatic matching. | ||||
|   not to assign a certain tag, correspondent, document type, or storage path. This will | ||||
|   usually be the case as you start filling up paperless with documents. | ||||
|   Example: If all your documents are either from "Webshop" and "Bank", paperless | ||||
|   will assign one of these correspondents to ANY new document, if both are set | ||||
|   to automatic matching. | ||||
|  | ||||
| Hooking into the consumption process | ||||
| #################################### | ||||
| @@ -268,6 +269,17 @@ If paperless detects that two documents share the same filename, paperless will | ||||
| append ``_01``, ``_02``, etc to the filename. This happens if all the placeholders in a filename | ||||
| evaluate to the same value. | ||||
|  | ||||
| .. hint:: | ||||
|     You can affect how empty placeholders are treated by changing the following setting to | ||||
|     `true`. | ||||
|  | ||||
|     .. code:: | ||||
|  | ||||
|         PAPERLESS_FILENAME_FORMAT_REMOVE_NONE=True | ||||
|  | ||||
|     Doing this results in all empty placeholders resolving to "" instead of "none" as stated above. | ||||
|     Spaces before empty placeholders are removed as well, empty directories are omitted. | ||||
|  | ||||
| .. hint:: | ||||
|  | ||||
|     Paperless checks the filename of a document whenever it is saved. Therefore, | ||||
| @@ -290,3 +302,59 @@ evaluate to the same value. | ||||
|  | ||||
|     However, keep in mind that inside docker, if files get stored outside of the | ||||
|     predefined volumes, they will be lost after a restart of paperless. | ||||
|  | ||||
|  | ||||
| Storage paths | ||||
| ############# | ||||
|  | ||||
| One of the best things in Paperless is that you can not only access the documents via the | ||||
| web interface, but also via the file system. | ||||
|  | ||||
| When as single storage layout is not sufficient for your use case, storage paths come to | ||||
| the rescue. Storage paths allow you to configure more precisely where each document is stored | ||||
| in the file system. | ||||
|  | ||||
| - Each storage path is a `PAPERLESS_FILENAME_FORMAT` and follows the rules described above | ||||
| - Each document is assigned a storage path using the matching algorithms described above, but | ||||
|   can be overwritten at any time | ||||
|  | ||||
| For example, you could define the following two storage paths: | ||||
|  | ||||
| 1. Normal communications are put into a folder structure sorted by `year/correspondent` | ||||
| 2. Communications with insurance companies are stored in a flat structure with longer file names, | ||||
|    but containing the full date of the correspondence. | ||||
|  | ||||
| .. code:: | ||||
|  | ||||
|     By Year = {created_year}/{correspondent}/{title} | ||||
|     Insurances = Insurances/{correspondent}/{created_year}-{created_month}-{created_day} {title} | ||||
|  | ||||
|  | ||||
| If you then map these storage paths to the documents, you might get the following result. | ||||
| For simplicity, `By Year` defines the same structure as in the previous example above. | ||||
|  | ||||
| .. code:: text | ||||
|  | ||||
|    2019/                                   # By Year | ||||
|       My bank/ | ||||
|         Statement January.pdf | ||||
|         Statement February.pdf | ||||
|  | ||||
|     Insurances/                           # Insurances | ||||
|       Healthcare 123/ | ||||
|         2022-01-01 Statement January.pdf | ||||
|         2022-02-02 Letter.pdf | ||||
|         2022-02-03 Letter.pdf | ||||
|       Dental 456/ | ||||
|         2021-12-01 New Conditions.pdf | ||||
|  | ||||
|  | ||||
| .. hint:: | ||||
|  | ||||
|     Defining a storage path is optional. If no storage path is defined for a document, the global | ||||
|     `PAPERLESS_FILENAME_FORMAT` is applied. | ||||
|  | ||||
| .. caution:: | ||||
|  | ||||
|     If you adjust the format of an existing storage path, old documents don't get relocated automatically. | ||||
|     You need to run the :ref:`document renamer <utilities-renamer>` to adjust their pathes. | ||||
|   | ||||
| @@ -111,6 +111,14 @@ PAPERLESS_FILENAME_FORMAT=<format> | ||||
|  | ||||
|     Default is none, which disables this feature. | ||||
|  | ||||
| PAPERLESS_FILENAME_FORMAT_REMOVE_NONE=<bool> | ||||
|     Tells paperless to replace placeholders in `PAPERLESS_FILENAME_FORMAT` that would resolve | ||||
|     to 'none' to be omitted from the resulting filename. This also holds true for directory | ||||
|     names. | ||||
|     See :ref:`advanced-file_name_handling` for details. | ||||
|  | ||||
|     Defaults to `false` which disables this feature. | ||||
|  | ||||
| PAPERLESS_LOGGING_DIR=<path> | ||||
|     This is where paperless will store log files. | ||||
|  | ||||
|   | ||||
| @@ -23,6 +23,7 @@ | ||||
| #PAPERLESS_MEDIA_ROOT=../media | ||||
| #PAPERLESS_STATICDIR=../static | ||||
| #PAPERLESS_FILENAME_FORMAT= | ||||
| #PAPERLESS_FILENAME_FORMAT_REMOVE_NONE= | ||||
|  | ||||
| # Security and hosting | ||||
|  | ||||
|   | ||||
| @@ -12,6 +12,7 @@ import { TagListComponent } from './components/manage/tag-list/tag-list.componen | ||||
| import { NotFoundComponent } from './components/not-found/not-found.component' | ||||
| import { DocumentAsnComponent } from './components/document-asn/document-asn.component' | ||||
| import { DirtyFormGuard } from './guards/dirty-form.guard' | ||||
| import { StoragePathListComponent } from './components/manage/storage-path-list/storage-path-list.component' | ||||
|  | ||||
| const routes: Routes = [ | ||||
|   { path: '', redirectTo: 'dashboard', pathMatch: 'full' }, | ||||
| @@ -27,6 +28,7 @@ const routes: Routes = [ | ||||
|       { path: 'tags', component: TagListComponent }, | ||||
|       { path: 'documenttypes', component: DocumentTypeListComponent }, | ||||
|       { path: 'correspondents', component: CorrespondentListComponent }, | ||||
|       { path: 'storagepaths', component: StoragePathListComponent }, | ||||
|       { path: 'logs', component: LogsComponent }, | ||||
|       { | ||||
|         path: 'settings', | ||||
|   | ||||
| @@ -87,6 +87,8 @@ import localeSr from '@angular/common/locales/sr' | ||||
| import localeSv from '@angular/common/locales/sv' | ||||
| import localeTr from '@angular/common/locales/tr' | ||||
| import localeZh from '@angular/common/locales/zh' | ||||
| import { StoragePathListComponent } from './components/manage/storage-path-list/storage-path-list.component' | ||||
| import { StoragePathEditDialogComponent } from './components/common/edit-dialog/storage-path-edit-dialog/storage-path-edit-dialog.component' | ||||
| import { SettingsService } from './services/settings.service' | ||||
|  | ||||
| registerLocaleData(localeBe) | ||||
| @@ -125,6 +127,7 @@ function initializeApp(settings: SettingsService) { | ||||
|     TagListComponent, | ||||
|     DocumentTypeListComponent, | ||||
|     CorrespondentListComponent, | ||||
|     StoragePathListComponent, | ||||
|     LogsComponent, | ||||
|     SettingsComponent, | ||||
|     NotFoundComponent, | ||||
| @@ -132,6 +135,7 @@ function initializeApp(settings: SettingsService) { | ||||
|     ConfirmDialogComponent, | ||||
|     TagEditDialogComponent, | ||||
|     DocumentTypeEditDialogComponent, | ||||
|     StoragePathEditDialogComponent, | ||||
|     TagComponent, | ||||
|     PageHeaderComponent, | ||||
|     AppFrameComponent, | ||||
|   | ||||
| @@ -134,6 +134,13 @@ | ||||
|               </svg> <ng-container i18n>Document types</ng-container> | ||||
|             </a> | ||||
|           </li> | ||||
|           <li class="nav-item"> | ||||
|             <a class="nav-link" routerLink="storagepaths" routerLinkActive="active" (click)="closeMenu()"> | ||||
|               <svg class="sidebaricon" fill="currentColor"> | ||||
|                 <use xlink:href="assets/bootstrap-icons.svg#folder"/> | ||||
|               </svg> <ng-container i18n>Storage paths</ng-container> | ||||
|             </a> | ||||
|           </li> | ||||
|           <li class="nav-item"> | ||||
|             <a class="nav-link" routerLink="logs" routerLinkActive="active" (click)="closeMenu()"> | ||||
|               <svg class="sidebaricon" fill="currentColor"> | ||||
|   | ||||
| @@ -5,7 +5,7 @@ | ||||
|     </div> | ||||
|     <div class="modal-body"> | ||||
|       <p *ngIf="messageBold"><b>{{messageBold}}</b></p> | ||||
|       <p *ngIf="message">{{message}}</p> | ||||
|       <p class="mb-0" *ngIf="message" [innerHTML]="message | safeHtml"></p> | ||||
|     </div> | ||||
|     <div class="modal-footer"> | ||||
|       <button type="button" class="btn btn-outline-secondary" (click)="cancel()" [disabled]="!buttonsEnabled" i18n> | ||||
|   | ||||
| @@ -0,0 +1,24 @@ | ||||
| <form [formGroup]="objectForm" (ngSubmit)="save()"> | ||||
|   <div class="modal-header"> | ||||
|     <h4 class="modal-title" id="modal-basic-title">{{getTitle()}}</h4> | ||||
|     <button type="button" [disabled]="!closeEnabled" class="btn-close" aria-label="Close" (click)="cancel()"> | ||||
|     </button> | ||||
|   </div> | ||||
|   <div class="modal-body"> | ||||
|  | ||||
|     <p *ngIf="this.dialogMode == 'edit'" i18n> | ||||
|       <em>Note that editing a path does not apply changes to stored files until you have run the 'document_renamer' utility. See the <a target="_blank" href="https://paperless-ngx.readthedocs.io/en/latest/administration.html#utilities-renamer">documentation</a>.</em> | ||||
|     </p> | ||||
|  | ||||
|     <app-input-text i18n-title title="Name" formControlName="name" [error]="error?.name"></app-input-text> | ||||
|     <app-input-text i18n-title title="Path" formControlName="path" [error]="error?.path" [hint]="pathHint"></app-input-text> | ||||
|     <app-input-select i18n-title title="Matching algorithm" [items]="getMatchingAlgorithms()" formControlName="matching_algorithm"></app-input-select> | ||||
|     <app-input-text *ngIf="patternRequired" i18n-title title="Matching pattern" formControlName="match" [error]="error?.match"></app-input-text> | ||||
|     <app-input-check *ngIf="patternRequired" i18n-title title="Case insensitive" formControlName="is_insensitive"></app-input-check> | ||||
|  | ||||
|   </div> | ||||
|   <div class="modal-footer"> | ||||
|     <button type="button" class="btn btn-outline-secondary" (click)="cancel()" i18n [disabled]="networkActive">Cancel</button> | ||||
|     <button type="submit" class="btn btn-primary" i18n [disabled]="networkActive">Save</button> | ||||
|   </div> | ||||
| </form> | ||||
| @@ -0,0 +1,50 @@ | ||||
| import { Component } from '@angular/core' | ||||
| import { FormControl, FormGroup } from '@angular/forms' | ||||
| import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap' | ||||
| import { EditDialogComponent } from 'src/app/components/common/edit-dialog/edit-dialog.component' | ||||
| import { PaperlessStoragePath } from 'src/app/data/paperless-storage-path' | ||||
| import { StoragePathService } from 'src/app/services/rest/storage-path.service' | ||||
| import { ToastService } from 'src/app/services/toast.service' | ||||
|  | ||||
| @Component({ | ||||
|   selector: 'app-storage-path-edit-dialog', | ||||
|   templateUrl: './storage-path-edit-dialog.component.html', | ||||
|   styleUrls: ['./storage-path-edit-dialog.component.scss'], | ||||
| }) | ||||
| export class StoragePathEditDialogComponent extends EditDialogComponent<PaperlessStoragePath> { | ||||
|   constructor( | ||||
|     service: StoragePathService, | ||||
|     activeModal: NgbActiveModal, | ||||
|     toastService: ToastService | ||||
|   ) { | ||||
|     super(service, activeModal, toastService) | ||||
|   } | ||||
|  | ||||
|   get pathHint() { | ||||
|     return ( | ||||
|       $localize`e.g.` + | ||||
|       ' <code>{created_year}-{title}</code> ' + | ||||
|       $localize`or use slashes to add directories e.g.` + | ||||
|       ' <code>{created_year}/{correspondent}/{title}</code>. ' + | ||||
|       $localize`See <a target="_blank" href="https://paperless-ngx.readthedocs.io/en/latest/advanced_usage.html#file-name-handling">documentation</a> for full list.` | ||||
|     ) | ||||
|   } | ||||
|  | ||||
|   getCreateTitle() { | ||||
|     return $localize`Create new storage path` | ||||
|   } | ||||
|  | ||||
|   getEditTitle() { | ||||
|     return $localize`Edit storage path` | ||||
|   } | ||||
|  | ||||
|   getForm(): FormGroup { | ||||
|     return new FormGroup({ | ||||
|       name: new FormControl(''), | ||||
|       path: new FormControl(''), | ||||
|       matching_algorithm: new FormControl(1), | ||||
|       match: new FormControl(''), | ||||
|       is_insensitive: new FormControl(true), | ||||
|     }) | ||||
|   } | ||||
| } | ||||
| @@ -9,7 +9,8 @@ | ||||
|         [items]="items" | ||||
|         [addTag]="allowCreateNew && addItemRef" | ||||
|         addTagText="Add item" | ||||
|         i18n-addTagText="Used for both types and correspondents" | ||||
|         i18n-addTagText="Used for both types, correspondents, storage paths" | ||||
|         [placeholder]="placeholder" | ||||
|         bindLabel="name" | ||||
|         bindValue="id" | ||||
|         (change)="onChange(value)" | ||||
|   | ||||
| @@ -41,6 +41,9 @@ export class SelectComponent extends AbstractInputComponent<number> { | ||||
|   @Input() | ||||
|   suggestions: number[] | ||||
|  | ||||
|   @Input() | ||||
|   placeholder: string | ||||
|  | ||||
|   @Output() | ||||
|   createNew = new EventEmitter<string>() | ||||
|  | ||||
|   | ||||
| @@ -1,7 +1,7 @@ | ||||
| <div class="mb-3"> | ||||
|   <label class="form-label" [for]="inputId">{{title}}</label> | ||||
|   <input #inputField type="text" class="form-control" [class.is-invalid]="error" [id]="inputId" [(ngModel)]="value" (change)="onChange(value)"> | ||||
|   <small *ngIf="hint" class="form-text text-muted">{{hint}}</small> | ||||
|   <small *ngIf="hint" class="form-text text-muted" [innerHTML]="hint | safeHtml"></small> | ||||
|   <div class="invalid-feedback"> | ||||
|     {{error}} | ||||
|   </div> | ||||
|   | ||||
| @@ -73,6 +73,8 @@ | ||||
|                             (createNew)="createCorrespondent($event)" [suggestions]="suggestions?.correspondents"></app-input-select> | ||||
|                         <app-input-select [items]="documentTypes" i18n-title title="Document type" formControlName="document_type" [allowNull]="true" | ||||
|                             (createNew)="createDocumentType($event)" [suggestions]="suggestions?.document_types"></app-input-select> | ||||
|                         <app-input-select [items]="storagePaths" i18n-title title="Storage path" formControlName="storage_path" [allowNull]="true" | ||||
|                             (createNew)="createStoragePath($event)" [suggestions]="suggestions?.storage_paths" i18n-placeholder placeholder="Default"></app-input-select> | ||||
|                         <app-input-tags formControlName="tags" [suggestions]="suggestions?.tags"></app-input-tags> | ||||
|  | ||||
|                     </ng-template> | ||||
|   | ||||
| @@ -33,6 +33,9 @@ import { PaperlessDocumentSuggestions } from 'src/app/data/paperless-document-su | ||||
| import { FILTER_FULLTEXT_MORELIKE } from 'src/app/data/filter-rule-type' | ||||
| import { normalizeDateStr } from 'src/app/utils/date' | ||||
| import { QueryParamsService } from 'src/app/services/query-params.service' | ||||
| import { StoragePathService } from 'src/app/services/rest/storage-path.service' | ||||
| import { PaperlessStoragePath } from 'src/app/data/paperless-storage-path' | ||||
| import { StoragePathEditDialogComponent } from '../common/edit-dialog/storage-path-edit-dialog/storage-path-edit-dialog.component' | ||||
| import { SETTINGS_KEYS } from 'src/app/data/paperless-uisettings' | ||||
|  | ||||
| @Component({ | ||||
| @@ -66,6 +69,7 @@ export class DocumentDetailComponent | ||||
|  | ||||
|   correspondents: PaperlessCorrespondent[] | ||||
|   documentTypes: PaperlessDocumentType[] | ||||
|   storagePaths: PaperlessStoragePath[] | ||||
|  | ||||
|   documentForm: FormGroup = new FormGroup({ | ||||
|     title: new FormControl(''), | ||||
| @@ -73,6 +77,7 @@ export class DocumentDetailComponent | ||||
|     created: new FormControl(), | ||||
|     correspondent: new FormControl(), | ||||
|     document_type: new FormControl(), | ||||
|     storage_path: new FormControl(), | ||||
|     archive_serial_number: new FormControl(), | ||||
|     tags: new FormControl([]), | ||||
|   }) | ||||
| @@ -115,6 +120,7 @@ export class DocumentDetailComponent | ||||
|     private documentTitlePipe: DocumentTitlePipe, | ||||
|     private toastService: ToastService, | ||||
|     private settings: SettingsService, | ||||
|     private storagePathService: StoragePathService, | ||||
|     private queryParamsService: QueryParamsService | ||||
|   ) {} | ||||
|  | ||||
| @@ -163,11 +169,17 @@ export class DocumentDetailComponent | ||||
|       .listAll() | ||||
|       .pipe(first()) | ||||
|       .subscribe((result) => (this.correspondents = result.results)) | ||||
|  | ||||
|     this.documentTypeService | ||||
|       .listAll() | ||||
|       .pipe(first()) | ||||
|       .subscribe((result) => (this.documentTypes = result.results)) | ||||
|  | ||||
|     this.storagePathService | ||||
|       .listAll() | ||||
|       .pipe(first()) | ||||
|       .subscribe((result) => (this.storagePaths = result.results)) | ||||
|  | ||||
|     this.route.paramMap | ||||
|       .pipe( | ||||
|         takeUntil(this.unsubscribeNotifier), | ||||
| @@ -230,6 +242,7 @@ export class DocumentDetailComponent | ||||
|             created: this.ogDate.toISOString(), | ||||
|             correspondent: doc.correspondent, | ||||
|             document_type: doc.document_type, | ||||
|             storage_path: doc.storage_path, | ||||
|             archive_serial_number: doc.archive_serial_number, | ||||
|             tags: [...doc.tags], | ||||
|           }) | ||||
| @@ -336,6 +349,27 @@ export class DocumentDetailComponent | ||||
|       }) | ||||
|   } | ||||
|  | ||||
|   createStoragePath(newName: string) { | ||||
|     var modal = this.modalService.open(StoragePathEditDialogComponent, { | ||||
|       backdrop: 'static', | ||||
|     }) | ||||
|     modal.componentInstance.dialogMode = 'create' | ||||
|     if (newName) modal.componentInstance.object = { name: newName } | ||||
|     modal.componentInstance.success | ||||
|       .pipe( | ||||
|         switchMap((newStoragePath) => { | ||||
|           return this.storagePathService | ||||
|             .listAll() | ||||
|             .pipe(map((storagePaths) => ({ newStoragePath, storagePaths }))) | ||||
|         }) | ||||
|       ) | ||||
|       .pipe(takeUntil(this.unsubscribeNotifier)) | ||||
|       .subscribe(({ newStoragePath, documentTypes: storagePaths }) => { | ||||
|         this.storagePaths = storagePaths.results | ||||
|         this.documentForm.get('storage_path').setValue(newStoragePath.id) | ||||
|       }) | ||||
|   } | ||||
|  | ||||
|   discard() { | ||||
|     this.documentsService | ||||
|       .get(this.documentId) | ||||
|   | ||||
| @@ -53,6 +53,15 @@ | ||||
|         [(selectionModel)]="documentTypeSelectionModel" | ||||
|         (apply)="setDocumentTypes($event)"> | ||||
|       </app-filterable-dropdown> | ||||
|       <app-filterable-dropdown class="me-2 me-md-3" title="Storage path" icon="folder-fill" i18n-title | ||||
|         filterPlaceholder="Filter storage paths" i18n-filterPlaceholder | ||||
|         [items]="storagePaths" | ||||
|         [editing]="true" | ||||
|         [applyOnClose]="applyOnClose" | ||||
|         (open)="openStoragePathDropdown()" | ||||
|         [(selectionModel)]="storagePathsSelectionModel" | ||||
|         (apply)="setStoragePaths($event)"> | ||||
|       </app-filterable-dropdown> | ||||
|     </div> | ||||
|   </div> | ||||
|   <div class="col-auto ms-auto mb-2 mb-xl-0 d-flex"> | ||||
|   | ||||
| @@ -22,6 +22,8 @@ import { MatchingModel } from 'src/app/data/matching-model' | ||||
| import { SettingsService } from 'src/app/services/settings.service' | ||||
| import { ToastService } from 'src/app/services/toast.service' | ||||
| import { saveAs } from 'file-saver' | ||||
| import { StoragePathService } from 'src/app/services/rest/storage-path.service' | ||||
| import { PaperlessStoragePath } from 'src/app/data/paperless-storage-path' | ||||
| import { SETTINGS_KEYS } from 'src/app/data/paperless-uisettings' | ||||
|  | ||||
| @Component({ | ||||
| @@ -33,10 +35,12 @@ export class BulkEditorComponent { | ||||
|   tags: PaperlessTag[] | ||||
|   correspondents: PaperlessCorrespondent[] | ||||
|   documentTypes: PaperlessDocumentType[] | ||||
|   storagePaths: PaperlessStoragePath[] | ||||
|  | ||||
|   tagSelectionModel = new FilterableDropdownSelectionModel() | ||||
|   correspondentSelectionModel = new FilterableDropdownSelectionModel() | ||||
|   documentTypeSelectionModel = new FilterableDropdownSelectionModel() | ||||
|   storagePathsSelectionModel = new FilterableDropdownSelectionModel() | ||||
|   awaitingDownload: boolean | ||||
|  | ||||
|   constructor( | ||||
| @@ -48,7 +52,8 @@ export class BulkEditorComponent { | ||||
|     private modalService: NgbModal, | ||||
|     private openDocumentService: OpenDocumentsService, | ||||
|     private settings: SettingsService, | ||||
|     private toastService: ToastService | ||||
|     private toastService: ToastService, | ||||
|     private storagePathService: StoragePathService | ||||
|   ) {} | ||||
|  | ||||
|   applyOnClose: boolean = this.settings.get( | ||||
| @@ -68,6 +73,9 @@ export class BulkEditorComponent { | ||||
|     this.documentTypeService | ||||
|       .listAll() | ||||
|       .subscribe((result) => (this.documentTypes = result.results)) | ||||
|     this.storagePathService | ||||
|       .listAll() | ||||
|       .subscribe((result) => (this.storagePaths = result.results)) | ||||
|   } | ||||
|  | ||||
|   private executeBulkOperation(modal, method: string, args) { | ||||
| @@ -145,6 +153,17 @@ export class BulkEditorComponent { | ||||
|       }) | ||||
|   } | ||||
|  | ||||
|   openStoragePathDropdown() { | ||||
|     this.documentService | ||||
|       .getSelectionData(Array.from(this.list.selected)) | ||||
|       .subscribe((s) => { | ||||
|         this.applySelectionData( | ||||
|           s.selected_storage_paths, | ||||
|           this.storagePathsSelectionModel | ||||
|         ) | ||||
|       }) | ||||
|   } | ||||
|  | ||||
|   private _localizeList(items: MatchingModel[]) { | ||||
|     if (items.length == 0) { | ||||
|       return '' | ||||
| @@ -299,6 +318,42 @@ export class BulkEditorComponent { | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   setStoragePaths(changedDocumentPaths: ChangedItems) { | ||||
|     if ( | ||||
|       changedDocumentPaths.itemsToAdd.length == 0 && | ||||
|       changedDocumentPaths.itemsToRemove.length == 0 | ||||
|     ) | ||||
|       return | ||||
|  | ||||
|     let storagePath = | ||||
|       changedDocumentPaths.itemsToAdd.length > 0 | ||||
|         ? changedDocumentPaths.itemsToAdd[0] | ||||
|         : null | ||||
|  | ||||
|     if (this.showConfirmationDialogs) { | ||||
|       let modal = this.modalService.open(ConfirmDialogComponent, { | ||||
|         backdrop: 'static', | ||||
|       }) | ||||
|       modal.componentInstance.title = $localize`Confirm storage path assignment` | ||||
|       if (storagePath) { | ||||
|         modal.componentInstance.message = $localize`This operation will assign the storage path "${storagePath.name}" to ${this.list.selected.size} selected document(s).` | ||||
|       } else { | ||||
|         modal.componentInstance.message = $localize`This operation will remove the storage path from ${this.list.selected.size} selected document(s).` | ||||
|       } | ||||
|       modal.componentInstance.btnClass = 'btn-warning' | ||||
|       modal.componentInstance.btnCaption = $localize`Confirm` | ||||
|       modal.componentInstance.confirmClicked.subscribe(() => { | ||||
|         this.executeBulkOperation(modal, 'set_storage_path', { | ||||
|           storage_path: storagePath ? storagePath.id : null, | ||||
|         }) | ||||
|       }) | ||||
|     } else { | ||||
|       this.executeBulkOperation(null, 'set_storage_path', { | ||||
|         storage_path: storagePath ? storagePath.id : null, | ||||
|       }) | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   applyDelete() { | ||||
|     let modal = this.modalService.open(ConfirmDialogComponent, { | ||||
|       backdrop: 'static', | ||||
|   | ||||
| @@ -67,6 +67,13 @@ | ||||
|               </svg> | ||||
|               <small>{{(document.document_type$ | async)?.name}}</small> | ||||
|             </button> | ||||
|             <button *ngIf="document.storage_path" type="button" class="list-group-item btn btn-sm bg-light text-dark p-1 border-0 me-2" title="Filter by storage path" | ||||
|              (click)="clickStoragePath.emit(document.storage_path);$event.stopPropagation()"> | ||||
|               <svg class="metadata-icon me-2 text-muted bi bi-folder" viewBox="0 0 16 16" fill="currentColor"> | ||||
|                 <path d="M0 2a1 1 0 0 1 1-1h14a1 1 0 0 1 1 1v2a1 1 0 0 1-1 1v7.5a2.5 2.5 0 0 1-2.5 2.5h-9A2.5 2.5 0 0 1 1 12.5V5a1 1 0 0 1-1-1V2zm2 3v7.5A1.5 1.5 0 0 0 3.5 14h9a1.5 1.5 0 0 0 1.5-1.5V5H2zm13-3H1v2h14V2zM5 7.5a.5.5 0 0 1 .5-.5h5a.5.5 0 0 1 0 1h-5a.5.5 0 0 1-.5-.5z"/> | ||||
|               </svg> | ||||
|               <small>{{(document.storage_path$ | async)?.name}}</small> | ||||
|             </button> | ||||
|             <div *ngIf="document.archive_serial_number" class="list-group-item me-2 bg-light text-dark p-1 border-0"> | ||||
|               <svg class="metadata-icon me-2 text-muted bi bi-upc-scan" viewBox="0 0 16 16" fill="currentColor"> | ||||
|                 <path d="M1.5 1a.5.5 0 0 0-.5.5v3a.5.5 0 0 1-1 0v-3A1.5 1.5 0 0 1 1.5 0h3a.5.5 0 0 1 0 1h-3zM11 .5a.5.5 0 0 1 .5-.5h3A1.5 1.5 0 0 1 16 1.5v3a.5.5 0 0 1-1 0v-3a.5.5 0 0 0-.5-.5h-3a.5.5 0 0 1-.5-.5zM.5 11a.5.5 0 0 1 .5.5v3a.5.5 0 0 0 .5.5h3a.5.5 0 0 1 0 1h-3A1.5 1.5 0 0 1 0 14.5v-3a.5.5 0 0 1 .5-.5zm15 0a.5.5 0 0 1 .5.5v3a1.5 1.5 0 0 1-1.5 1.5h-3a.5.5 0 0 1 0-1h3a.5.5 0 0 0 .5-.5v-3a.5.5 0 0 1 .5-.5zM3 4.5a.5.5 0 0 1 1 0v7a.5.5 0 0 1-1 0v-7zm2 0a.5.5 0 0 1 1 0v7a.5.5 0 0 1-1 0v-7zm2 0a.5.5 0 0 1 1 0v7a.5.5 0 0 1-1 0v-7zm2 0a.5.5 0 0 1 .5-.5h1a.5.5 0 0 1 .5.5v7a.5.5 0 0 1-.5.5h-1a.5.5 0 0 1-.5-.5v-7zm3 0a.5.5 0 0 1 1 0v7a.5.5 0 0 1-1 0v-7z"/> | ||||
|   | ||||
| @@ -52,6 +52,9 @@ export class DocumentCardLargeComponent implements OnInit { | ||||
|   @Output() | ||||
|   clickDocumentType = new EventEmitter<number>() | ||||
|  | ||||
|   @Output() | ||||
|   clickStoragePath = new EventEmitter<number>() | ||||
|  | ||||
|   @Output() | ||||
|   clickMoreLike = new EventEmitter() | ||||
|  | ||||
|   | ||||
| @@ -37,6 +37,13 @@ | ||||
|           </svg> | ||||
|           <small>{{(document.document_type$ | async)?.name}}</small> | ||||
|         </button> | ||||
|         <button *ngIf="document.storage_path" type="button" class="list-group-item list-group-item-action bg-transparent ps-0 p-1 border-0" title="Filter by storage path" | ||||
|          (click)="clickStoragePath.emit(document.storage_path);$event.stopPropagation()"> | ||||
|           <svg class="metadata-icon me-2 text-muted bi bi-folder" viewBox="0 0 16 16" fill="currentColor"> | ||||
|             <path d="M0 2a1 1 0 0 1 1-1h14a1 1 0 0 1 1 1v2a1 1 0 0 1-1 1v7.5a2.5 2.5 0 0 1-2.5 2.5h-9A2.5 2.5 0 0 1 1 12.5V5a1 1 0 0 1-1-1V2zm2 3v7.5A1.5 1.5 0 0 0 3.5 14h9a1.5 1.5 0 0 0 1.5-1.5V5H2zm13-3H1v2h14V2zM5 7.5a.5.5 0 0 1 .5-.5h5a.5.5 0 0 1 0 1h-5a.5.5 0 0 1-.5-.5z"/> | ||||
|           </svg> | ||||
|           <small>{{(document.storage_path$ | async)?.name}}</small> | ||||
|         </button> | ||||
|         <div class="list-group-item bg-transparent p-0 border-0 d-flex flex-wrap-reverse justify-content-between"> | ||||
|           <ng-template #dateTooltip> | ||||
|             <div class="d-flex flex-column"> | ||||
|   | ||||
| @@ -47,6 +47,9 @@ export class DocumentCardSmallComponent implements OnInit { | ||||
|   @Output() | ||||
|   clickDocumentType = new EventEmitter<number>() | ||||
|  | ||||
|   @Output() | ||||
|   clickStoragePath = new EventEmitter<number>() | ||||
|  | ||||
|   moreTags: number = null | ||||
|  | ||||
|   @ViewChild('popover') popover: NgbPopover | ||||
|   | ||||
| @@ -107,7 +107,7 @@ | ||||
| <ng-template #documentListNoError> | ||||
|  | ||||
|   <div *ngIf="displayMode == 'largeCards'"> | ||||
|     <app-document-card-large [selected]="list.isSelected(d)" (toggleSelected)="toggleSelected(d, $event)" *ngFor="let d of list.documents; trackBy: trackByDocumentId" [document]="d" (clickTag)="clickTag($event)" (clickCorrespondent)="clickCorrespondent($event)" (clickDocumentType)="clickDocumentType($event)" (clickMoreLike)="clickMoreLike(d.id)"> | ||||
|     <app-document-card-large [selected]="list.isSelected(d)" (toggleSelected)="toggleSelected(d, $event)" *ngFor="let d of list.documents; trackBy: trackByDocumentId" [document]="d" (clickTag)="clickTag($event)" (clickCorrespondent)="clickCorrespondent($event)" (clickDocumentType)="clickDocumentType($event)" (clickStoragePath)="clickStoragePath($event)" (clickMoreLike)="clickMoreLike(d.id)"> | ||||
|     </app-document-card-large> | ||||
|   </div> | ||||
|  | ||||
| @@ -138,6 +138,12 @@ | ||||
|         [currentSortReverse]="list.sortReverse" | ||||
|         (sort)="onSort($event)" | ||||
|         i18n>Document type</th> | ||||
|       <th class="d-none d-xl-table-cell" | ||||
|         sortable="storage_path__name" | ||||
|         [currentSortField]="list.sortField" | ||||
|         [currentSortReverse]="list.sortReverse" | ||||
|         (sort)="onSort($event)" | ||||
|         i18n>Storage path</th> | ||||
|       <th | ||||
|         sortable="created" | ||||
|         [currentSortField]="list.sortField" | ||||
| @@ -176,6 +182,11 @@ | ||||
|             <a (click)="clickDocumentType(d.document_type);$event.stopPropagation()" title="Filter by document type">{{(d.document_type$ | async)?.name}}</a> | ||||
|           </ng-container> | ||||
|         </td> | ||||
|         <td class="d-none d-xl-table-cell"> | ||||
|           <ng-container *ngIf="d.storage_path"> | ||||
|             <a (click)="clickStoragePath(d.storage_path);$event.stopPropagation()" title="Filter by storage path">{{(d.storage_path$ | async)?.name}}</a> | ||||
|           </ng-container> | ||||
|         </td> | ||||
|         <td> | ||||
|           {{d.created | customDate}} | ||||
|         </td> | ||||
| @@ -187,7 +198,7 @@ | ||||
|   </table> | ||||
|  | ||||
|   <div class="row row-cols-paperless-cards" *ngIf="displayMode == 'smallCards'"> | ||||
|     <app-document-card-small class="p-0" [selected]="list.isSelected(d)" (toggleSelected)="toggleSelected(d, $event)" [document]="d" *ngFor="let d of list.documents; trackBy: trackByDocumentId" (clickTag)="clickTag($event)" (clickCorrespondent)="clickCorrespondent($event)" (clickDocumentType)="clickDocumentType($event)"></app-document-card-small> | ||||
|     <app-document-card-small class="p-0" [selected]="list.isSelected(d)" (toggleSelected)="toggleSelected(d, $event)" [document]="d" *ngFor="let d of list.documents; trackBy: trackByDocumentId" (clickTag)="clickTag($event)" (clickCorrespondent)="clickCorrespondent($event)" (clickStoragePath)="clickStoragePath($event)" (clickDocumentType)="clickDocumentType($event)"></app-document-card-small> | ||||
|   </div> | ||||
|   <div *ngIf="list.documents?.length > 15" class="mt-3"> | ||||
|     <ng-container *ngTemplateOutlet="pagination"></ng-container> | ||||
|   | ||||
| @@ -265,6 +265,13 @@ export class DocumentListComponent implements OnInit, OnDestroy, AfterViewInit { | ||||
|     }) | ||||
|   } | ||||
|  | ||||
|   clickStoragePath(storagePathID: number) { | ||||
|     this.list.selectNone() | ||||
|     setTimeout(() => { | ||||
|       this.filterEditor.addStoragePath(storagePathID) | ||||
|     }) | ||||
|   } | ||||
|  | ||||
|   clickMoreLike(documentID: number) { | ||||
|     this.queryParamsService.navigateWithFilterRules([ | ||||
|       { rule_type: FILTER_FULLTEXT_MORELIKE, value: documentID.toString() }, | ||||
|   | ||||
| @@ -1,7 +1,7 @@ | ||||
| <div class="row"> | ||||
| <div class="row flex-wrap"> | ||||
|    <div class="col mb-2 mb-xl-0"> | ||||
|      <div class="form-inline d-flex align-items-center"> | ||||
|          <div class="input-group input-group-sm flex-fill w-auto"> | ||||
|          <div class="input-group input-group-sm flex-fill w-auto flex-nowrap"> | ||||
|            <div ngbDropdown> | ||||
|             <button class="btn btn-sm btn-outline-primary" ngbDropdownToggle>{{textFilterTargetName}}</button> | ||||
|             <div class="dropdown-menu shadow" ngbDropdownMenu> | ||||
| @@ -18,6 +18,7 @@ | ||||
|   <div class="w-100 d-xl-none"></div> | ||||
|     <div class="col col-xl-auto"> | ||||
|       <div class="d-flex flex-wrap"> | ||||
|         <div class="d-flex flex-wrap mb-2 mb-lg-0"> | ||||
|           <app-filterable-dropdown class="flex-fill" title="Tags" icon="tag-fill" i18n-title | ||||
|             filterPlaceholder="Filter tags" i18n-filterPlaceholder | ||||
|             [items]="tags" | ||||
| @@ -40,6 +41,15 @@ | ||||
|             (open)="onDocumentTypeDropdownOpen()" | ||||
|             (selectionModelChange)="updateRules()" | ||||
|             [allowSelectNone]="true"></app-filterable-dropdown> | ||||
|           <app-filterable-dropdown class="me-2 flex-fill" title="Storage path" icon="folder-fill" i18n-title | ||||
|             filterPlaceholder="Filter storage path" i18n-filterPlaceholder | ||||
|             [items]="storagePaths" | ||||
|             [(selectionModel)]="storagePathSelectionModel" | ||||
|             (open)="onStoragePathDropdownOpen()" | ||||
|             (selectionModelChange)="updateRules()" | ||||
|             [allowSelectNone]="true"></app-filterable-dropdown> | ||||
|         </div> | ||||
|         <div class="d-flex flex-wrap"> | ||||
|           <app-date-dropdown class="mb-2 mb-xl-0" | ||||
|             title="Created" i18n-title | ||||
|             (datesSet)="updateRules()" | ||||
| @@ -52,9 +62,10 @@ | ||||
|             (datesSet)="updateRules()"></app-date-dropdown> | ||||
|         </div> | ||||
|      </div> | ||||
|    </div> | ||||
|    <div class="w-100 d-xl-none"></div> | ||||
|    <div class="col col-xl-auto"> | ||||
|      <button class="btn btn-link btn-sm px-0 mx-0 ms-xl-n3" [disabled]="!rulesModified" (click)="resetSelected()"> | ||||
|    <div class="col col-xl-auto ps-0"> | ||||
|      <button class="btn btn-link btn-sm px-0" [disabled]="!rulesModified" (click)="resetSelected()"> | ||||
|        <svg width="1em" height="1em" viewBox="0 0 16 16" class="bi bi-x me-1" fill="currentColor" xmlns="http://www.w3.org/2000/svg"> | ||||
|          <path fill-rule="evenodd" d="M4.646 4.646a.5.5 0 0 1 .708 0L8 7.293l2.646-2.647a.5.5 0 0 1 .708.708L8.707 8l2.647 2.646a.5.5 0 0 1-.708.708L8 8.707l-2.646 2.647a.5.5 0 0 1-.708-.708L7.293 8 4.646 5.354a.5.5 0 0 1 0-.708z"/> | ||||
|        </svg><ng-container i18n>Reset filters</ng-container> | ||||
|   | ||||
| @@ -33,6 +33,7 @@ import { | ||||
|   FILTER_DOES_NOT_HAVE_TAG, | ||||
|   FILTER_TITLE, | ||||
|   FILTER_TITLE_CONTENT, | ||||
|   FILTER_STORAGE_PATH, | ||||
|   FILTER_ASN_ISNULL, | ||||
|   FILTER_ASN_GT, | ||||
|   FILTER_ASN_LT, | ||||
| @@ -41,6 +42,8 @@ import { FilterableDropdownSelectionModel } from '../../common/filterable-dropdo | ||||
| import { ToggleableItemState } from '../../common/filterable-dropdown/toggleable-dropdown-button/toggleable-dropdown-button.component' | ||||
| import { DocumentService } from 'src/app/services/rest/document.service' | ||||
| import { PaperlessDocument } from 'src/app/data/paperless-document' | ||||
| import { PaperlessStoragePath } from 'src/app/data/paperless-storage-path' | ||||
| import { StoragePathService } from 'src/app/services/rest/storage-path.service' | ||||
|  | ||||
| const TEXT_FILTER_TARGET_TITLE = 'title' | ||||
| const TEXT_FILTER_TARGET_TITLE_CONTENT = 'title-content' | ||||
| @@ -107,7 +110,8 @@ export class FilterEditorComponent implements OnInit, OnDestroy { | ||||
|     private documentTypeService: DocumentTypeService, | ||||
|     private tagService: TagService, | ||||
|     private correspondentService: CorrespondentService, | ||||
|     private documentService: DocumentService | ||||
|     private documentService: DocumentService, | ||||
|     private storagePathService: StoragePathService | ||||
|   ) {} | ||||
|  | ||||
|   @ViewChild('textFilterInput') | ||||
| @@ -116,6 +120,7 @@ export class FilterEditorComponent implements OnInit, OnDestroy { | ||||
|   tags: PaperlessTag[] = [] | ||||
|   correspondents: PaperlessCorrespondent[] = [] | ||||
|   documentTypes: PaperlessDocumentType[] = [] | ||||
|   storagePaths: PaperlessStoragePath[] = [] | ||||
|  | ||||
|   _textFilter = '' | ||||
|   _moreLikeId: number | ||||
| @@ -186,6 +191,7 @@ export class FilterEditorComponent implements OnInit, OnDestroy { | ||||
|   tagSelectionModel = new FilterableDropdownSelectionModel() | ||||
|   correspondentSelectionModel = new FilterableDropdownSelectionModel() | ||||
|   documentTypeSelectionModel = new FilterableDropdownSelectionModel() | ||||
|   storagePathSelectionModel = new FilterableDropdownSelectionModel() | ||||
|  | ||||
|   dateCreatedBefore: string | ||||
|   dateCreatedAfter: string | ||||
| @@ -210,6 +216,7 @@ export class FilterEditorComponent implements OnInit, OnDestroy { | ||||
|     this._filterRules = value | ||||
|  | ||||
|     this.documentTypeSelectionModel.clear(false) | ||||
|     this.storagePathSelectionModel.clear(false) | ||||
|     this.tagSelectionModel.clear(false) | ||||
|     this.correspondentSelectionModel.clear(false) | ||||
|     this._textFilter = null | ||||
| @@ -297,6 +304,13 @@ export class FilterEditorComponent implements OnInit, OnDestroy { | ||||
|             false | ||||
|           ) | ||||
|           break | ||||
|         case FILTER_STORAGE_PATH: | ||||
|           this.storagePathSelectionModel.set( | ||||
|             rule.value ? +rule.value : null, | ||||
|             ToggleableItemState.Selected, | ||||
|             false | ||||
|           ) | ||||
|           break | ||||
|         case FILTER_ASN_ISNULL: | ||||
|           this.textFilterTarget = TEXT_FILTER_TARGET_ASN | ||||
|           this.textFilterModifier = TEXT_FILTER_MODIFIER_NULL | ||||
| @@ -418,6 +432,12 @@ export class FilterEditorComponent implements OnInit, OnDestroy { | ||||
|           value: documentType.id?.toString(), | ||||
|         }) | ||||
|       }) | ||||
|     this.storagePathSelectionModel.getSelectedItems().forEach((storagePath) => { | ||||
|       filterRules.push({ | ||||
|         rule_type: FILTER_STORAGE_PATH, | ||||
|         value: storagePath.id?.toString(), | ||||
|       }) | ||||
|     }) | ||||
|     if (this.dateCreatedBefore) { | ||||
|       filterRules.push({ | ||||
|         rule_type: FILTER_CREATED_BEFORE, | ||||
| @@ -500,6 +520,9 @@ export class FilterEditorComponent implements OnInit, OnDestroy { | ||||
|     this.documentTypeService | ||||
|       .listAll() | ||||
|       .subscribe((result) => (this.documentTypes = result.results)) | ||||
|     this.storagePathService | ||||
|       .listAll() | ||||
|       .subscribe((result) => (this.storagePaths = result.results)) | ||||
|  | ||||
|     this.textFilterDebounce = new Subject<string>() | ||||
|  | ||||
| @@ -542,6 +565,13 @@ export class FilterEditorComponent implements OnInit, OnDestroy { | ||||
|     ) | ||||
|   } | ||||
|  | ||||
|   addStoragePath(storagePathID: number) { | ||||
|     this.storagePathSelectionModel.set( | ||||
|       storagePathID, | ||||
|       ToggleableItemState.Selected | ||||
|     ) | ||||
|   } | ||||
|  | ||||
|   onTagsDropdownOpen() { | ||||
|     this.tagSelectionModel.apply() | ||||
|   } | ||||
| @@ -554,6 +584,10 @@ export class FilterEditorComponent implements OnInit, OnDestroy { | ||||
|     this.documentTypeSelectionModel.apply() | ||||
|   } | ||||
|  | ||||
|   onStoragePathDropdownOpen() { | ||||
|     this.storagePathSelectionModel.apply() | ||||
|   } | ||||
|  | ||||
|   updateTextFilter(text) { | ||||
|     this._textFilter = text | ||||
|     this.documentService.searchQuery = text | ||||
|   | ||||
| @@ -0,0 +1,48 @@ | ||||
| import { Component } from '@angular/core' | ||||
| import { NgbModal } from '@ng-bootstrap/ng-bootstrap' | ||||
| import { FILTER_STORAGE_PATH } from 'src/app/data/filter-rule-type' | ||||
| import { PaperlessStoragePath } from 'src/app/data/paperless-storage-path' | ||||
| import { DocumentListViewService } from 'src/app/services/document-list-view.service' | ||||
| import { QueryParamsService } from 'src/app/services/query-params.service' | ||||
| import { StoragePathService } from 'src/app/services/rest/storage-path.service' | ||||
| import { ToastService } from 'src/app/services/toast.service' | ||||
| import { StoragePathEditDialogComponent } from '../../common/edit-dialog/storage-path-edit-dialog/storage-path-edit-dialog.component' | ||||
| import { ManagementListComponent } from '../management-list/management-list.component' | ||||
|  | ||||
| @Component({ | ||||
|   selector: 'app-storage-path-list', | ||||
|   templateUrl: './../management-list/management-list.component.html', | ||||
|   styleUrls: ['./../management-list/management-list.component.scss'], | ||||
| }) | ||||
| export class StoragePathListComponent extends ManagementListComponent<PaperlessStoragePath> { | ||||
|   constructor( | ||||
|     directoryService: StoragePathService, | ||||
|     modalService: NgbModal, | ||||
|     toastService: ToastService, | ||||
|     queryParamsService: QueryParamsService | ||||
|   ) { | ||||
|     super( | ||||
|       directoryService, | ||||
|       modalService, | ||||
|       StoragePathEditDialogComponent, | ||||
|       toastService, | ||||
|       queryParamsService, | ||||
|       FILTER_STORAGE_PATH, | ||||
|       $localize`storage path`, | ||||
|       $localize`storage paths`, | ||||
|       [ | ||||
|         { | ||||
|           key: 'path', | ||||
|           name: $localize`Path`, | ||||
|           valueFn: (c: PaperlessStoragePath) => { | ||||
|             return c.path | ||||
|           }, | ||||
|         }, | ||||
|       ] | ||||
|     ) | ||||
|   } | ||||
|  | ||||
|   getDeleteMessage(object: PaperlessStoragePath) { | ||||
|     return $localize`Do you really want to delete the storage path "${object.name}"?` | ||||
|   } | ||||
| } | ||||
| @@ -28,6 +28,8 @@ export const FILTER_TITLE_CONTENT = 21 | ||||
| export const FILTER_FULLTEXT_QUERY = 22 | ||||
| export const FILTER_FULLTEXT_MORELIKE = 23 | ||||
|  | ||||
| export const FILTER_STORAGE_PATH = 30 | ||||
|  | ||||
| export const FILTER_RULE_TYPES: FilterRuleType[] = [ | ||||
|   { | ||||
|     id: FILTER_TITLE, | ||||
| @@ -56,6 +58,13 @@ export const FILTER_RULE_TYPES: FilterRuleType[] = [ | ||||
|     datatype: 'correspondent', | ||||
|     multi: false, | ||||
|   }, | ||||
|   { | ||||
|     id: FILTER_STORAGE_PATH, | ||||
|     filtervar: 'storage_path__id', | ||||
|     isnull_filtervar: 'storage_path__isnull', | ||||
|     datatype: 'storage_path', | ||||
|     multi: false, | ||||
|   }, | ||||
|   { | ||||
|     id: FILTER_DOCUMENT_TYPE, | ||||
|     filtervar: 'document_type__id', | ||||
| @@ -180,7 +189,6 @@ export const FILTER_RULE_TYPES: FilterRuleType[] = [ | ||||
|     datatype: 'string', | ||||
|     multi: false, | ||||
|   }, | ||||
|  | ||||
|   { | ||||
|     id: FILTER_FULLTEXT_MORELIKE, | ||||
|     filtervar: 'more_like_id', | ||||
|   | ||||
| @@ -4,4 +4,6 @@ export interface PaperlessDocumentSuggestions { | ||||
|   correspondents?: number[] | ||||
|  | ||||
|   document_types?: number[] | ||||
|  | ||||
|   storage_paths?: number[] | ||||
| } | ||||
|   | ||||
| @@ -3,6 +3,7 @@ import { ObjectWithId } from './object-with-id' | ||||
| import { PaperlessTag } from './paperless-tag' | ||||
| import { PaperlessDocumentType } from './paperless-document-type' | ||||
| import { Observable } from 'rxjs' | ||||
| import { PaperlessStoragePath } from './paperless-storage-path' | ||||
|  | ||||
| export interface SearchHit { | ||||
|   score?: number | ||||
| @@ -20,6 +21,10 @@ export interface PaperlessDocument extends ObjectWithId { | ||||
|  | ||||
|   document_type?: number | ||||
|  | ||||
|   storage_path$?: Observable<PaperlessStoragePath> | ||||
|  | ||||
|   storage_path?: number | ||||
|  | ||||
|   title?: string | ||||
|  | ||||
|   content?: string | ||||
|   | ||||
							
								
								
									
										5
									
								
								src-ui/src/app/data/paperless-storage-path.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										5
									
								
								src-ui/src/app/data/paperless-storage-path.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,5 @@ | ||||
| import { MatchingModel } from './matching-model' | ||||
|  | ||||
| export interface PaperlessStoragePath extends MatchingModel { | ||||
|   path?: string | ||||
| } | ||||
| @@ -12,6 +12,7 @@ import { DocumentTypeService } from './document-type.service' | ||||
| import { TagService } from './tag.service' | ||||
| import { PaperlessDocumentSuggestions } from 'src/app/data/paperless-document-suggestions' | ||||
| import { filterRulesToQueryParams } from '../query-params.service' | ||||
| import { StoragePathService } from './storage-path.service' | ||||
|  | ||||
| export const DOCUMENT_SORT_FIELDS = [ | ||||
|   { field: 'archive_serial_number', name: $localize`ASN` }, | ||||
| @@ -37,6 +38,7 @@ export interface SelectionDataItem { | ||||
| } | ||||
|  | ||||
| export interface SelectionData { | ||||
|   selected_storage_paths: SelectionDataItem[] | ||||
|   selected_correspondents: SelectionDataItem[] | ||||
|   selected_tags: SelectionDataItem[] | ||||
|   selected_document_types: SelectionDataItem[] | ||||
| @@ -52,7 +54,8 @@ export class DocumentService extends AbstractPaperlessService<PaperlessDocument> | ||||
|     http: HttpClient, | ||||
|     private correspondentService: CorrespondentService, | ||||
|     private documentTypeService: DocumentTypeService, | ||||
|     private tagService: TagService | ||||
|     private tagService: TagService, | ||||
|     private storagePathService: StoragePathService | ||||
|   ) { | ||||
|     super(http, 'documents') | ||||
|   } | ||||
| @@ -69,6 +72,9 @@ export class DocumentService extends AbstractPaperlessService<PaperlessDocument> | ||||
|     if (doc.tags) { | ||||
|       doc.tags$ = this.tagService.getCachedMany(doc.tags) | ||||
|     } | ||||
|     if (doc.storage_path) { | ||||
|       doc.storage_path$ = this.storagePathService.getCached(doc.storage_path) | ||||
|     } | ||||
|     return doc | ||||
|   } | ||||
|  | ||||
|   | ||||
							
								
								
									
										13
									
								
								src-ui/src/app/services/rest/storage-path.service.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										13
									
								
								src-ui/src/app/services/rest/storage-path.service.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,13 @@ | ||||
| import { HttpClient } from '@angular/common/http' | ||||
| import { Injectable } from '@angular/core' | ||||
| import { PaperlessStoragePath } from 'src/app/data/paperless-storage-path' | ||||
| import { AbstractNameFilterService } from './abstract-name-filter-service' | ||||
|  | ||||
| @Injectable({ | ||||
|   providedIn: 'root', | ||||
| }) | ||||
| export class StoragePathService extends AbstractNameFilterService<PaperlessStoragePath> { | ||||
|   constructor(http: HttpClient) { | ||||
|     super(http, 'storage_paths') | ||||
|   } | ||||
| } | ||||
| @@ -198,15 +198,11 @@ a, a:hover, .btn-link, .btn-link:hover { | ||||
|     min-height: calc(1.5em + 0.75rem + 5px); | ||||
|     line-height: 1.5; | ||||
|  | ||||
|     .ng-select-container { | ||||
|       height: 100%; | ||||
|       border-top-right-radius: 0; | ||||
|       border-bottom-right-radius: 0; | ||||
|     .ng-select-container .ng-value-container .ng-input { | ||||
|       top: 7px; | ||||
|     } | ||||
|  | ||||
|       .ng-value-container .ng-input { | ||||
|         top: 10px; | ||||
|       } | ||||
|     } | ||||
|     .paperless-input-select .ng-select .ng-select-container | ||||
|  | ||||
|     .ng-dropdown-panel .ng-dropdown-panel-items .ng-option.ng-option-marked { | ||||
|       background-color: var(--pngx-bg-darker) !important; | ||||
| @@ -218,6 +214,14 @@ a, a:hover, .btn-link, .btn-link:hover { | ||||
|       background: none; | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   .input-group { | ||||
|     .ng-select-container { | ||||
|       height: 100%; | ||||
|       border-top-right-radius: 0; | ||||
|       border-bottom-right-radius: 0; | ||||
|     } | ||||
|   } | ||||
| } | ||||
|  | ||||
| .paperless-input-tags { | ||||
| @@ -506,3 +510,7 @@ a.badge { | ||||
|     background-color: var(--bs-primary); | ||||
|     border-color: var(--bs-primary); | ||||
| } | ||||
|  | ||||
| code { | ||||
|   color: var(--pngx-body-color-accent) | ||||
| } | ||||
|   | ||||
| @@ -5,6 +5,7 @@ from .models import Document | ||||
| from .models import DocumentType | ||||
| from .models import SavedView | ||||
| from .models import SavedViewFilterRule | ||||
| from .models import StoragePath | ||||
| from .models import Tag | ||||
|  | ||||
|  | ||||
| @@ -100,8 +101,19 @@ class SavedViewAdmin(admin.ModelAdmin): | ||||
|     inlines = [RuleInline] | ||||
|  | ||||
|  | ||||
| class StoragePathInline(admin.TabularInline): | ||||
|     model = StoragePath | ||||
|  | ||||
|  | ||||
| class StoragePathAdmin(admin.ModelAdmin): | ||||
|     list_display = ("name", "path", "match", "matching_algorithm") | ||||
|     list_filter = ("path", "matching_algorithm") | ||||
|     list_editable = ("path", "match", "matching_algorithm") | ||||
|  | ||||
|  | ||||
| admin.site.register(Correspondent, CorrespondentAdmin) | ||||
| admin.site.register(Tag, TagAdmin) | ||||
| admin.site.register(DocumentType, DocumentTypeAdmin) | ||||
| admin.site.register(Document, DocumentAdmin) | ||||
| admin.site.register(SavedView, SavedViewAdmin) | ||||
| admin.site.register(StoragePath, StoragePathAdmin) | ||||
|   | ||||
| @@ -16,6 +16,7 @@ class DocumentsConfig(AppConfig): | ||||
|             set_correspondent, | ||||
|             set_document_type, | ||||
|             set_tags, | ||||
|             set_storage_path, | ||||
|             add_to_index, | ||||
|         ) | ||||
|  | ||||
| @@ -23,6 +24,7 @@ class DocumentsConfig(AppConfig): | ||||
|         document_consumption_finished.connect(set_correspondent) | ||||
|         document_consumption_finished.connect(set_document_type) | ||||
|         document_consumption_finished.connect(set_tags) | ||||
|         document_consumption_finished.connect(set_storage_path) | ||||
|         document_consumption_finished.connect(set_log_entry) | ||||
|         document_consumption_finished.connect(add_to_index) | ||||
|  | ||||
|   | ||||
| @@ -5,6 +5,7 @@ from django_q.tasks import async_task | ||||
| from documents.models import Correspondent | ||||
| from documents.models import Document | ||||
| from documents.models import DocumentType | ||||
| from documents.models import StoragePath | ||||
|  | ||||
|  | ||||
| def set_correspondent(doc_ids, correspondent): | ||||
| @@ -20,6 +21,24 @@ def set_correspondent(doc_ids, correspondent): | ||||
|     return "OK" | ||||
|  | ||||
|  | ||||
| def set_storage_path(doc_ids, storage_path): | ||||
|     if storage_path: | ||||
|         storage_path = StoragePath.objects.get(id=storage_path) | ||||
|  | ||||
|     qs = Document.objects.filter( | ||||
|         Q(id__in=doc_ids) & ~Q(storage_path=storage_path), | ||||
|     ) | ||||
|     affected_docs = [doc.id for doc in qs] | ||||
|     qs.update(storage_path=storage_path) | ||||
|  | ||||
|     async_task( | ||||
|         "documents.tasks.bulk_update_documents", | ||||
|         document_ids=affected_docs, | ||||
|     ) | ||||
|  | ||||
|     return "OK" | ||||
|  | ||||
|  | ||||
| def set_document_type(doc_ids, document_type): | ||||
|     if document_type: | ||||
|         document_type = DocumentType.objects.get(id=document_type) | ||||
|   | ||||
| @@ -59,8 +59,8 @@ def load_classifier(): | ||||
|  | ||||
| class DocumentClassifier: | ||||
|  | ||||
|     # v7 - Updated scikit-learn package version | ||||
|     FORMAT_VERSION = 7 | ||||
|     # v8 - Added storage path classifier | ||||
|     FORMAT_VERSION = 8 | ||||
|  | ||||
|     def __init__(self): | ||||
|         # hash of the training data. used to prevent re-training when the | ||||
| @@ -72,6 +72,7 @@ class DocumentClassifier: | ||||
|         self.tags_classifier = None | ||||
|         self.correspondent_classifier = None | ||||
|         self.document_type_classifier = None | ||||
|         self.storage_path_classifier = None | ||||
|  | ||||
|     def load(self): | ||||
|         with open(settings.MODEL_FILE, "rb") as f: | ||||
| @@ -90,6 +91,7 @@ class DocumentClassifier: | ||||
|                     self.tags_classifier = pickle.load(f) | ||||
|                     self.correspondent_classifier = pickle.load(f) | ||||
|                     self.document_type_classifier = pickle.load(f) | ||||
|                     self.storage_path_classifier = pickle.load(f) | ||||
|                 except Exception: | ||||
|                     raise ClassifierModelCorruptError() | ||||
|  | ||||
| @@ -107,6 +109,7 @@ class DocumentClassifier: | ||||
|             pickle.dump(self.tags_classifier, f) | ||||
|             pickle.dump(self.correspondent_classifier, f) | ||||
|             pickle.dump(self.document_type_classifier, f) | ||||
|             pickle.dump(self.storage_path_classifier, f) | ||||
|  | ||||
|         if os.path.isfile(target_file): | ||||
|             os.unlink(target_file) | ||||
| @@ -118,6 +121,7 @@ class DocumentClassifier: | ||||
|         labels_tags = list() | ||||
|         labels_correspondent = list() | ||||
|         labels_document_type = list() | ||||
|         labels_storage_path = list() | ||||
|  | ||||
|         # Step 1: Extract and preprocess training data from the database. | ||||
|         logger.debug("Gathering data from database...") | ||||
| @@ -153,6 +157,13 @@ class DocumentClassifier: | ||||
|                 m.update(tag.to_bytes(4, "little", signed=True)) | ||||
|             labels_tags.append(tags) | ||||
|  | ||||
|             y = -1 | ||||
|             sd = doc.storage_path | ||||
|             if sd and sd.matching_algorithm == MatchingModel.MATCH_AUTO: | ||||
|                 y = sd.pk | ||||
|             m.update(y.to_bytes(4, "little", signed=True)) | ||||
|             labels_storage_path.append(y) | ||||
|  | ||||
|         if not data: | ||||
|             raise ValueError("No training data available.") | ||||
|  | ||||
| @@ -172,14 +183,16 @@ class DocumentClassifier: | ||||
|         # it usually is. | ||||
|         num_correspondents = len(set(labels_correspondent) | {-1}) - 1 | ||||
|         num_document_types = len(set(labels_document_type) | {-1}) - 1 | ||||
|         num_storage_paths = len(set(labels_storage_path) | {-1}) - 1 | ||||
|  | ||||
|         logger.debug( | ||||
|             "{} documents, {} tag(s), {} correspondent(s), " | ||||
|             "{} document type(s).".format( | ||||
|             "{} document type(s). {} storage path(es)".format( | ||||
|                 len(data), | ||||
|                 num_tags, | ||||
|                 num_correspondents, | ||||
|                 num_document_types, | ||||
|                 num_storage_paths, | ||||
|             ), | ||||
|         ) | ||||
|  | ||||
| @@ -242,6 +255,21 @@ class DocumentClassifier: | ||||
|                 "classifier.", | ||||
|             ) | ||||
|  | ||||
|         if num_storage_paths > 0: | ||||
|             logger.debug( | ||||
|                 "Training storage paths classifier...", | ||||
|             ) | ||||
|             self.storage_path_classifier = MLPClassifier(tol=0.01) | ||||
|             self.storage_path_classifier.fit( | ||||
|                 data_vectorized, | ||||
|                 labels_storage_path, | ||||
|             ) | ||||
|         else: | ||||
|             self.storage_path_classifier = None | ||||
|             logger.debug( | ||||
|                 "There are no storage paths. Not training storage path classifier.", | ||||
|             ) | ||||
|  | ||||
|         self.data_hash = new_data_hash | ||||
|  | ||||
|         return True | ||||
| @@ -288,3 +316,14 @@ class DocumentClassifier: | ||||
|                 return [] | ||||
|         else: | ||||
|             return [] | ||||
|  | ||||
|     def predict_storage_path(self, content): | ||||
|         if self.storage_path_classifier: | ||||
|             X = self.data_vectorizer.transform([preprocess_content(content)]) | ||||
|             storage_path_id = self.storage_path_classifier.predict(X) | ||||
|             if storage_path_id != -1: | ||||
|                 return storage_path_id | ||||
|             else: | ||||
|                 return None | ||||
|         else: | ||||
|             return None | ||||
|   | ||||
| @@ -128,13 +128,26 @@ def generate_unique_filename(doc, archive_filename=False): | ||||
|  | ||||
| def generate_filename(doc, counter=0, append_gpg=True, archive_filename=False): | ||||
|     path = "" | ||||
|     filename_format = settings.FILENAME_FORMAT | ||||
|  | ||||
|     try: | ||||
|         if settings.PAPERLESS_FILENAME_FORMAT is not None: | ||||
|             tags = defaultdictNoStr(lambda: slugify(None), many_to_dictionary(doc.tags)) | ||||
|         if doc.storage_path is not None: | ||||
|             logger.debug( | ||||
|                 f"Document has storage_path {doc.storage_path.id} " | ||||
|                 f"({doc.storage_path.path}) set", | ||||
|             ) | ||||
|             filename_format = doc.storage_path.path | ||||
|  | ||||
|         if filename_format is not None: | ||||
|             tags = defaultdictNoStr( | ||||
|                 lambda: slugify(None), | ||||
|                 many_to_dictionary(doc.tags), | ||||
|             ) | ||||
|  | ||||
|             tag_list = pathvalidate.sanitize_filename( | ||||
|                 ",".join(sorted(tag.name for tag in doc.tags.all())), | ||||
|                 ",".join( | ||||
|                     sorted(tag.name for tag in doc.tags.all()), | ||||
|                 ), | ||||
|                 replacement_text="-", | ||||
|             ) | ||||
|  | ||||
| @@ -144,7 +157,7 @@ def generate_filename(doc, counter=0, append_gpg=True, archive_filename=False): | ||||
|                     replacement_text="-", | ||||
|                 ) | ||||
|             else: | ||||
|                 correspondent = "none" | ||||
|                 correspondent = "-none-" | ||||
|  | ||||
|             if doc.document_type: | ||||
|                 document_type = pathvalidate.sanitize_filename( | ||||
| @@ -152,18 +165,18 @@ def generate_filename(doc, counter=0, append_gpg=True, archive_filename=False): | ||||
|                     replacement_text="-", | ||||
|                 ) | ||||
|             else: | ||||
|                 document_type = "none" | ||||
|                 document_type = "-none-" | ||||
|  | ||||
|             if doc.archive_serial_number: | ||||
|                 asn = str(doc.archive_serial_number) | ||||
|             else: | ||||
|                 asn = "none" | ||||
|                 asn = "-none-" | ||||
|  | ||||
|             # Convert UTC database date to localized date | ||||
|             local_added = timezone.localdate(doc.added) | ||||
|             local_created = timezone.localdate(doc.created) | ||||
|  | ||||
|             path = settings.PAPERLESS_FILENAME_FORMAT.format( | ||||
|             path = filename_format.format( | ||||
|                 title=pathvalidate.sanitize_filename(doc.title, replacement_text="-"), | ||||
|                 correspondent=correspondent, | ||||
|                 document_type=document_type, | ||||
| @@ -180,12 +193,17 @@ def generate_filename(doc, counter=0, append_gpg=True, archive_filename=False): | ||||
|                 tag_list=tag_list, | ||||
|             ).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 occurences | ||||
|  | ||||
|             path = path.replace("-none-", "none")  # backward compatibility | ||||
|             path = path.strip(os.sep) | ||||
|  | ||||
|     except (ValueError, KeyError, IndexError): | ||||
|         logger.warning( | ||||
|             f"Invalid PAPERLESS_FILENAME_FORMAT: " | ||||
|             f"{settings.PAPERLESS_FILENAME_FORMAT}, falling back to default", | ||||
|             f"Invalid filename_format '{filename_format}', falling back to default", | ||||
|         ) | ||||
|  | ||||
|     counter_str = f"_{counter:02}" if counter else "" | ||||
|   | ||||
| @@ -7,6 +7,7 @@ from .models import Correspondent | ||||
| from .models import Document | ||||
| from .models import DocumentType | ||||
| from .models import Log | ||||
| from .models import StoragePath | ||||
| from .models import Tag | ||||
|  | ||||
| CHAR_KWARGS = ["istartswith", "iendswith", "icontains", "iexact"] | ||||
| @@ -114,6 +115,9 @@ class DocumentFilterSet(FilterSet): | ||||
|             "document_type": ["isnull"], | ||||
|             "document_type__id": ID_KWARGS, | ||||
|             "document_type__name": CHAR_KWARGS, | ||||
|             "storage_path": ["isnull"], | ||||
|             "storage_path__id": ID_KWARGS, | ||||
|             "storage_path__name": CHAR_KWARGS, | ||||
|         } | ||||
|  | ||||
|  | ||||
| @@ -121,3 +125,12 @@ class LogFilterSet(FilterSet): | ||||
|     class Meta: | ||||
|         model = Log | ||||
|         fields = {"level": INT_KWARGS, "created": DATE_KWARGS, "group": ID_KWARGS} | ||||
|  | ||||
|  | ||||
| class StoragePathFilterSet(FilterSet): | ||||
|     class Meta: | ||||
|         model = StoragePath | ||||
|         fields = { | ||||
|             "name": CHAR_KWARGS, | ||||
|             "path": CHAR_KWARGS, | ||||
|         } | ||||
|   | ||||
| @@ -46,6 +46,9 @@ def get_schema(): | ||||
|         created=DATETIME(sortable=True), | ||||
|         modified=DATETIME(sortable=True), | ||||
|         added=DATETIME(sortable=True), | ||||
|         path=TEXT(sortable=True), | ||||
|         path_id=NUMERIC(), | ||||
|         has_path=BOOLEAN(), | ||||
|     ) | ||||
|  | ||||
|  | ||||
| @@ -104,6 +107,9 @@ def update_document(writer, doc): | ||||
|         added=doc.added, | ||||
|         asn=doc.archive_serial_number, | ||||
|         modified=doc.modified, | ||||
|         path=doc.storage_path.name if doc.storage_path else None, | ||||
|         path_id=doc.storage_path.id if doc.storage_path else None, | ||||
|         has_path=doc.storage_path is not None, | ||||
|     ) | ||||
|  | ||||
|  | ||||
| @@ -157,6 +163,11 @@ class DelayedQuery: | ||||
|                 criterias.append(query.DateRange("added", start=isoparse(v), end=None)) | ||||
|             elif k == "added__date__lt": | ||||
|                 criterias.append(query.DateRange("added", start=None, end=isoparse(v))) | ||||
|             elif k == "storage_path__id": | ||||
|                 criterias.append(query.Term("path_id", v)) | ||||
|             elif k == "storage_path__isnull": | ||||
|                 criterias.append(query.Term("has_path", v == "false")) | ||||
|  | ||||
|         if len(criterias) > 0: | ||||
|             return query.And(criterias) | ||||
|         else: | ||||
|   | ||||
| @@ -152,4 +152,4 @@ class Command(BaseCommand): | ||||
|                     ), | ||||
|                 ) | ||||
|         except KeyboardInterrupt: | ||||
|             self.stdout.write(self.style.NOTICE(("Aborting..."))) | ||||
|             self.stdout.write(self.style.NOTICE("Aborting...")) | ||||
|   | ||||
| @@ -4,6 +4,7 @@ import re | ||||
| from documents.models import Correspondent | ||||
| from documents.models import DocumentType | ||||
| from documents.models import MatchingModel | ||||
| from documents.models import StoragePath | ||||
| from documents.models import Tag | ||||
|  | ||||
|  | ||||
| @@ -57,6 +58,22 @@ def match_tags(document, classifier): | ||||
|     ) | ||||
|  | ||||
|  | ||||
| def match_storage_paths(document, classifier): | ||||
|     if classifier: | ||||
|         pred_id = classifier.predict_storage_path(document.content) | ||||
|     else: | ||||
|         pred_id = None | ||||
|  | ||||
|     storage_paths = StoragePath.objects.all() | ||||
|  | ||||
|     return list( | ||||
|         filter( | ||||
|             lambda o: matches(o, document) or o.pk == pred_id, | ||||
|             storage_paths, | ||||
|         ), | ||||
|     ) | ||||
|  | ||||
|  | ||||
| def matches(matching_model, document): | ||||
|     search_kwargs = {} | ||||
|  | ||||
|   | ||||
| @@ -83,7 +83,7 @@ def generate_filename(doc, counter=0, append_gpg=True, archive_filename=False): | ||||
|     path = "" | ||||
|  | ||||
|     try: | ||||
|         if settings.PAPERLESS_FILENAME_FORMAT is not None: | ||||
|         if settings.FILENAME_FORMAT is not None: | ||||
|             tags = defaultdictNoStr(lambda: slugify(None), many_to_dictionary(doc.tags)) | ||||
|  | ||||
|             tag_list = pathvalidate.sanitize_filename( | ||||
| @@ -105,7 +105,7 @@ def generate_filename(doc, counter=0, append_gpg=True, archive_filename=False): | ||||
|             else: | ||||
|                 document_type = "none" | ||||
|  | ||||
|             path = settings.PAPERLESS_FILENAME_FORMAT.format( | ||||
|             path = settings.FILENAME_FORMAT.format( | ||||
|                 title=pathvalidate.sanitize_filename(doc.title, replacement_text="-"), | ||||
|                 correspondent=correspondent, | ||||
|                 document_type=document_type, | ||||
| @@ -128,7 +128,7 @@ def generate_filename(doc, counter=0, append_gpg=True, archive_filename=False): | ||||
|     except (ValueError, KeyError, IndexError): | ||||
|         logger.warning( | ||||
|             f"Invalid PAPERLESS_FILENAME_FORMAT: " | ||||
|             f"{settings.PAPERLESS_FILENAME_FORMAT}, falling back to default" | ||||
|             f"{settings.FILENAME_FORMAT}, falling back to default" | ||||
|         ) | ||||
|  | ||||
|     counter_str = f"_{counter:02}" if counter else "" | ||||
|   | ||||
| @@ -0,0 +1,73 @@ | ||||
| # Generated by Django 4.0.4 on 2022-05-02 15:56 | ||||
|  | ||||
| from django.db import migrations, models | ||||
| import django.db.models.deletion | ||||
|  | ||||
|  | ||||
| class Migration(migrations.Migration): | ||||
|  | ||||
|     dependencies = [ | ||||
|         ("documents", "1018_alter_savedviewfilterrule_value"), | ||||
|     ] | ||||
|  | ||||
|     operations = [ | ||||
|         migrations.CreateModel( | ||||
|             name="StoragePath", | ||||
|             fields=[ | ||||
|                 ( | ||||
|                     "id", | ||||
|                     models.AutoField( | ||||
|                         auto_created=True, | ||||
|                         primary_key=True, | ||||
|                         serialize=False, | ||||
|                         verbose_name="ID", | ||||
|                     ), | ||||
|                 ), | ||||
|                 ( | ||||
|                     "name", | ||||
|                     models.CharField(max_length=128, unique=True, verbose_name="name"), | ||||
|                 ), | ||||
|                 ( | ||||
|                     "match", | ||||
|                     models.CharField(blank=True, max_length=256, verbose_name="match"), | ||||
|                 ), | ||||
|                 ( | ||||
|                     "matching_algorithm", | ||||
|                     models.PositiveIntegerField( | ||||
|                         choices=[ | ||||
|                             (1, "Any word"), | ||||
|                             (2, "All words"), | ||||
|                             (3, "Exact match"), | ||||
|                             (4, "Regular expression"), | ||||
|                             (5, "Fuzzy word"), | ||||
|                             (6, "Automatic"), | ||||
|                         ], | ||||
|                         default=1, | ||||
|                         verbose_name="matching algorithm", | ||||
|                     ), | ||||
|                 ), | ||||
|                 ( | ||||
|                     "is_insensitive", | ||||
|                     models.BooleanField(default=True, verbose_name="is insensitive"), | ||||
|                 ), | ||||
|                 ("path", models.CharField(max_length=512, verbose_name="path")), | ||||
|             ], | ||||
|             options={ | ||||
|                 "verbose_name": "storage path", | ||||
|                 "verbose_name_plural": "storage paths", | ||||
|                 "ordering": ("name",), | ||||
|             }, | ||||
|         ), | ||||
|         migrations.AddField( | ||||
|             model_name="document", | ||||
|             name="storage_path", | ||||
|             field=models.ForeignKey( | ||||
|                 blank=True, | ||||
|                 null=True, | ||||
|                 on_delete=django.db.models.deletion.SET_NULL, | ||||
|                 related_name="documents", | ||||
|                 to="documents.storagepath", | ||||
|                 verbose_name="storage path", | ||||
|             ), | ||||
|         ), | ||||
|     ] | ||||
							
								
								
									
										13
									
								
								src/documents/migrations/1020_merge_20220518_1839.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										13
									
								
								src/documents/migrations/1020_merge_20220518_1839.py
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,13 @@ | ||||
| # Generated by Django 4.0.4 on 2022-05-18 18:39 | ||||
|  | ||||
| from django.db import migrations | ||||
|  | ||||
|  | ||||
| class Migration(migrations.Migration): | ||||
|  | ||||
|     dependencies = [ | ||||
|         ("documents", "1019_storagepath_document_storage_path"), | ||||
|         ("documents", "1019_uisettings"), | ||||
|     ] | ||||
|  | ||||
|     operations = [] | ||||
| @@ -83,6 +83,18 @@ class DocumentType(MatchingModel): | ||||
|         verbose_name_plural = _("document types") | ||||
|  | ||||
|  | ||||
| class StoragePath(MatchingModel): | ||||
|     path = models.CharField( | ||||
|         _("path"), | ||||
|         max_length=512, | ||||
|     ) | ||||
|  | ||||
|     class Meta: | ||||
|         ordering = ("name",) | ||||
|         verbose_name = _("storage path") | ||||
|         verbose_name_plural = _("storage paths") | ||||
|  | ||||
|  | ||||
| class Document(models.Model): | ||||
|  | ||||
|     STORAGE_TYPE_UNENCRYPTED = "unencrypted" | ||||
| @@ -101,6 +113,15 @@ class Document(models.Model): | ||||
|         verbose_name=_("correspondent"), | ||||
|     ) | ||||
|  | ||||
|     storage_path = models.ForeignKey( | ||||
|         StoragePath, | ||||
|         blank=True, | ||||
|         null=True, | ||||
|         related_name="documents", | ||||
|         on_delete=models.SET_NULL, | ||||
|         verbose_name=_("storage path"), | ||||
|     ) | ||||
|  | ||||
|     title = models.CharField(_("title"), max_length=128, blank=True, db_index=True) | ||||
|  | ||||
|     document_type = models.ForeignKey( | ||||
|   | ||||
| @@ -14,6 +14,7 @@ from .models import DocumentType | ||||
| from .models import MatchingModel | ||||
| from .models import SavedView | ||||
| from .models import SavedViewFilterRule | ||||
| from .models import StoragePath | ||||
| from .models import Tag | ||||
| from .models import UiSettings | ||||
| from .parsers import is_mime_type_supported | ||||
| @@ -199,11 +200,17 @@ class DocumentTypeField(serializers.PrimaryKeyRelatedField): | ||||
|         return DocumentType.objects.all() | ||||
|  | ||||
|  | ||||
| class StoragePathField(serializers.PrimaryKeyRelatedField): | ||||
|     def get_queryset(self): | ||||
|         return StoragePath.objects.all() | ||||
|  | ||||
|  | ||||
| class DocumentSerializer(DynamicFieldsModelSerializer): | ||||
|  | ||||
|     correspondent = CorrespondentField(allow_null=True) | ||||
|     tags = TagsField(many=True) | ||||
|     document_type = DocumentTypeField(allow_null=True) | ||||
|     storage_path = StoragePathField(allow_null=True) | ||||
|  | ||||
|     original_file_name = SerializerMethodField() | ||||
|     archived_file_name = SerializerMethodField() | ||||
| @@ -224,6 +231,7 @@ class DocumentSerializer(DynamicFieldsModelSerializer): | ||||
|             "id", | ||||
|             "correspondent", | ||||
|             "document_type", | ||||
|             "storage_path", | ||||
|             "title", | ||||
|             "content", | ||||
|             "tags", | ||||
| @@ -310,6 +318,7 @@ class BulkEditSerializer(DocumentListSerializer): | ||||
|         choices=[ | ||||
|             "set_correspondent", | ||||
|             "set_document_type", | ||||
|             "set_storage_path", | ||||
|             "add_tag", | ||||
|             "remove_tag", | ||||
|             "modify_tags", | ||||
| @@ -337,6 +346,8 @@ class BulkEditSerializer(DocumentListSerializer): | ||||
|             return bulk_edit.set_correspondent | ||||
|         elif method == "set_document_type": | ||||
|             return bulk_edit.set_document_type | ||||
|         elif method == "set_storage_path": | ||||
|             return bulk_edit.set_storage_path | ||||
|         elif method == "add_tag": | ||||
|             return bulk_edit.add_tag | ||||
|         elif method == "remove_tag": | ||||
| @@ -383,6 +394,20 @@ class BulkEditSerializer(DocumentListSerializer): | ||||
|         else: | ||||
|             raise serializers.ValidationError("correspondent not specified") | ||||
|  | ||||
|     def _validate_storage_path(self, parameters): | ||||
|         if "storage_path" in parameters: | ||||
|             storage_path_id = parameters["storage_path"] | ||||
|             if storage_path_id is None: | ||||
|                 return | ||||
|             try: | ||||
|                 StoragePath.objects.get(id=storage_path_id) | ||||
|             except StoragePath.DoesNotExist: | ||||
|                 raise serializers.ValidationError( | ||||
|                     "Storage path does not exist", | ||||
|                 ) | ||||
|         else: | ||||
|             raise serializers.ValidationError("storage path not specified") | ||||
|  | ||||
|     def _validate_parameters_modify_tags(self, parameters): | ||||
|         if "add_tags" in parameters: | ||||
|             self._validate_tag_id_list(parameters["add_tags"], "add_tags") | ||||
| @@ -407,6 +432,8 @@ class BulkEditSerializer(DocumentListSerializer): | ||||
|             self._validate_parameters_tags(parameters) | ||||
|         elif method == bulk_edit.modify_tags: | ||||
|             self._validate_parameters_modify_tags(parameters) | ||||
|         elif method == bulk_edit.set_storage_path: | ||||
|             self._validate_storage_path(parameters) | ||||
|  | ||||
|         return attrs | ||||
|  | ||||
| @@ -508,6 +535,47 @@ class BulkDownloadSerializer(DocumentListSerializer): | ||||
|         }[compression] | ||||
|  | ||||
|  | ||||
| class StoragePathSerializer(MatchingModelSerializer): | ||||
|     document_count = serializers.IntegerField(read_only=True) | ||||
|  | ||||
|     class Meta: | ||||
|         model = StoragePath | ||||
|         fields = ( | ||||
|             "id", | ||||
|             "slug", | ||||
|             "name", | ||||
|             "path", | ||||
|             "match", | ||||
|             "matching_algorithm", | ||||
|             "is_insensitive", | ||||
|             "document_count", | ||||
|         ) | ||||
|  | ||||
|     def validate_path(self, path): | ||||
|         try: | ||||
|             path.format( | ||||
|                 title="title", | ||||
|                 correspondent="correspondent", | ||||
|                 document_type="document_type", | ||||
|                 created="created", | ||||
|                 created_year="created_year", | ||||
|                 created_month="created_month", | ||||
|                 created_day="created_day", | ||||
|                 added="added", | ||||
|                 added_year="added_year", | ||||
|                 added_month="added_month", | ||||
|                 added_day="added_day", | ||||
|                 asn="asn", | ||||
|                 tags="tags", | ||||
|                 tag_list="tag_list", | ||||
|             ) | ||||
|  | ||||
|         except (KeyError): | ||||
|             raise serializers.ValidationError(_("Invalid variable detected.")) | ||||
|  | ||||
|         return path | ||||
|  | ||||
|  | ||||
| class UiSettingsViewSerializer(serializers.ModelSerializer): | ||||
|     class Meta: | ||||
|         model = UiSettings | ||||
|   | ||||
| @@ -230,6 +230,76 @@ def set_tags( | ||||
|         document.tags.add(*relevant_tags) | ||||
|  | ||||
|  | ||||
| def set_storage_path( | ||||
|     sender, | ||||
|     document=None, | ||||
|     logging_group=None, | ||||
|     classifier=None, | ||||
|     replace=False, | ||||
|     use_first=True, | ||||
|     suggest=False, | ||||
|     base_url=None, | ||||
|     color=False, | ||||
|     **kwargs, | ||||
| ): | ||||
|     if document.storage_path and not replace: | ||||
|         return | ||||
|  | ||||
|     potential_storage_path = matching.match_storage_paths( | ||||
|         document, | ||||
|         classifier, | ||||
|     ) | ||||
|  | ||||
|     potential_count = len(potential_storage_path) | ||||
|     if potential_storage_path: | ||||
|         selected = potential_storage_path[0] | ||||
|     else: | ||||
|         selected = None | ||||
|  | ||||
|     if potential_count > 1: | ||||
|         if use_first: | ||||
|             logger.info( | ||||
|                 f"Detected {potential_count} potential storage paths, " | ||||
|                 f"so we've opted for {selected}", | ||||
|                 extra={"group": logging_group}, | ||||
|             ) | ||||
|         else: | ||||
|             logger.info( | ||||
|                 f"Detected {potential_count} potential storage paths, " | ||||
|                 f"not assigning any storage directory", | ||||
|                 extra={"group": logging_group}, | ||||
|             ) | ||||
|             return | ||||
|  | ||||
|     if selected or replace: | ||||
|         if suggest: | ||||
|             if base_url: | ||||
|                 print( | ||||
|                     termcolors.colorize(str(document), fg="green") | ||||
|                     if color | ||||
|                     else str(document), | ||||
|                 ) | ||||
|                 print(f"{base_url}/documents/{document.pk}") | ||||
|             else: | ||||
|                 print( | ||||
|                     ( | ||||
|                         termcolors.colorize(str(document), fg="green") | ||||
|                         if color | ||||
|                         else str(document) | ||||
|                     ) | ||||
|                     + f" [{document.pk}]", | ||||
|                 ) | ||||
|             print(f"Sugest storage directory {selected}") | ||||
|         else: | ||||
|             logger.info( | ||||
|                 f"Assigning storage path {selected} to {document}", | ||||
|                 extra={"group": logging_group}, | ||||
|             ) | ||||
|  | ||||
|             document.storage_path = selected | ||||
|             document.save(update_fields=("storage_path",)) | ||||
|  | ||||
|  | ||||
| @receiver(models.signals.post_delete, sender=Document) | ||||
| def cleanup_document_deletion(sender, instance, using, **kwargs): | ||||
|     with FileLock(settings.MEDIA_LOCK): | ||||
|   | ||||
| @@ -19,6 +19,7 @@ from documents.consumer import ConsumerError | ||||
| from documents.models import Correspondent | ||||
| from documents.models import Document | ||||
| from documents.models import DocumentType | ||||
| from documents.models import StoragePath | ||||
| from documents.models import Tag | ||||
| from documents.sanity_checker import SanityCheckFailedException | ||||
| from pdf2image import convert_from_path | ||||
| @@ -53,6 +54,7 @@ def train_classifier(): | ||||
|         not Tag.objects.filter(matching_algorithm=Tag.MATCH_AUTO).exists() | ||||
|         and not DocumentType.objects.filter(matching_algorithm=Tag.MATCH_AUTO).exists() | ||||
|         and not Correspondent.objects.filter(matching_algorithm=Tag.MATCH_AUTO).exists() | ||||
|         and not StoragePath.objects.filter(matching_algorithm=Tag.MATCH_AUTO).exists() | ||||
|     ): | ||||
|  | ||||
|         return | ||||
|   | ||||
										
											Binary file not shown.
										
									
								
							| @@ -26,8 +26,10 @@ from documents.models import Document | ||||
| from documents.models import DocumentType | ||||
| from documents.models import MatchingModel | ||||
| from documents.models import SavedView | ||||
| from documents.models import StoragePath | ||||
| from documents.models import Tag | ||||
| from documents.models import UiSettings | ||||
| from documents.models import StoragePath | ||||
| from documents.tests.utils import DirectoriesMixin | ||||
| from paperless import version | ||||
| from rest_framework.test import APITestCase | ||||
| @@ -99,6 +101,7 @@ class TestDocumentApi(DirectoriesMixin, APITestCase): | ||||
|         c = Correspondent.objects.create(name="c", pk=41) | ||||
|         dt = DocumentType.objects.create(name="dt", pk=63) | ||||
|         tag = Tag.objects.create(name="t", pk=85) | ||||
|         storage_path = StoragePath.objects.create(name="sp", pk=77, path="p") | ||||
|         doc = Document.objects.create( | ||||
|             title="WOW", | ||||
|             content="the content", | ||||
| @@ -106,6 +109,7 @@ class TestDocumentApi(DirectoriesMixin, APITestCase): | ||||
|             document_type=dt, | ||||
|             checksum="123", | ||||
|             mime_type="application/pdf", | ||||
|             storage_path=storage_path, | ||||
|         ) | ||||
|  | ||||
|         response = self.client.get("/api/documents/", format="json") | ||||
| @@ -192,7 +196,7 @@ class TestDocumentApi(DirectoriesMixin, APITestCase): | ||||
|         self.assertEqual(response.status_code, 200) | ||||
|         self.assertEqual(response.content, content_thumbnail) | ||||
|  | ||||
|     @override_settings(PAPERLESS_FILENAME_FORMAT="") | ||||
|     @override_settings(FILENAME_FORMAT="") | ||||
|     def test_download_with_archive(self): | ||||
|  | ||||
|         content = b"This is a test" | ||||
| @@ -580,10 +584,12 @@ class TestDocumentApi(DirectoriesMixin, APITestCase): | ||||
|         t2 = Tag.objects.create(name="tag2") | ||||
|         c = Correspondent.objects.create(name="correspondent") | ||||
|         dt = DocumentType.objects.create(name="type") | ||||
|         sp = StoragePath.objects.create(name="path") | ||||
|  | ||||
|         d1 = Document.objects.create(checksum="1", correspondent=c, content="test") | ||||
|         d2 = Document.objects.create(checksum="2", document_type=dt, content="test") | ||||
|         d3 = Document.objects.create(checksum="3", content="test") | ||||
|  | ||||
|         d3.tags.add(t) | ||||
|         d3.tags.add(t2) | ||||
|         d4 = Document.objects.create( | ||||
| @@ -598,6 +604,7 @@ class TestDocumentApi(DirectoriesMixin, APITestCase): | ||||
|             content="test", | ||||
|         ) | ||||
|         d6 = Document.objects.create(checksum="6", content="test2") | ||||
|         d7 = Document.objects.create(checksum="7", storage_path=sp, content="test") | ||||
|  | ||||
|         with AsyncWriter(index.open_index()) as writer: | ||||
|             for doc in Document.objects.all(): | ||||
| @@ -608,18 +615,30 @@ class TestDocumentApi(DirectoriesMixin, APITestCase): | ||||
|             self.assertEqual(r.status_code, 200) | ||||
|             return [hit["id"] for hit in r.data["results"]] | ||||
|  | ||||
|         self.assertCountEqual(search_query(""), [d1.id, d2.id, d3.id, d4.id, d5.id]) | ||||
|         self.assertCountEqual( | ||||
|             search_query(""), | ||||
|             [d1.id, d2.id, d3.id, d4.id, d5.id, d7.id], | ||||
|         ) | ||||
|         self.assertCountEqual(search_query("&is_tagged=true"), [d3.id, d4.id]) | ||||
|         self.assertCountEqual(search_query("&is_tagged=false"), [d1.id, d2.id, d5.id]) | ||||
|         self.assertCountEqual( | ||||
|             search_query("&is_tagged=false"), | ||||
|             [d1.id, d2.id, d5.id, d7.id], | ||||
|         ) | ||||
|         self.assertCountEqual(search_query("&correspondent__id=" + str(c.id)), [d1.id]) | ||||
|         self.assertCountEqual(search_query("&document_type__id=" + str(dt.id)), [d2.id]) | ||||
|         self.assertCountEqual(search_query("&storage_path__id=" + str(sp.id)), [d7.id]) | ||||
|  | ||||
|         self.assertCountEqual( | ||||
|             search_query("&storage_path__isnull"), | ||||
|             [d1.id, d2.id, d3.id, d4.id, d5.id], | ||||
|         ) | ||||
|         self.assertCountEqual( | ||||
|             search_query("&correspondent__isnull"), | ||||
|             [d2.id, d3.id, d4.id, d5.id], | ||||
|             [d2.id, d3.id, d4.id, d5.id, d7.id], | ||||
|         ) | ||||
|         self.assertCountEqual( | ||||
|             search_query("&document_type__isnull"), | ||||
|             [d1.id, d3.id, d4.id, d5.id], | ||||
|             [d1.id, d3.id, d4.id, d5.id, d7.id], | ||||
|         ) | ||||
|         self.assertCountEqual( | ||||
|             search_query("&tags__id__all=" + str(t.id) + "," + str(t2.id)), | ||||
| @@ -1080,35 +1099,49 @@ class TestDocumentApi(DirectoriesMixin, APITestCase): | ||||
|         self.assertEqual(response.status_code, 200) | ||||
|         self.assertEqual( | ||||
|             response.data, | ||||
|             {"correspondents": [], "tags": [], "document_types": []}, | ||||
|             { | ||||
|                 "correspondents": [], | ||||
|                 "tags": [], | ||||
|                 "document_types": [], | ||||
|                 "storage_paths": [], | ||||
|             }, | ||||
|         ) | ||||
|  | ||||
|     def test_get_suggestions_invalid_doc(self): | ||||
|         response = self.client.get(f"/api/documents/34676/suggestions/") | ||||
|         self.assertEqual(response.status_code, 404) | ||||
|  | ||||
|     @mock.patch("documents.views.match_correspondents") | ||||
|     @mock.patch("documents.views.match_tags") | ||||
|     @mock.patch("documents.views.match_storage_paths") | ||||
|     @mock.patch("documents.views.match_document_types") | ||||
|     @mock.patch("documents.views.match_tags") | ||||
|     @mock.patch("documents.views.match_correspondents") | ||||
|     def test_get_suggestions( | ||||
|         self, | ||||
|         match_document_types, | ||||
|         match_tags, | ||||
|         match_correspondents, | ||||
|         match_tags, | ||||
|         match_document_types, | ||||
|         match_storage_paths, | ||||
|     ): | ||||
|         doc = Document.objects.create( | ||||
|             title="test", | ||||
|             mime_type="application/pdf", | ||||
|             content="this is an invoice!", | ||||
|         ) | ||||
|  | ||||
|         match_correspondents.return_value = [Correspondent(id=88), Correspondent(id=2)] | ||||
|         match_tags.return_value = [Tag(id=56), Tag(id=123)] | ||||
|         match_document_types.return_value = [DocumentType(id=23)] | ||||
|         match_correspondents.return_value = [Correspondent(id=88), Correspondent(id=2)] | ||||
|         match_storage_paths.return_value = [StoragePath(id=99), StoragePath(id=77)] | ||||
|  | ||||
|         response = self.client.get(f"/api/documents/{doc.pk}/suggestions/") | ||||
|         self.assertEqual( | ||||
|             response.data, | ||||
|             {"correspondents": [88, 2], "tags": [56, 123], "document_types": [23]}, | ||||
|             { | ||||
|                 "correspondents": [88, 2], | ||||
|                 "tags": [56, 123], | ||||
|                 "document_types": [23], | ||||
|                 "storage_paths": [99, 77], | ||||
|             }, | ||||
|         ) | ||||
|  | ||||
|     def test_saved_views(self): | ||||
| @@ -1469,6 +1502,7 @@ class TestBulkEdit(DirectoriesMixin, APITestCase): | ||||
|         self.doc2.tags.add(self.t1) | ||||
|         self.doc3.tags.add(self.t2) | ||||
|         self.doc4.tags.add(self.t1, self.t2) | ||||
|         self.sp1 = StoragePath.objects.create(name="sp1", path="Something/{checksum}") | ||||
|  | ||||
|     def test_set_correspondent(self): | ||||
|         self.assertEqual(Document.objects.filter(correspondent=self.c2).count(), 1) | ||||
| @@ -1508,6 +1542,60 @@ class TestBulkEdit(DirectoriesMixin, APITestCase): | ||||
|         args, kwargs = self.async_task.call_args | ||||
|         self.assertCountEqual(kwargs["document_ids"], [self.doc2.id, self.doc3.id]) | ||||
|  | ||||
|     def test_set_document_storage_path(self): | ||||
|         """ | ||||
|         GIVEN: | ||||
|             - 5 documents without defined storage path | ||||
|         WHEN: | ||||
|             - Bulk edit called to add storage path to 1 document | ||||
|         THEN: | ||||
|             - Single document storage path update | ||||
|         """ | ||||
|         self.assertEqual(Document.objects.filter(storage_path=None).count(), 5) | ||||
|  | ||||
|         bulk_edit.set_storage_path( | ||||
|             [self.doc1.id], | ||||
|             self.sp1.id, | ||||
|         ) | ||||
|  | ||||
|         self.assertEqual(Document.objects.filter(storage_path=None).count(), 4) | ||||
|  | ||||
|         self.async_task.assert_called_once() | ||||
|         args, kwargs = self.async_task.call_args | ||||
|  | ||||
|         self.assertCountEqual(kwargs["document_ids"], [self.doc1.id]) | ||||
|  | ||||
|     def test_unset_document_storage_path(self): | ||||
|         """ | ||||
|         GIVEN: | ||||
|             - 4 documents without defined storage path | ||||
|             - 1 document with a defined storage | ||||
|         WHEN: | ||||
|             - Bulk edit called to remove storage path from 1 document | ||||
|         THEN: | ||||
|             - Single document storage path removed | ||||
|         """ | ||||
|         self.assertEqual(Document.objects.filter(storage_path=None).count(), 5) | ||||
|  | ||||
|         bulk_edit.set_storage_path( | ||||
|             [self.doc1.id], | ||||
|             self.sp1.id, | ||||
|         ) | ||||
|  | ||||
|         self.assertEqual(Document.objects.filter(storage_path=None).count(), 4) | ||||
|  | ||||
|         bulk_edit.set_storage_path( | ||||
|             [self.doc1.id], | ||||
|             None, | ||||
|         ) | ||||
|  | ||||
|         self.assertEqual(Document.objects.filter(storage_path=None).count(), 5) | ||||
|  | ||||
|         self.async_task.assert_called() | ||||
|         args, kwargs = self.async_task.call_args | ||||
|  | ||||
|         self.assertCountEqual(kwargs["document_ids"], [self.doc1.id]) | ||||
|  | ||||
|     def test_add_tag(self): | ||||
|         self.assertEqual(Document.objects.filter(tags__id=self.t1.id).count(), 2) | ||||
|         bulk_edit.add_tag( | ||||
|   | ||||
| @@ -13,6 +13,7 @@ from documents.classifier import load_classifier | ||||
| from documents.models import Correspondent | ||||
| from documents.models import Document | ||||
| from documents.models import DocumentType | ||||
| from documents.models import StoragePath | ||||
| from documents.models import Tag | ||||
| from documents.tests.utils import DirectoriesMixin | ||||
|  | ||||
| @@ -56,6 +57,16 @@ class TestClassifier(DirectoriesMixin, TestCase): | ||||
|             name="dt2", | ||||
|             matching_algorithm=DocumentType.MATCH_AUTO, | ||||
|         ) | ||||
|         self.sp1 = StoragePath.objects.create( | ||||
|             name="sp1", | ||||
|             path="path1", | ||||
|             matching_algorithm=DocumentType.MATCH_AUTO, | ||||
|         ) | ||||
|         self.sp2 = StoragePath.objects.create( | ||||
|             name="sp2", | ||||
|             path="path2", | ||||
|             matching_algorithm=DocumentType.MATCH_AUTO, | ||||
|         ) | ||||
|  | ||||
|         self.doc1 = Document.objects.create( | ||||
|             title="doc1", | ||||
| @@ -64,12 +75,14 @@ class TestClassifier(DirectoriesMixin, TestCase): | ||||
|             checksum="A", | ||||
|             document_type=self.dt, | ||||
|         ) | ||||
|  | ||||
|         self.doc2 = Document.objects.create( | ||||
|             title="doc1", | ||||
|             content="this is another document, but from c2", | ||||
|             correspondent=self.c2, | ||||
|             checksum="B", | ||||
|         ) | ||||
|  | ||||
|         self.doc_inbox = Document.objects.create( | ||||
|             title="doc235", | ||||
|             content="aa", | ||||
| @@ -81,6 +94,8 @@ class TestClassifier(DirectoriesMixin, TestCase): | ||||
|         self.doc2.tags.add(self.t3) | ||||
|         self.doc_inbox.tags.add(self.t2) | ||||
|  | ||||
|         self.doc1.storage_path = self.sp1 | ||||
|  | ||||
|     def testNoTrainingData(self): | ||||
|         try: | ||||
|             self.classifier.train() | ||||
| @@ -177,6 +192,14 @@ class TestClassifier(DirectoriesMixin, TestCase): | ||||
|         new_classifier.load() | ||||
|         self.assertFalse(new_classifier.train()) | ||||
|  | ||||
|     # @override_settings( | ||||
|     #     MODEL_FILE=os.path.join(os.path.dirname(__file__), "data", "model.pickle"), | ||||
|     # ) | ||||
|     # def test_create_test_load_and_classify(self): | ||||
|     #     self.generate_test_data() | ||||
|     #     self.classifier.train() | ||||
|     #     self.classifier.save() | ||||
|  | ||||
|     @override_settings( | ||||
|         MODEL_FILE=os.path.join(os.path.dirname(__file__), "data", "model.pickle"), | ||||
|     ) | ||||
| @@ -263,6 +286,45 @@ class TestClassifier(DirectoriesMixin, TestCase): | ||||
|         self.assertEqual(self.classifier.predict_document_type(doc1.content), dt.pk) | ||||
|         self.assertIsNone(self.classifier.predict_document_type(doc2.content)) | ||||
|  | ||||
|     def test_one_path_predict(self): | ||||
|         sp = StoragePath.objects.create( | ||||
|             name="sp", | ||||
|             matching_algorithm=StoragePath.MATCH_AUTO, | ||||
|         ) | ||||
|  | ||||
|         doc1 = Document.objects.create( | ||||
|             title="doc1", | ||||
|             content="this is a document from c1", | ||||
|             checksum="A", | ||||
|             storage_path=sp, | ||||
|         ) | ||||
|  | ||||
|         self.classifier.train() | ||||
|         self.assertEqual(self.classifier.predict_storage_path(doc1.content), sp.pk) | ||||
|  | ||||
|     def test_one_path_predict_manydocs(self): | ||||
|         sp = StoragePath.objects.create( | ||||
|             name="sp", | ||||
|             matching_algorithm=StoragePath.MATCH_AUTO, | ||||
|         ) | ||||
|  | ||||
|         doc1 = Document.objects.create( | ||||
|             title="doc1", | ||||
|             content="this is a document from c1", | ||||
|             checksum="A", | ||||
|             storage_path=sp, | ||||
|         ) | ||||
|  | ||||
|         doc2 = Document.objects.create( | ||||
|             title="doc1", | ||||
|             content="this is a document from c2", | ||||
|             checksum="B", | ||||
|         ) | ||||
|  | ||||
|         self.classifier.train() | ||||
|         self.assertEqual(self.classifier.predict_storage_path(doc1.content), sp.pk) | ||||
|         self.assertIsNone(self.classifier.predict_storage_path(doc2.content)) | ||||
|  | ||||
|     def test_one_tag_predict(self): | ||||
|         t1 = Tag.objects.create(name="t1", matching_algorithm=Tag.MATCH_AUTO, pk=12) | ||||
|  | ||||
|   | ||||
| @@ -320,7 +320,7 @@ class TestConsumer(DirectoriesMixin, TestCase): | ||||
|         shutil.copy(src, dst) | ||||
|         return dst | ||||
|  | ||||
|     @override_settings(PAPERLESS_FILENAME_FORMAT=None, TIME_ZONE="America/Chicago") | ||||
|     @override_settings(FILENAME_FORMAT=None, TIME_ZONE="America/Chicago") | ||||
|     def testNormalOperation(self): | ||||
|  | ||||
|         filename = self.get_test_file() | ||||
| @@ -351,7 +351,7 @@ class TestConsumer(DirectoriesMixin, TestCase): | ||||
|  | ||||
|         self.assertEqual(document.created.tzinfo, zoneinfo.ZoneInfo("America/Chicago")) | ||||
|  | ||||
|     @override_settings(PAPERLESS_FILENAME_FORMAT=None) | ||||
|     @override_settings(FILENAME_FORMAT=None) | ||||
|     def testDeleteMacFiles(self): | ||||
|         # https://github.com/jonaswinkler/paperless-ng/discussions/1037 | ||||
|  | ||||
| @@ -518,7 +518,7 @@ class TestConsumer(DirectoriesMixin, TestCase): | ||||
|         # Database empty | ||||
|         self.assertEqual(len(Document.objects.all()), 0) | ||||
|  | ||||
|     @override_settings(PAPERLESS_FILENAME_FORMAT="{correspondent}/{title}") | ||||
|     @override_settings(FILENAME_FORMAT="{correspondent}/{title}") | ||||
|     def testFilenameHandling(self): | ||||
|         filename = self.get_test_file() | ||||
|  | ||||
| @@ -530,7 +530,7 @@ class TestConsumer(DirectoriesMixin, TestCase): | ||||
|  | ||||
|         self._assert_first_last_send_progress() | ||||
|  | ||||
|     @override_settings(PAPERLESS_FILENAME_FORMAT="{correspondent}/{title}") | ||||
|     @override_settings(FILENAME_FORMAT="{correspondent}/{title}") | ||||
|     @mock.patch("documents.signals.handlers.generate_unique_filename") | ||||
|     def testFilenameHandlingUnstableFormat(self, m): | ||||
|  | ||||
| @@ -612,7 +612,7 @@ class TestConsumer(DirectoriesMixin, TestCase): | ||||
|  | ||||
|         self._assert_first_last_send_progress(last_status="FAILED") | ||||
|  | ||||
|     @override_settings(PAPERLESS_FILENAME_FORMAT="{title}") | ||||
|     @override_settings(FILENAME_FORMAT="{title}") | ||||
|     @mock.patch("documents.parsers.document_consumer_declaration.send") | ||||
|     def test_similar_filenames(self, m): | ||||
|         shutil.copy( | ||||
| @@ -660,7 +660,7 @@ class TestConsumer(DirectoriesMixin, TestCase): | ||||
| @mock.patch("documents.consumer.magic.from_file", fake_magic_from_file) | ||||
| class TestConsumerCreatedDate(DirectoriesMixin, TestCase): | ||||
|     def setUp(self): | ||||
|         super(TestConsumerCreatedDate, self).setUp() | ||||
|         super().setUp() | ||||
|  | ||||
|         # this prevents websocket message reports during testing. | ||||
|         patcher = mock.patch("documents.consumer.Consumer._send_progress") | ||||
|   | ||||
| @@ -20,12 +20,12 @@ from ..file_handling import generate_unique_filename | ||||
| from ..models import Correspondent | ||||
| from ..models import Document | ||||
| from ..models import DocumentType | ||||
| from ..models import Tag | ||||
| from ..models import StoragePath | ||||
| from .utils import DirectoriesMixin | ||||
|  | ||||
|  | ||||
| class TestFileHandling(DirectoriesMixin, TestCase): | ||||
|     @override_settings(PAPERLESS_FILENAME_FORMAT="") | ||||
|     @override_settings(FILENAME_FORMAT="") | ||||
|     def test_generate_source_filename(self): | ||||
|         document = Document() | ||||
|         document.mime_type = "application/pdf" | ||||
| @@ -40,7 +40,7 @@ class TestFileHandling(DirectoriesMixin, TestCase): | ||||
|             f"{document.pk:07d}.pdf.gpg", | ||||
|         ) | ||||
|  | ||||
|     @override_settings(PAPERLESS_FILENAME_FORMAT="{correspondent}/{correspondent}") | ||||
|     @override_settings(FILENAME_FORMAT="{correspondent}/{correspondent}") | ||||
|     def test_file_renaming(self): | ||||
|         document = Document() | ||||
|         document.mime_type = "application/pdf" | ||||
| @@ -82,7 +82,7 @@ class TestFileHandling(DirectoriesMixin, TestCase): | ||||
|             True, | ||||
|         ) | ||||
|  | ||||
|     @override_settings(PAPERLESS_FILENAME_FORMAT="{correspondent}/{correspondent}") | ||||
|     @override_settings(FILENAME_FORMAT="{correspondent}/{correspondent}") | ||||
|     def test_file_renaming_missing_permissions(self): | ||||
|         document = Document() | ||||
|         document.mime_type = "application/pdf" | ||||
| @@ -117,7 +117,7 @@ class TestFileHandling(DirectoriesMixin, TestCase): | ||||
|  | ||||
|         os.chmod(settings.ORIGINALS_DIR + "/none", 0o777) | ||||
|  | ||||
|     @override_settings(PAPERLESS_FILENAME_FORMAT="{correspondent}/{correspondent}") | ||||
|     @override_settings(FILENAME_FORMAT="{correspondent}/{correspondent}") | ||||
|     def test_file_renaming_database_error(self): | ||||
|  | ||||
|         document1 = Document.objects.create( | ||||
| @@ -156,7 +156,7 @@ class TestFileHandling(DirectoriesMixin, TestCase): | ||||
|             ) | ||||
|             self.assertEqual(document.filename, "none/none.pdf") | ||||
|  | ||||
|     @override_settings(PAPERLESS_FILENAME_FORMAT="{correspondent}/{correspondent}") | ||||
|     @override_settings(FILENAME_FORMAT="{correspondent}/{correspondent}") | ||||
|     def test_document_delete(self): | ||||
|         document = Document() | ||||
|         document.mime_type = "application/pdf" | ||||
| @@ -180,7 +180,7 @@ class TestFileHandling(DirectoriesMixin, TestCase): | ||||
|         self.assertEqual(os.path.isdir(settings.ORIGINALS_DIR + "/none"), False) | ||||
|  | ||||
|     @override_settings( | ||||
|         PAPERLESS_FILENAME_FORMAT="{correspondent}/{correspondent}", | ||||
|         FILENAME_FORMAT="{correspondent}/{correspondent}", | ||||
|         TRASH_DIR=tempfile.mkdtemp(), | ||||
|     ) | ||||
|     def test_document_delete_trash(self): | ||||
| @@ -218,7 +218,7 @@ class TestFileHandling(DirectoriesMixin, TestCase): | ||||
|         document.delete() | ||||
|         self.assertEqual(os.path.isfile(settings.TRASH_DIR + "/none_01.pdf"), True) | ||||
|  | ||||
|     @override_settings(PAPERLESS_FILENAME_FORMAT="{correspondent}/{correspondent}") | ||||
|     @override_settings(FILENAME_FORMAT="{correspondent}/{correspondent}") | ||||
|     def test_document_delete_nofile(self): | ||||
|         document = Document() | ||||
|         document.mime_type = "application/pdf" | ||||
| @@ -227,7 +227,7 @@ class TestFileHandling(DirectoriesMixin, TestCase): | ||||
|  | ||||
|         document.delete() | ||||
|  | ||||
|     @override_settings(PAPERLESS_FILENAME_FORMAT="{correspondent}/{correspondent}") | ||||
|     @override_settings(FILENAME_FORMAT="{correspondent}/{correspondent}") | ||||
|     def test_directory_not_empty(self): | ||||
|         document = Document() | ||||
|         document.mime_type = "application/pdf" | ||||
| @@ -253,7 +253,7 @@ class TestFileHandling(DirectoriesMixin, TestCase): | ||||
|         self.assertEqual(os.path.isdir(settings.ORIGINALS_DIR + "/none"), True) | ||||
|         self.assertTrue(os.path.isfile(important_file)) | ||||
|  | ||||
|     @override_settings(PAPERLESS_FILENAME_FORMAT="{document_type} - {title}") | ||||
|     @override_settings(FILENAME_FORMAT="{document_type} - {title}") | ||||
|     def test_document_type(self): | ||||
|         dt = DocumentType.objects.create(name="my_doc_type") | ||||
|         d = Document.objects.create(title="the_doc", mime_type="application/pdf") | ||||
| @@ -264,7 +264,7 @@ class TestFileHandling(DirectoriesMixin, TestCase): | ||||
|  | ||||
|         self.assertEqual(generate_filename(d), "my_doc_type - the_doc.pdf") | ||||
|  | ||||
|     @override_settings(PAPERLESS_FILENAME_FORMAT="{asn} - {title}") | ||||
|     @override_settings(FILENAME_FORMAT="{asn} - {title}") | ||||
|     def test_asn(self): | ||||
|         d1 = Document.objects.create( | ||||
|             title="the_doc", | ||||
| @@ -281,7 +281,7 @@ class TestFileHandling(DirectoriesMixin, TestCase): | ||||
|         self.assertEqual(generate_filename(d1), "652 - the_doc.pdf") | ||||
|         self.assertEqual(generate_filename(d2), "none - the_doc.pdf") | ||||
|  | ||||
|     @override_settings(PAPERLESS_FILENAME_FORMAT="{tags[type]}") | ||||
|     @override_settings(FILENAME_FORMAT="{tags[type]}") | ||||
|     def test_tags_with_underscore(self): | ||||
|         document = Document() | ||||
|         document.mime_type = "application/pdf" | ||||
| @@ -296,7 +296,7 @@ class TestFileHandling(DirectoriesMixin, TestCase): | ||||
|         # Ensure that filename is properly generated | ||||
|         self.assertEqual(generate_filename(document), "demo.pdf") | ||||
|  | ||||
|     @override_settings(PAPERLESS_FILENAME_FORMAT="{tags[type]}") | ||||
|     @override_settings(FILENAME_FORMAT="{tags[type]}") | ||||
|     def test_tags_with_dash(self): | ||||
|         document = Document() | ||||
|         document.mime_type = "application/pdf" | ||||
| @@ -311,7 +311,7 @@ class TestFileHandling(DirectoriesMixin, TestCase): | ||||
|         # Ensure that filename is properly generated | ||||
|         self.assertEqual(generate_filename(document), "demo.pdf") | ||||
|  | ||||
|     @override_settings(PAPERLESS_FILENAME_FORMAT="{tags[type]}") | ||||
|     @override_settings(FILENAME_FORMAT="{tags[type]}") | ||||
|     def test_tags_malformed(self): | ||||
|         document = Document() | ||||
|         document.mime_type = "application/pdf" | ||||
| @@ -326,7 +326,7 @@ class TestFileHandling(DirectoriesMixin, TestCase): | ||||
|         # Ensure that filename is properly generated | ||||
|         self.assertEqual(generate_filename(document), "none.pdf") | ||||
|  | ||||
|     @override_settings(PAPERLESS_FILENAME_FORMAT="{tags[0]}") | ||||
|     @override_settings(FILENAME_FORMAT="{tags[0]}") | ||||
|     def test_tags_all(self): | ||||
|         document = Document() | ||||
|         document.mime_type = "application/pdf" | ||||
| @@ -340,7 +340,7 @@ class TestFileHandling(DirectoriesMixin, TestCase): | ||||
|         # Ensure that filename is properly generated | ||||
|         self.assertEqual(generate_filename(document), "demo.pdf") | ||||
|  | ||||
|     @override_settings(PAPERLESS_FILENAME_FORMAT="{tags[1]}") | ||||
|     @override_settings(FILENAME_FORMAT="{tags[1]}") | ||||
|     def test_tags_out_of_bounds(self): | ||||
|         document = Document() | ||||
|         document.mime_type = "application/pdf" | ||||
| @@ -354,7 +354,7 @@ class TestFileHandling(DirectoriesMixin, TestCase): | ||||
|         # Ensure that filename is properly generated | ||||
|         self.assertEqual(generate_filename(document), "none.pdf") | ||||
|  | ||||
|     @override_settings(PAPERLESS_FILENAME_FORMAT="{tags}") | ||||
|     @override_settings(FILENAME_FORMAT="{tags}") | ||||
|     def test_tags_without_args(self): | ||||
|         document = Document() | ||||
|         document.mime_type = "application/pdf" | ||||
| @@ -363,7 +363,7 @@ class TestFileHandling(DirectoriesMixin, TestCase): | ||||
|  | ||||
|         self.assertEqual(generate_filename(document), f"{document.pk:07}.pdf") | ||||
|  | ||||
|     @override_settings(PAPERLESS_FILENAME_FORMAT="{title} {tag_list}") | ||||
|     @override_settings(FILENAME_FORMAT="{title} {tag_list}") | ||||
|     def test_tag_list(self): | ||||
|         doc = Document.objects.create(title="doc1", mime_type="application/pdf") | ||||
|         doc.tags.create(name="tag2") | ||||
| @@ -379,7 +379,7 @@ class TestFileHandling(DirectoriesMixin, TestCase): | ||||
|  | ||||
|         self.assertEqual(generate_filename(doc), "doc2.pdf") | ||||
|  | ||||
|     @override_settings(PAPERLESS_FILENAME_FORMAT="//etc/something/{title}") | ||||
|     @override_settings(FILENAME_FORMAT="//etc/something/{title}") | ||||
|     def test_filename_relative(self): | ||||
|         doc = Document.objects.create(title="doc1", mime_type="application/pdf") | ||||
|         doc.filename = generate_filename(doc) | ||||
| @@ -391,7 +391,7 @@ class TestFileHandling(DirectoriesMixin, TestCase): | ||||
|         ) | ||||
|  | ||||
|     @override_settings( | ||||
|         PAPERLESS_FILENAME_FORMAT="{created_year}-{created_month}-{created_day}", | ||||
|         FILENAME_FORMAT="{created_year}-{created_month}-{created_day}", | ||||
|     ) | ||||
|     def test_created_year_month_day(self): | ||||
|         d1 = timezone.make_aware(datetime.datetime(2020, 3, 6, 1, 1, 1)) | ||||
| @@ -408,7 +408,7 @@ class TestFileHandling(DirectoriesMixin, TestCase): | ||||
|         self.assertEqual(generate_filename(doc1), "2020-11-16.pdf") | ||||
|  | ||||
|     @override_settings( | ||||
|         PAPERLESS_FILENAME_FORMAT="{added_year}-{added_month}-{added_day}", | ||||
|         FILENAME_FORMAT="{added_year}-{added_month}-{added_day}", | ||||
|     ) | ||||
|     def test_added_year_month_day(self): | ||||
|         d1 = timezone.make_aware(datetime.datetime(232, 1, 9, 1, 1, 1)) | ||||
| @@ -425,7 +425,7 @@ class TestFileHandling(DirectoriesMixin, TestCase): | ||||
|         self.assertEqual(generate_filename(doc1), "2020-11-16.pdf") | ||||
|  | ||||
|     @override_settings( | ||||
|         PAPERLESS_FILENAME_FORMAT="{correspondent}/{correspondent}/{correspondent}", | ||||
|         FILENAME_FORMAT="{correspondent}/{correspondent}/{correspondent}", | ||||
|     ) | ||||
|     def test_nested_directory_cleanup(self): | ||||
|         document = Document() | ||||
| @@ -453,7 +453,7 @@ class TestFileHandling(DirectoriesMixin, TestCase): | ||||
|         self.assertEqual(os.path.isdir(settings.ORIGINALS_DIR + "/none"), False) | ||||
|         self.assertEqual(os.path.isdir(settings.ORIGINALS_DIR), True) | ||||
|  | ||||
|     @override_settings(PAPERLESS_FILENAME_FORMAT=None) | ||||
|     @override_settings(FILENAME_FORMAT=None) | ||||
|     def test_format_none(self): | ||||
|         document = Document() | ||||
|         document.pk = 1 | ||||
| @@ -479,7 +479,7 @@ class TestFileHandling(DirectoriesMixin, TestCase): | ||||
|         self.assertEqual(os.path.isfile(os.path.join(tmp, "notempty", "file")), True) | ||||
|         self.assertEqual(os.path.isdir(os.path.join(tmp, "notempty", "empty")), False) | ||||
|  | ||||
|     @override_settings(PAPERLESS_FILENAME_FORMAT="{created/[title]") | ||||
|     @override_settings(FILENAME_FORMAT="{created/[title]") | ||||
|     def test_invalid_format(self): | ||||
|         document = Document() | ||||
|         document.pk = 1 | ||||
| @@ -488,7 +488,7 @@ class TestFileHandling(DirectoriesMixin, TestCase): | ||||
|  | ||||
|         self.assertEqual(generate_filename(document), "0000001.pdf") | ||||
|  | ||||
|     @override_settings(PAPERLESS_FILENAME_FORMAT="{created__year}") | ||||
|     @override_settings(FILENAME_FORMAT="{created__year}") | ||||
|     def test_invalid_format_key(self): | ||||
|         document = Document() | ||||
|         document.pk = 1 | ||||
| @@ -497,7 +497,7 @@ class TestFileHandling(DirectoriesMixin, TestCase): | ||||
|  | ||||
|         self.assertEqual(generate_filename(document), "0000001.pdf") | ||||
|  | ||||
|     @override_settings(PAPERLESS_FILENAME_FORMAT="{title}") | ||||
|     @override_settings(FILENAME_FORMAT="{title}") | ||||
|     def test_duplicates(self): | ||||
|         document = Document.objects.create( | ||||
|             mime_type="application/pdf", | ||||
| @@ -548,7 +548,7 @@ class TestFileHandling(DirectoriesMixin, TestCase): | ||||
|         self.assertTrue(os.path.isfile(document.source_path)) | ||||
|         self.assertEqual(document2.filename, "qwe.pdf") | ||||
|  | ||||
|     @override_settings(PAPERLESS_FILENAME_FORMAT="{title}") | ||||
|     @override_settings(FILENAME_FORMAT="{title}") | ||||
|     @mock.patch("documents.signals.handlers.Document.objects.filter") | ||||
|     def test_no_update_without_change(self, m): | ||||
|         doc = Document.objects.create( | ||||
| @@ -568,7 +568,7 @@ class TestFileHandling(DirectoriesMixin, TestCase): | ||||
|  | ||||
|  | ||||
| class TestFileHandlingWithArchive(DirectoriesMixin, TestCase): | ||||
|     @override_settings(PAPERLESS_FILENAME_FORMAT=None) | ||||
|     @override_settings(FILENAME_FORMAT=None) | ||||
|     def test_create_no_format(self): | ||||
|         original = os.path.join(settings.ORIGINALS_DIR, "0000001.pdf") | ||||
|         archive = os.path.join(settings.ARCHIVE_DIR, "0000001.pdf") | ||||
| @@ -587,7 +587,7 @@ class TestFileHandlingWithArchive(DirectoriesMixin, TestCase): | ||||
|         self.assertTrue(os.path.isfile(doc.source_path)) | ||||
|         self.assertTrue(os.path.isfile(doc.archive_path)) | ||||
|  | ||||
|     @override_settings(PAPERLESS_FILENAME_FORMAT="{correspondent}/{title}") | ||||
|     @override_settings(FILENAME_FORMAT="{correspondent}/{title}") | ||||
|     def test_create_with_format(self): | ||||
|         original = os.path.join(settings.ORIGINALS_DIR, "0000001.pdf") | ||||
|         archive = os.path.join(settings.ARCHIVE_DIR, "0000001.pdf") | ||||
| @@ -615,7 +615,7 @@ class TestFileHandlingWithArchive(DirectoriesMixin, TestCase): | ||||
|             os.path.join(settings.ARCHIVE_DIR, "none", "my_doc.pdf"), | ||||
|         ) | ||||
|  | ||||
|     @override_settings(PAPERLESS_FILENAME_FORMAT="{correspondent}/{title}") | ||||
|     @override_settings(FILENAME_FORMAT="{correspondent}/{title}") | ||||
|     def test_move_archive_gone(self): | ||||
|         original = os.path.join(settings.ORIGINALS_DIR, "0000001.pdf") | ||||
|         archive = os.path.join(settings.ARCHIVE_DIR, "0000001.pdf") | ||||
| @@ -634,7 +634,7 @@ class TestFileHandlingWithArchive(DirectoriesMixin, TestCase): | ||||
|         self.assertTrue(os.path.isfile(doc.source_path)) | ||||
|         self.assertFalse(os.path.isfile(doc.archive_path)) | ||||
|  | ||||
|     @override_settings(PAPERLESS_FILENAME_FORMAT="{correspondent}/{title}") | ||||
|     @override_settings(FILENAME_FORMAT="{correspondent}/{title}") | ||||
|     def test_move_archive_exists(self): | ||||
|         original = os.path.join(settings.ORIGINALS_DIR, "0000001.pdf") | ||||
|         archive = os.path.join(settings.ARCHIVE_DIR, "0000001.pdf") | ||||
| @@ -659,7 +659,7 @@ class TestFileHandlingWithArchive(DirectoriesMixin, TestCase): | ||||
|         self.assertTrue(os.path.isfile(existing_archive_file)) | ||||
|         self.assertEqual(doc.archive_filename, "none/my_doc_01.pdf") | ||||
|  | ||||
|     @override_settings(PAPERLESS_FILENAME_FORMAT="{title}") | ||||
|     @override_settings(FILENAME_FORMAT="{title}") | ||||
|     def test_move_original_only(self): | ||||
|         original = os.path.join(settings.ORIGINALS_DIR, "document_01.pdf") | ||||
|         archive = os.path.join(settings.ARCHIVE_DIR, "document.pdf") | ||||
| @@ -681,7 +681,7 @@ class TestFileHandlingWithArchive(DirectoriesMixin, TestCase): | ||||
|         self.assertTrue(os.path.isfile(doc.source_path)) | ||||
|         self.assertTrue(os.path.isfile(doc.archive_path)) | ||||
|  | ||||
|     @override_settings(PAPERLESS_FILENAME_FORMAT="{title}") | ||||
|     @override_settings(FILENAME_FORMAT="{title}") | ||||
|     def test_move_archive_only(self): | ||||
|         original = os.path.join(settings.ORIGINALS_DIR, "document.pdf") | ||||
|         archive = os.path.join(settings.ARCHIVE_DIR, "document_01.pdf") | ||||
| @@ -703,7 +703,7 @@ class TestFileHandlingWithArchive(DirectoriesMixin, TestCase): | ||||
|         self.assertTrue(os.path.isfile(doc.source_path)) | ||||
|         self.assertTrue(os.path.isfile(doc.archive_path)) | ||||
|  | ||||
|     @override_settings(PAPERLESS_FILENAME_FORMAT="{correspondent}/{title}") | ||||
|     @override_settings(FILENAME_FORMAT="{correspondent}/{title}") | ||||
|     @mock.patch("documents.signals.handlers.os.rename") | ||||
|     def test_move_archive_error(self, m): | ||||
|         def fake_rename(src, dst): | ||||
| @@ -734,7 +734,7 @@ class TestFileHandlingWithArchive(DirectoriesMixin, TestCase): | ||||
|         self.assertTrue(os.path.isfile(doc.source_path)) | ||||
|         self.assertTrue(os.path.isfile(doc.archive_path)) | ||||
|  | ||||
|     @override_settings(PAPERLESS_FILENAME_FORMAT="{correspondent}/{title}") | ||||
|     @override_settings(FILENAME_FORMAT="{correspondent}/{title}") | ||||
|     def test_move_file_gone(self): | ||||
|         original = os.path.join(settings.ORIGINALS_DIR, "0000001.pdf") | ||||
|         archive = os.path.join(settings.ARCHIVE_DIR, "0000001.pdf") | ||||
| @@ -754,7 +754,7 @@ class TestFileHandlingWithArchive(DirectoriesMixin, TestCase): | ||||
|         self.assertFalse(os.path.isfile(doc.source_path)) | ||||
|         self.assertTrue(os.path.isfile(doc.archive_path)) | ||||
|  | ||||
|     @override_settings(PAPERLESS_FILENAME_FORMAT="{correspondent}/{title}") | ||||
|     @override_settings(FILENAME_FORMAT="{correspondent}/{title}") | ||||
|     @mock.patch("documents.signals.handlers.os.rename") | ||||
|     def test_move_file_error(self, m): | ||||
|         def fake_rename(src, dst): | ||||
| @@ -785,7 +785,7 @@ class TestFileHandlingWithArchive(DirectoriesMixin, TestCase): | ||||
|         self.assertTrue(os.path.isfile(doc.source_path)) | ||||
|         self.assertTrue(os.path.isfile(doc.archive_path)) | ||||
|  | ||||
|     @override_settings(PAPERLESS_FILENAME_FORMAT="") | ||||
|     @override_settings(FILENAME_FORMAT="") | ||||
|     def test_archive_deleted(self): | ||||
|         original = os.path.join(settings.ORIGINALS_DIR, "0000001.pdf") | ||||
|         archive = os.path.join(settings.ARCHIVE_DIR, "0000001.pdf") | ||||
| @@ -812,7 +812,7 @@ class TestFileHandlingWithArchive(DirectoriesMixin, TestCase): | ||||
|         self.assertFalse(os.path.isfile(doc.source_path)) | ||||
|         self.assertFalse(os.path.isfile(doc.archive_path)) | ||||
|  | ||||
|     @override_settings(PAPERLESS_FILENAME_FORMAT="{title}") | ||||
|     @override_settings(FILENAME_FORMAT="{title}") | ||||
|     def test_archive_deleted2(self): | ||||
|         original = os.path.join(settings.ORIGINALS_DIR, "document.png") | ||||
|         original2 = os.path.join(settings.ORIGINALS_DIR, "0000001.pdf") | ||||
| @@ -846,7 +846,7 @@ class TestFileHandlingWithArchive(DirectoriesMixin, TestCase): | ||||
|         self.assertTrue(os.path.isfile(doc1.archive_path)) | ||||
|         self.assertFalse(os.path.isfile(doc2.source_path)) | ||||
|  | ||||
|     @override_settings(PAPERLESS_FILENAME_FORMAT="{correspondent}/{title}") | ||||
|     @override_settings(FILENAME_FORMAT="{correspondent}/{title}") | ||||
|     def test_database_error(self): | ||||
|  | ||||
|         original = os.path.join(settings.ORIGINALS_DIR, "0000001.pdf") | ||||
| @@ -872,7 +872,7 @@ class TestFileHandlingWithArchive(DirectoriesMixin, TestCase): | ||||
|  | ||||
|  | ||||
| class TestFilenameGeneration(TestCase): | ||||
|     @override_settings(PAPERLESS_FILENAME_FORMAT="{title}") | ||||
|     @override_settings(FILENAME_FORMAT="{title}") | ||||
|     def test_invalid_characters(self): | ||||
|  | ||||
|         doc = Document.objects.create( | ||||
| @@ -891,7 +891,7 @@ class TestFilenameGeneration(TestCase): | ||||
|         ) | ||||
|         self.assertEqual(generate_filename(doc), "my-invalid-..-title-yay.pdf") | ||||
|  | ||||
|     @override_settings(PAPERLESS_FILENAME_FORMAT="{created}") | ||||
|     @override_settings(FILENAME_FORMAT="{created}") | ||||
|     def test_date(self): | ||||
|         doc = Document.objects.create( | ||||
|             title="does not matter", | ||||
| @@ -902,6 +902,140 @@ class TestFilenameGeneration(TestCase): | ||||
|         ) | ||||
|         self.assertEqual(generate_filename(doc), "2020-05-21.pdf") | ||||
|  | ||||
|     def test_dynamic_path(self): | ||||
|         """ | ||||
|         GIVEN: | ||||
|             - A document with a defined storage path | ||||
|         WHEN: | ||||
|             - the filename is generated for the document | ||||
|         THEN: | ||||
|             - the generated filename uses the defined storage path for the document | ||||
|         """ | ||||
|         doc = Document.objects.create( | ||||
|             title="does not matter", | ||||
|             created=timezone.make_aware(datetime.datetime(2020, 6, 25, 7, 36, 51, 153)), | ||||
|             mime_type="application/pdf", | ||||
|             pk=2, | ||||
|             checksum="2", | ||||
|             storage_path=StoragePath.objects.create(path="TestFolder/{created}"), | ||||
|         ) | ||||
|         self.assertEqual(generate_filename(doc), "TestFolder/2020-06-25.pdf") | ||||
|  | ||||
|     def test_dynamic_path_with_none(self): | ||||
|         """ | ||||
|         GIVEN: | ||||
|             - A document with a defined storage path | ||||
|             - The defined storage path uses an undefined field for the document | ||||
|         WHEN: | ||||
|             - the filename is generated for the document | ||||
|         THEN: | ||||
|             - the generated filename uses the defined storage path for the document | ||||
|             - the generated filename includes "none" in the place undefined field | ||||
|         """ | ||||
|         doc = Document.objects.create( | ||||
|             title="does not matter", | ||||
|             created=timezone.make_aware(datetime.datetime(2020, 6, 25, 7, 36, 51, 153)), | ||||
|             mime_type="application/pdf", | ||||
|             pk=2, | ||||
|             checksum="2", | ||||
|             storage_path=StoragePath.objects.create(path="{asn} - {created}"), | ||||
|         ) | ||||
|         self.assertEqual(generate_filename(doc), "none - 2020-06-25.pdf") | ||||
|  | ||||
|     @override_settings( | ||||
|         FILENAME_FORMAT_REMOVE_NONE=True, | ||||
|     ) | ||||
|     def test_dynamic_path_remove_none(self): | ||||
|         """ | ||||
|         GIVEN: | ||||
|             - A document with a defined storage path | ||||
|             - The defined storage path uses an undefined field for the document | ||||
|             - The setting for removing undefined fields is enabled | ||||
|         WHEN: | ||||
|             - the filename is generated for the document | ||||
|         THEN: | ||||
|             - the generated filename uses the defined storage path for the document | ||||
|             - the generated filename does not include "none" in the place undefined field | ||||
|         """ | ||||
|         doc = Document.objects.create( | ||||
|             title="does not matter", | ||||
|             created=timezone.make_aware(datetime.datetime(2020, 6, 25, 7, 36, 51, 153)), | ||||
|             mime_type="application/pdf", | ||||
|             pk=2, | ||||
|             checksum="2", | ||||
|             storage_path=StoragePath.objects.create(path="TestFolder/{asn}/{created}"), | ||||
|         ) | ||||
|         self.assertEqual(generate_filename(doc), "TestFolder/2020-06-25.pdf") | ||||
|  | ||||
|     def test_multiple_doc_paths(self): | ||||
|         """ | ||||
|         GIVEN: | ||||
|             - Two documents, each with different storage paths | ||||
|         WHEN: | ||||
|             - the filename is generated for the documents | ||||
|         THEN: | ||||
|             - Each document generated filename uses its storage path | ||||
|         """ | ||||
|         doc_a = Document.objects.create( | ||||
|             title="does not matter", | ||||
|             created=timezone.make_aware(datetime.datetime(2020, 6, 25, 7, 36, 51, 153)), | ||||
|             mime_type="application/pdf", | ||||
|             pk=2, | ||||
|             checksum="2", | ||||
|             archive_serial_number=4, | ||||
|             storage_path=StoragePath.objects.create( | ||||
|                 name="sp1", | ||||
|                 path="ThisIsAFolder/{asn}/{created}", | ||||
|             ), | ||||
|         ) | ||||
|         doc_b = Document.objects.create( | ||||
|             title="does not matter", | ||||
|             created=timezone.make_aware(datetime.datetime(2020, 7, 25, 7, 36, 51, 153)), | ||||
|             mime_type="application/pdf", | ||||
|             pk=5, | ||||
|             checksum="abcde", | ||||
|             storage_path=StoragePath.objects.create( | ||||
|                 name="sp2", | ||||
|                 path="SomeImportantNone/{created}", | ||||
|             ), | ||||
|         ) | ||||
|  | ||||
|         self.assertEqual(generate_filename(doc_a), "ThisIsAFolder/4/2020-06-25.pdf") | ||||
|         self.assertEqual(generate_filename(doc_b), "SomeImportantNone/2020-07-25.pdf") | ||||
|  | ||||
|     def test_no_path_fallback(self): | ||||
|         """ | ||||
|         GIVEN: | ||||
|             - Two documents, one with defined storage path, the other not | ||||
|         WHEN: | ||||
|             - the filename is generated for the documents | ||||
|         THEN: | ||||
|             - Document with defined path uses its format | ||||
|             - Document without defined path uses the default path | ||||
|         """ | ||||
|         doc_a = Document.objects.create( | ||||
|             title="does not matter", | ||||
|             created=timezone.make_aware(datetime.datetime(2020, 6, 25, 7, 36, 51, 153)), | ||||
|             mime_type="application/pdf", | ||||
|             pk=2, | ||||
|             checksum="2", | ||||
|             archive_serial_number=4, | ||||
|         ) | ||||
|         doc_b = Document.objects.create( | ||||
|             title="does not matter", | ||||
|             created=timezone.make_aware(datetime.datetime(2020, 7, 25, 7, 36, 51, 153)), | ||||
|             mime_type="application/pdf", | ||||
|             pk=5, | ||||
|             checksum="abcde", | ||||
|             storage_path=StoragePath.objects.create( | ||||
|                 name="sp2", | ||||
|                 path="SomeImportantNone/{created}", | ||||
|             ), | ||||
|         ) | ||||
|  | ||||
|         self.assertEqual(generate_filename(doc_a), "0000002.pdf") | ||||
|         self.assertEqual(generate_filename(doc_b), "SomeImportantNone/2020-07-25.pdf") | ||||
|  | ||||
|  | ||||
| def run(): | ||||
|     doc = Document.objects.create( | ||||
|   | ||||
| @@ -18,7 +18,7 @@ from documents.tests.utils import DirectoriesMixin | ||||
| sample_file = os.path.join(os.path.dirname(__file__), "samples", "simple.pdf") | ||||
|  | ||||
|  | ||||
| @override_settings(PAPERLESS_FILENAME_FORMAT="{correspondent}/{title}") | ||||
| @override_settings(FILENAME_FORMAT="{correspondent}/{title}") | ||||
| class TestArchiver(DirectoriesMixin, TestCase): | ||||
|     def make_models(self): | ||||
|         return Document.objects.create( | ||||
| @@ -72,7 +72,7 @@ class TestArchiver(DirectoriesMixin, TestCase): | ||||
|         self.assertIsNone(doc.archive_filename) | ||||
|         self.assertTrue(os.path.isfile(doc.source_path)) | ||||
|  | ||||
|     @override_settings(PAPERLESS_FILENAME_FORMAT="{title}") | ||||
|     @override_settings(FILENAME_FORMAT="{title}") | ||||
|     def test_naming_priorities(self): | ||||
|         doc1 = Document.objects.create( | ||||
|             checksum="A", | ||||
| @@ -109,7 +109,7 @@ class TestDecryptDocuments(TestCase): | ||||
|         ORIGINALS_DIR=os.path.join(os.path.dirname(__file__), "samples", "originals"), | ||||
|         THUMBNAIL_DIR=os.path.join(os.path.dirname(__file__), "samples", "thumb"), | ||||
|         PASSPHRASE="test", | ||||
|         PAPERLESS_FILENAME_FORMAT=None, | ||||
|         FILENAME_FORMAT=None, | ||||
|     ) | ||||
|     @mock.patch("documents.management.commands.decrypt_documents.input") | ||||
|     def test_decrypt(self, m): | ||||
| @@ -184,7 +184,7 @@ class TestMakeIndex(TestCase): | ||||
|  | ||||
|  | ||||
| class TestRenamer(DirectoriesMixin, TestCase): | ||||
|     @override_settings(PAPERLESS_FILENAME_FORMAT="") | ||||
|     @override_settings(FILENAME_FORMAT="") | ||||
|     def test_rename(self): | ||||
|         doc = Document.objects.create(title="test", mime_type="image/jpeg") | ||||
|         doc.filename = generate_filename(doc) | ||||
| @@ -194,7 +194,7 @@ class TestRenamer(DirectoriesMixin, TestCase): | ||||
|         Path(doc.source_path).touch() | ||||
|         Path(doc.archive_path).touch() | ||||
|  | ||||
|         with override_settings(PAPERLESS_FILENAME_FORMAT="{correspondent}/{title}"): | ||||
|         with override_settings(FILENAME_FORMAT="{correspondent}/{title}"): | ||||
|             call_command("document_renamer") | ||||
|  | ||||
|         doc2 = Document.objects.get(id=doc.id) | ||||
|   | ||||
| @@ -200,7 +200,7 @@ class TestExportImport(DirectoriesMixin, TestCase): | ||||
|         ) | ||||
|  | ||||
|         with override_settings( | ||||
|             PAPERLESS_FILENAME_FORMAT="{created_year}/{correspondent}/{title}", | ||||
|             FILENAME_FORMAT="{created_year}/{correspondent}/{title}", | ||||
|         ): | ||||
|             self.test_exporter(use_filename_format=True) | ||||
|  | ||||
| @@ -309,7 +309,7 @@ class TestExportImport(DirectoriesMixin, TestCase): | ||||
|  | ||||
|         self.assertTrue(len(manifest), 6) | ||||
|  | ||||
|     @override_settings(PAPERLESS_FILENAME_FORMAT="{title}/{correspondent}") | ||||
|     @override_settings(FILENAME_FORMAT="{title}/{correspondent}") | ||||
|     def test_update_export_changed_location(self): | ||||
|         shutil.rmtree(os.path.join(self.dirs.media_dir, "documents")) | ||||
|         shutil.copytree( | ||||
|   | ||||
| @@ -111,7 +111,7 @@ simple_png = os.path.join(os.path.dirname(__file__), "samples", "simple-noalpha. | ||||
| simple_png2 = os.path.join(os.path.dirname(__file__), "examples", "no-text.png") | ||||
|  | ||||
|  | ||||
| @override_settings(PAPERLESS_FILENAME_FORMAT="") | ||||
| @override_settings(FILENAME_FORMAT="") | ||||
| class TestMigrateArchiveFiles(DirectoriesMixin, TestMigrations): | ||||
|  | ||||
|     migrate_from = "1011_auto_20210101_2340" | ||||
| @@ -240,7 +240,7 @@ class TestMigrateArchiveFiles(DirectoriesMixin, TestMigrations): | ||||
|         ) | ||||
|  | ||||
|  | ||||
| @override_settings(PAPERLESS_FILENAME_FORMAT="{correspondent}/{title}") | ||||
| @override_settings(FILENAME_FORMAT="{correspondent}/{title}") | ||||
| class TestMigrateArchiveFilesWithFilenameFormat(TestMigrateArchiveFiles): | ||||
|     def test_filenames(self): | ||||
|         Document = self.apps.get_model("documents", "Document") | ||||
| @@ -279,7 +279,7 @@ def fake_parse_wrapper(parser, path, mime_type, file_name): | ||||
|     parser.text = "the text" | ||||
|  | ||||
|  | ||||
| @override_settings(PAPERLESS_FILENAME_FORMAT="") | ||||
| @override_settings(FILENAME_FORMAT="") | ||||
| class TestMigrateArchiveFilesErrors(DirectoriesMixin, TestMigrations): | ||||
|  | ||||
|     migrate_from = "1011_auto_20210101_2340" | ||||
| @@ -447,7 +447,7 @@ class TestMigrateArchiveFilesErrors(DirectoriesMixin, TestMigrations): | ||||
|         self.assertIsNone(doc2.archive_filename) | ||||
|  | ||||
|  | ||||
| @override_settings(PAPERLESS_FILENAME_FORMAT="") | ||||
| @override_settings(FILENAME_FORMAT="") | ||||
| class TestMigrateArchiveFilesBackwards(DirectoriesMixin, TestMigrations): | ||||
|  | ||||
|     migrate_from = "1012_fix_archive_files" | ||||
| @@ -505,14 +505,14 @@ class TestMigrateArchiveFilesBackwards(DirectoriesMixin, TestMigrations): | ||||
|         ) | ||||
|  | ||||
|  | ||||
| @override_settings(PAPERLESS_FILENAME_FORMAT="{correspondent}/{title}") | ||||
| @override_settings(FILENAME_FORMAT="{correspondent}/{title}") | ||||
| class TestMigrateArchiveFilesBackwardsWithFilenameFormat( | ||||
|     TestMigrateArchiveFilesBackwards, | ||||
| ): | ||||
|     pass | ||||
|  | ||||
|  | ||||
| @override_settings(PAPERLESS_FILENAME_FORMAT="") | ||||
| @override_settings(FILENAME_FORMAT="") | ||||
| class TestMigrateArchiveFilesBackwardsErrors(DirectoriesMixin, TestMigrations): | ||||
|  | ||||
|     migrate_from = "1012_fix_archive_files" | ||||
|   | ||||
| @@ -55,14 +55,17 @@ from .classifier import load_classifier | ||||
| from .filters import CorrespondentFilterSet | ||||
| from .filters import DocumentFilterSet | ||||
| from .filters import DocumentTypeFilterSet | ||||
| from .filters import StoragePathFilterSet | ||||
| from .filters import TagFilterSet | ||||
| from .matching import match_correspondents | ||||
| from .matching import match_document_types | ||||
| from .matching import match_storage_paths | ||||
| from .matching import match_tags | ||||
| from .models import Correspondent | ||||
| from .models import Document | ||||
| from .models import DocumentType | ||||
| from .models import SavedView | ||||
| from .models import StoragePath | ||||
| from .models import Tag | ||||
| from .parsers import get_parser_class_for_mime_type | ||||
| from .serialisers import BulkDownloadSerializer | ||||
| @@ -73,6 +76,7 @@ from .serialisers import DocumentSerializer | ||||
| from .serialisers import DocumentTypeSerializer | ||||
| from .serialisers import PostDocumentSerializer | ||||
| from .serialisers import SavedViewSerializer | ||||
| from .serialisers import StoragePathSerializer | ||||
| from .serialisers import TagSerializer | ||||
| from .serialisers import TagSerializerVersion1 | ||||
| from .serialisers import UiSettingsViewSerializer | ||||
| @@ -335,6 +339,7 @@ class DocumentViewSet( | ||||
|                 "document_types": [ | ||||
|                     dt.id for dt in match_document_types(doc, classifier) | ||||
|                 ], | ||||
|                 "storage_paths": [dt.id for dt in match_storage_paths(doc, classifier)], | ||||
|             }, | ||||
|         ) | ||||
|  | ||||
| @@ -577,6 +582,12 @@ class SelectionDataView(GenericAPIView): | ||||
|             ), | ||||
|         ) | ||||
|  | ||||
|         storage_paths = StoragePath.objects.annotate( | ||||
|             document_count=Count( | ||||
|                 Case(When(documents__id__in=ids, then=1), output_field=IntegerField()), | ||||
|             ), | ||||
|         ) | ||||
|  | ||||
|         r = Response( | ||||
|             { | ||||
|                 "selected_correspondents": [ | ||||
| @@ -589,6 +600,10 @@ class SelectionDataView(GenericAPIView): | ||||
|                 "selected_document_types": [ | ||||
|                     {"id": t.id, "document_count": t.document_count} for t in types | ||||
|                 ], | ||||
|                 "selected_storage_paths": [ | ||||
|                     {"id": t.id, "document_count": t.document_count} | ||||
|                     for t in storage_paths | ||||
|                 ], | ||||
|             }, | ||||
|         ) | ||||
|  | ||||
| @@ -729,6 +744,21 @@ class RemoteVersionView(GenericAPIView): | ||||
|         ) | ||||
|  | ||||
|  | ||||
| class StoragePathViewSet(ModelViewSet): | ||||
|     model = DocumentType | ||||
|  | ||||
|     queryset = StoragePath.objects.annotate(document_count=Count("documents")).order_by( | ||||
|         Lower("name"), | ||||
|     ) | ||||
|  | ||||
|     serializer_class = StoragePathSerializer | ||||
|     pagination_class = StandardPagination | ||||
|     permission_classes = (IsAuthenticated,) | ||||
|     filter_backends = (DjangoFilterBackend, OrderingFilter) | ||||
|     filterset_class = StoragePathFilterSet | ||||
|     ordering_fields = ("name", "path", "matching_algorithm", "match", "document_count") | ||||
|  | ||||
|  | ||||
| class UiSettingsView(GenericAPIView): | ||||
|  | ||||
|     permission_classes = (IsAuthenticated,) | ||||
|   | ||||
| @@ -597,15 +597,22 @@ FILENAME_PARSE_TRANSFORMS = [] | ||||
| for t in json.loads(os.getenv("PAPERLESS_FILENAME_PARSE_TRANSFORMS", "[]")): | ||||
|     FILENAME_PARSE_TRANSFORMS.append((re.compile(t["pattern"]), t["repl"])) | ||||
|  | ||||
| # TODO: this should not have a prefix. | ||||
| # Specify the filename format for out files | ||||
| PAPERLESS_FILENAME_FORMAT = os.getenv("PAPERLESS_FILENAME_FORMAT") | ||||
| FILENAME_FORMAT = os.getenv("PAPERLESS_FILENAME_FORMAT") | ||||
|  | ||||
| # If this is enabled, variables in filename format will resolve to empty-string instead of 'none'. | ||||
| # Directories with 'empty names' are omitted, too. | ||||
| FILENAME_FORMAT_REMOVE_NONE = __get_boolean( | ||||
|     "PAPERLESS_FILENAME_FORMAT_REMOVE_NONE", | ||||
|     "NO", | ||||
| ) | ||||
|  | ||||
| THUMBNAIL_FONT_NAME = os.getenv( | ||||
|     "PAPERLESS_THUMBNAIL_FONT_NAME", | ||||
|     "/usr/share/fonts/liberation/LiberationSerif-Regular.ttf", | ||||
| ) | ||||
|  | ||||
| # TODO: this should not have a prefix. | ||||
| # Tika settings | ||||
| PAPERLESS_TIKA_ENABLED = __get_boolean("PAPERLESS_TIKA_ENABLED", "NO") | ||||
| PAPERLESS_TIKA_ENDPOINT = os.getenv("PAPERLESS_TIKA_ENDPOINT", "http://localhost:9998") | ||||
|   | ||||
| @@ -19,6 +19,7 @@ from documents.views import SavedViewViewSet | ||||
| from documents.views import SearchAutoCompleteView | ||||
| from documents.views import SelectionDataView | ||||
| from documents.views import StatisticsView | ||||
| from documents.views import StoragePathViewSet | ||||
| from documents.views import TagViewSet | ||||
| from documents.views import UiSettingsView | ||||
| from documents.views import UnifiedSearchViewSet | ||||
| @@ -34,6 +35,7 @@ api_router.register(r"documents", UnifiedSearchViewSet) | ||||
| api_router.register(r"logs", LogViewSet, basename="logs") | ||||
| api_router.register(r"tags", TagViewSet) | ||||
| api_router.register(r"saved_views", SavedViewViewSet) | ||||
| api_router.register(r"storage_paths", StoragePathViewSet) | ||||
|  | ||||
|  | ||||
| urlpatterns = [ | ||||
|   | ||||
		Reference in New Issue
	
	Block a user
	 Markus
					Markus