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. | # this is where the compiled frontend is moved to. | ||||||
| /src/documents/static/frontend/ | /src/documents/static/frontend/ | ||||||
|  |  | ||||||
|  | # mac os | ||||||
|  | .DS_Store | ||||||
|   | |||||||
							
								
								
									
										1
									
								
								Pipfile
									
									
									
									
									
								
							
							
						
						
									
										1
									
								
								Pipfile
									
									
									
									
									
								
							| @@ -69,4 +69,5 @@ sphinx_rtd_theme = "*" | |||||||
| tox = "*" | tox = "*" | ||||||
| black = "*" | black = "*" | ||||||
| pre-commit = "*" | pre-commit = "*" | ||||||
|  | sphinx-autobuild = "*" | ||||||
| myst-parser = "*" | myst-parser = "*" | ||||||
|   | |||||||
							
								
								
									
										77
									
								
								Pipfile.lock
									
									
									
										generated
									
									
									
								
							
							
						
						
									
										77
									
								
								Pipfile.lock
									
									
									
										generated
									
									
									
								
							| @@ -1,7 +1,7 @@ | |||||||
| { | { | ||||||
|     "_meta": { |     "_meta": { | ||||||
|         "hash": { |         "hash": { | ||||||
|             "sha256": "edaf53125fd5a0dc3aff5b75e188523ef3b7bc29bda792ee78ee67506e0b831d" |             "sha256": "818f3513df4a757e6302baf5a17ce61e85c7d69a7666e7d49e7e50e78e064ae3" | ||||||
|         }, |         }, | ||||||
|         "pipfile-spec": 6, |         "pipfile-spec": 6, | ||||||
|         "requires": {}, |         "requires": {}, | ||||||
| @@ -466,7 +466,7 @@ | |||||||
|                 "sha256:84d9dd047ffa80596e0f246e2eab0b391788b0503584e8945f2368256d2735ff", |                 "sha256:84d9dd047ffa80596e0f246e2eab0b391788b0503584e8945f2368256d2735ff", | ||||||
|                 "sha256:9d643ff0a55b762d5cdb124b8eaa99c66322e2157b69160bc32796e824360e6d" |                 "sha256:9d643ff0a55b762d5cdb124b8eaa99c66322e2157b69160bc32796e824360e6d" | ||||||
|             ], |             ], | ||||||
|             "markers": "python_version >= '3'", |             "markers": "python_version >= '3.5'", | ||||||
|             "version": "==3.3" |             "version": "==3.3" | ||||||
|         }, |         }, | ||||||
|         "imap-tools": { |         "imap-tools": { | ||||||
| @@ -1587,6 +1587,14 @@ | |||||||
|             "markers": "python_version >= '3.7'", |             "markers": "python_version >= '3.7'", | ||||||
|             "version": "==8.1.3" |             "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": { |         "coverage": { | ||||||
|             "extras": [ |             "extras": [ | ||||||
|                 "toml" |                 "toml" | ||||||
| @@ -1711,7 +1719,7 @@ | |||||||
|                 "sha256:84d9dd047ffa80596e0f246e2eab0b391788b0503584e8945f2368256d2735ff", |                 "sha256:84d9dd047ffa80596e0f246e2eab0b391788b0503584e8945f2368256d2735ff", | ||||||
|                 "sha256:9d643ff0a55b762d5cdb124b8eaa99c66322e2157b69160bc32796e824360e6d" |                 "sha256:9d643ff0a55b762d5cdb124b8eaa99c66322e2157b69160bc32796e824360e6d" | ||||||
|             ], |             ], | ||||||
|             "markers": "python_version >= '3'", |             "markers": "python_version >= '3.5'", | ||||||
|             "version": "==3.3" |             "version": "==3.3" | ||||||
|         }, |         }, | ||||||
|         "imagesize": { |         "imagesize": { | ||||||
| @@ -1745,6 +1753,12 @@ | |||||||
|             "markers": "python_version >= '3.7'", |             "markers": "python_version >= '3.7'", | ||||||
|             "version": "==3.1.2" |             "version": "==3.1.2" | ||||||
|         }, |         }, | ||||||
|  |         "livereload": { | ||||||
|  |             "hashes": [ | ||||||
|  |                 "sha256:776f2f865e59fde56490a56bcc6773b6917366bce0c267c60ee8aaf1a0959869" | ||||||
|  |             ], | ||||||
|  |             "version": "==2.6.3" | ||||||
|  |         }, | ||||||
|         "markdown-it-py": { |         "markdown-it-py": { | ||||||
|             "hashes": [ |             "hashes": [ | ||||||
|                 "sha256:93de681e5c021a432c63147656fe21790bc01231e0cd2da73626f1aa3ac0fe27", |                 "sha256:93de681e5c021a432c63147656fe21790bc01231e0cd2da73626f1aa3ac0fe27", | ||||||
| @@ -2046,6 +2060,14 @@ | |||||||
|             "index": "pypi", |             "index": "pypi", | ||||||
|             "version": "==4.5.0" |             "version": "==4.5.0" | ||||||
|         }, |         }, | ||||||
|  |         "sphinx-autobuild": { | ||||||
|  |             "hashes": [ | ||||||
|  |                 "sha256:8fe8cbfdb75db04475232f05187c776f46f6e9e04cacf1e49ce81bdac649ccac", | ||||||
|  |                 "sha256:de1ca3b66e271d2b5b5140c35034c89e47f263f2cd5db302c9217065f7443f05" | ||||||
|  |             ], | ||||||
|  |             "index": "pypi", | ||||||
|  |             "version": "==2021.3.14" | ||||||
|  |         }, | ||||||
|         "sphinx-rtd-theme": { |         "sphinx-rtd-theme": { | ||||||
|             "hashes": [ |             "hashes": [ | ||||||
|                 "sha256:4d35a56f4508cfee4c4fb604373ede6feae2a306731d533f409ef5c3496fdbd8", |                 "sha256:4d35a56f4508cfee4c4fb604373ede6feae2a306731d533f409ef5c3496fdbd8", | ||||||
| @@ -2121,9 +2143,56 @@ | |||||||
|                 "sha256:939de3e7a6161af0c887ef91b7d41a53e7c5a1ca976325f429cb46ea9bc30ecc", |                 "sha256:939de3e7a6161af0c887ef91b7d41a53e7c5a1ca976325f429cb46ea9bc30ecc", | ||||||
|                 "sha256:de526c12914f0c550d15924c62d72abc48d6fe7364aa87328337a31007fe8a4f" |                 "sha256:de526c12914f0c550d15924c62d72abc48d6fe7364aa87328337a31007fe8a4f" | ||||||
|             ], |             ], | ||||||
|             "markers": "python_version < '3.11'", |             "markers": "python_version >= '3.7'", | ||||||
|             "version": "==2.0.1" |             "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": { |         "tox": { | ||||||
|             "hashes": [ |             "hashes": [ | ||||||
|                 "sha256:0805727eb4d6b049de304977dfc9ce315a1938e6619c3ab9f38682bb04662a5a", |                 "sha256:0805727eb4d6b049de304977dfc9ce315a1938e6619c3ab9f38682bb04662a5a", | ||||||
|   | |||||||
| @@ -24,6 +24,7 @@ I18NSPHINXOPTS  = $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) . | |||||||
| help: | help: | ||||||
| 	@echo "Please use \`make <target>' where <target> is one of" | 	@echo "Please use \`make <target>' where <target> is one of" | ||||||
| 	@echo "  html       to make standalone HTML files" | 	@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 "  dirhtml    to make HTML files named index.html in directories" | ||||||
| 	@echo "  singlehtml to make a single large HTML file" | 	@echo "  singlehtml to make a single large HTML file" | ||||||
| 	@echo "  pickle     to make pickle files" | 	@echo "  pickle     to make pickle files" | ||||||
| @@ -54,6 +55,9 @@ html: | |||||||
| 	@echo | 	@echo | ||||||
| 	@echo "Build finished. The HTML pages are in $(BUILDDIR)/html." | 	@echo "Build finished. The HTML pages are in $(BUILDDIR)/html." | ||||||
|  |  | ||||||
|  | livehtml: | ||||||
|  | 	sphinx-autobuild "./" "$(BUILDDIR)" $(O) | ||||||
|  |  | ||||||
| dirhtml: | dirhtml: | ||||||
| 	$(SPHINXBUILD) -b dirhtml $(ALLSPHINXOPTS) $(BUILDDIR)/dirhtml | 	$(SPHINXBUILD) -b dirhtml $(ALLSPHINXOPTS) $(BUILDDIR)/dirhtml | ||||||
| 	@echo | 	@echo | ||||||
|   | |||||||
							
								
								
									
										52
									
								
								docs/_static/js/darkmode.js
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										52
									
								
								docs/_static/js/darkmode.js
									
									
									
									
										vendored
									
									
								
							| @@ -1,47 +1,47 @@ | |||||||
| let toggleButton; | let toggleButton | ||||||
| let icon; | let icon | ||||||
|  |  | ||||||
| function load() { | function load() { | ||||||
| 	"use strict"; | 	'use strict' | ||||||
|  |  | ||||||
| 	toggleButton = document.createElement("button"); | 	toggleButton = document.createElement('button') | ||||||
| 	toggleButton.setAttribute("title", "Toggle dark mode"); | 	toggleButton.setAttribute('title', 'Toggle dark mode') | ||||||
| 	toggleButton.classList.add("dark-mode-toggle"); | 	toggleButton.classList.add('dark-mode-toggle') | ||||||
| 	icon = document.createElement("i"); | 	icon = document.createElement('i') | ||||||
| 	icon.classList.add("fa", darkModeState ? "fa-sun-o" : "fa-moon-o"); | 	icon.classList.add('fa', darkModeState ? 'fa-sun-o' : 'fa-moon-o') | ||||||
| 	toggleButton.appendChild(icon); | 	toggleButton.appendChild(icon) | ||||||
| 	document.body.prepend(toggleButton); | 	document.body.prepend(toggleButton) | ||||||
|  |  | ||||||
| 	// Listen for changes in the OS settings | 	// Listen for changes in the OS settings | ||||||
| 	// addListener is used because older versions of Safari don't support addEventListener | 	// addListener is used because older versions of Safari don't support addEventListener | ||||||
| 	// prefersDarkQuery set in <head> | 	// prefersDarkQuery set in <head> | ||||||
| 	if (prefersDarkQuery) { | 	if (prefersDarkQuery) { | ||||||
| 		prefersDarkQuery.addListener(function (evt) { | 		prefersDarkQuery.addListener(function (evt) { | ||||||
| 			toggleDarkMode(evt.matches); | 			toggleDarkMode(evt.matches) | ||||||
| 		}); | 		}) | ||||||
| 	} | 	} | ||||||
|  |  | ||||||
| 	// Initial setting depending on the prefers-color-mode or localstorage | 	// Initial setting depending on the prefers-color-mode or localstorage | ||||||
| 	// darkModeState should be set in the document <head> to prevent flash | 	// darkModeState should be set in the document <head> to prevent flash | ||||||
| 	if (darkModeState == undefined) darkModeState = false; | 	if (darkModeState == undefined) darkModeState = false | ||||||
| 	toggleDarkMode(darkModeState); | 	toggleDarkMode(darkModeState) | ||||||
|  |  | ||||||
| 	// Toggles the "dark-mode" class on click and sets localStorage state | 	// Toggles the "dark-mode" class on click and sets localStorage state | ||||||
| 	toggleButton.addEventListener("click", () => { | 	toggleButton.addEventListener('click', () => { | ||||||
| 		darkModeState = !darkModeState; | 		darkModeState = !darkModeState | ||||||
|  |  | ||||||
| 		toggleDarkMode(darkModeState); | 		toggleDarkMode(darkModeState) | ||||||
| 		localStorage.setItem("dark-mode", darkModeState); | 		localStorage.setItem('dark-mode', darkModeState) | ||||||
| 	}); | 	}) | ||||||
| } | } | ||||||
|  |  | ||||||
| function toggleDarkMode(state) { | function toggleDarkMode(state) { | ||||||
| 	document.documentElement.classList.toggle("dark-mode", state); | 	document.documentElement.classList.toggle('dark-mode', state) | ||||||
| 	document.documentElement.classList.toggle("light-mode", !state); | 	document.documentElement.classList.toggle('light-mode', !state) | ||||||
| 	icon.classList.remove("fa-sun-o"); | 	icon.classList.remove('fa-sun-o') | ||||||
| 	icon.classList.remove("fa-moon-o"); | 	icon.classList.remove('fa-moon-o') | ||||||
| 	icon.classList.add(state ? "fa-sun-o" : "fa-moon-o"); | 	icon.classList.add(state ? 'fa-sun-o' : 'fa-moon-o') | ||||||
| 	darkModeState = state; | 	darkModeState = state | ||||||
| } | } | ||||||
|  |  | ||||||
| document.addEventListener("DOMContentLoaded", load); | document.addEventListener('DOMContentLoaded', load) | ||||||
|   | |||||||
| @@ -7,12 +7,12 @@ easier. | |||||||
|  |  | ||||||
| .. _advanced-matching: | .. _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 | Paperless will compare the matching algorithms defined by every tag, correspondent, | ||||||
| correspondent already set in your database to see if they apply to the text in | document type, and storage path in your database to see if they apply to the text | ||||||
| a document.  In other words, if you defined a tag called ``Home Utility`` | 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 | that had a ``match`` property of ``bc hydro`` and a ``matching_algorithm`` of | ||||||
| ``literal``, Paperless will automatically tag your newly-consumed document with | ``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 | 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 | document with different algorithms, and as such, some experimentation may be | ||||||
| necessary to get things right. | necessary to get things right. | ||||||
|  |  | ||||||
| In order to have a tag, correspondent, or type assigned automatically to newly | In order to have a tag, correspondent, document type, or storage path assigned | ||||||
| consumed documents, assign a match and matching algorithm using the web | automatically to newly consumed documents, assign a match and matching algorithm | ||||||
| interface. These settings define when to assign correspondents, tags, and types | using the web interface. These settings define when to assign tags, correspondents, | ||||||
| to documents. | document types, and storage paths to documents. | ||||||
|  |  | ||||||
| The following algorithms are available: | 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. | * **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 | * **Regular expression:** Parses the match as a regular expression and tries to | ||||||
|   find a match within the document. |   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 | * **Auto:** Tries to automatically match new documents. This does not require you | ||||||
|   to set a match. See the notes below. |   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 | will match documents that contain either "Bank of America" or "BofA", but will | ||||||
| not match documents containing "Bank of South America". | not match documents containing "Bank of South America". | ||||||
|  |  | ||||||
| Then just save your tag/correspondent and run another document through the | Then just save your tag, correspondent, document type, or storage path and run | ||||||
| consumer.  Once complete, you should see the newly-created document, | another document through the consumer.  Once complete, you should see the | ||||||
| automatically tagged with the appropriate data. | newly-created document, automatically tagged with the appropriate data. | ||||||
|  |  | ||||||
|  |  | ||||||
| .. _advanced-automatic_matching: | .. _advanced-automatic_matching: | ||||||
| @@ -58,9 +58,9 @@ Automatic matching | |||||||
| ================== | ================== | ||||||
|  |  | ||||||
| Paperless-ngx comes with a new matching algorithm called *Auto*. This matching | Paperless-ngx comes with a new matching algorithm called *Auto*. This matching | ||||||
| algorithm tries to assign tags, correspondents, and document types to your | algorithm tries to assign tags, correspondents, document types, and storage paths | ||||||
| documents based on how you have already assigned these on existing documents. It | to your documents based on how you have already assigned these on existing documents. | ||||||
| uses a neural network under the hood. | It uses a neural network under the hood. | ||||||
|  |  | ||||||
| If, for example, all your bank statements of your account 123 at the Bank of | 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 | 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 |   that the neural network only learns from documents which you have correctly | ||||||
|   tagged before. |   tagged before. | ||||||
| * The matching algorithm can only work if there is a correlation between the | * The matching algorithm can only work if there is a correlation between the | ||||||
|   tag, correspondent, or document type and the document itself. Your bank |   tag, correspondent, document type, or storage path and the document itself. | ||||||
|   statements usually contain your bank account number and the name of the bank, |   Your bank statements usually contain your bank account number and the name | ||||||
|   so this works reasonably well, However, tags such as "TODO" cannot be |   of the bank, so this works reasonably well, However, tags such as "TODO" | ||||||
|   automatically assigned. |   cannot be automatically assigned. | ||||||
| * The matching algorithm needs a reasonable number of documents to identify when | * 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 |   to assign tags, correspondents, storage paths, and types. If one out of a | ||||||
|   has the correspondent "Very obscure web shop I bought something five years |   thousand documents has the correspondent "Very obscure web shop I bought | ||||||
|   ago", it will probably not assign this correspondent automatically if you buy |   something five years ago", it will probably not assign this correspondent | ||||||
|   something from them again. The more documents, the better. |   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 | * 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 |   not to assign a certain tag, correspondent, document type, or storage path. This will | ||||||
|   case as you start filling up paperless with documents. Example: If all your |   usually be the case as you start filling up paperless with documents. | ||||||
|   documents are either from "Webshop" and "Bank", paperless will assign one of |   Example: If all your documents are either from "Webshop" and "Bank", paperless | ||||||
|   these correspondents to ANY new document, if both are set to automatic matching. |   will assign one of these correspondents to ANY new document, if both are set | ||||||
|  |   to automatic matching. | ||||||
|  |  | ||||||
| Hooking into the consumption process | 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 | append ``_01``, ``_02``, etc to the filename. This happens if all the placeholders in a filename | ||||||
| evaluate to the same value. | 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:: | .. hint:: | ||||||
|  |  | ||||||
|     Paperless checks the filename of a document whenever it is saved. Therefore, |     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 |     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. |     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. |     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> | PAPERLESS_LOGGING_DIR=<path> | ||||||
|     This is where paperless will store log files. |     This is where paperless will store log files. | ||||||
|  |  | ||||||
|   | |||||||
| @@ -23,6 +23,7 @@ | |||||||
| #PAPERLESS_MEDIA_ROOT=../media | #PAPERLESS_MEDIA_ROOT=../media | ||||||
| #PAPERLESS_STATICDIR=../static | #PAPERLESS_STATICDIR=../static | ||||||
| #PAPERLESS_FILENAME_FORMAT= | #PAPERLESS_FILENAME_FORMAT= | ||||||
|  | #PAPERLESS_FILENAME_FORMAT_REMOVE_NONE= | ||||||
|  |  | ||||||
| # Security and hosting | # Security and hosting | ||||||
|  |  | ||||||
|   | |||||||
| @@ -6,4 +6,4 @@ | |||||||
|   "pluginsFile": "cypress/plugins/index.ts", |   "pluginsFile": "cypress/plugins/index.ts", | ||||||
|   "fixturesFolder": "cypress/fixtures", |   "fixturesFolder": "cypress/fixtures", | ||||||
|   "baseUrl": "http://localhost:4200" |   "baseUrl": "http://localhost:4200" | ||||||
| } | } | ||||||
|   | |||||||
| @@ -12,6 +12,7 @@ import { TagListComponent } from './components/manage/tag-list/tag-list.componen | |||||||
| import { NotFoundComponent } from './components/not-found/not-found.component' | import { NotFoundComponent } from './components/not-found/not-found.component' | ||||||
| import { DocumentAsnComponent } from './components/document-asn/document-asn.component' | import { DocumentAsnComponent } from './components/document-asn/document-asn.component' | ||||||
| import { DirtyFormGuard } from './guards/dirty-form.guard' | import { DirtyFormGuard } from './guards/dirty-form.guard' | ||||||
|  | import { StoragePathListComponent } from './components/manage/storage-path-list/storage-path-list.component' | ||||||
|  |  | ||||||
| const routes: Routes = [ | const routes: Routes = [ | ||||||
|   { path: '', redirectTo: 'dashboard', pathMatch: 'full' }, |   { path: '', redirectTo: 'dashboard', pathMatch: 'full' }, | ||||||
| @@ -27,6 +28,7 @@ const routes: Routes = [ | |||||||
|       { path: 'tags', component: TagListComponent }, |       { path: 'tags', component: TagListComponent }, | ||||||
|       { path: 'documenttypes', component: DocumentTypeListComponent }, |       { path: 'documenttypes', component: DocumentTypeListComponent }, | ||||||
|       { path: 'correspondents', component: CorrespondentListComponent }, |       { path: 'correspondents', component: CorrespondentListComponent }, | ||||||
|  |       { path: 'storagepaths', component: StoragePathListComponent }, | ||||||
|       { path: 'logs', component: LogsComponent }, |       { path: 'logs', component: LogsComponent }, | ||||||
|       { |       { | ||||||
|         path: 'settings', |         path: 'settings', | ||||||
|   | |||||||
| @@ -87,6 +87,8 @@ import localeSr from '@angular/common/locales/sr' | |||||||
| import localeSv from '@angular/common/locales/sv' | import localeSv from '@angular/common/locales/sv' | ||||||
| import localeTr from '@angular/common/locales/tr' | import localeTr from '@angular/common/locales/tr' | ||||||
| import localeZh from '@angular/common/locales/zh' | 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' | import { SettingsService } from './services/settings.service' | ||||||
|  |  | ||||||
| registerLocaleData(localeBe) | registerLocaleData(localeBe) | ||||||
| @@ -125,6 +127,7 @@ function initializeApp(settings: SettingsService) { | |||||||
|     TagListComponent, |     TagListComponent, | ||||||
|     DocumentTypeListComponent, |     DocumentTypeListComponent, | ||||||
|     CorrespondentListComponent, |     CorrespondentListComponent, | ||||||
|  |     StoragePathListComponent, | ||||||
|     LogsComponent, |     LogsComponent, | ||||||
|     SettingsComponent, |     SettingsComponent, | ||||||
|     NotFoundComponent, |     NotFoundComponent, | ||||||
| @@ -132,6 +135,7 @@ function initializeApp(settings: SettingsService) { | |||||||
|     ConfirmDialogComponent, |     ConfirmDialogComponent, | ||||||
|     TagEditDialogComponent, |     TagEditDialogComponent, | ||||||
|     DocumentTypeEditDialogComponent, |     DocumentTypeEditDialogComponent, | ||||||
|  |     StoragePathEditDialogComponent, | ||||||
|     TagComponent, |     TagComponent, | ||||||
|     PageHeaderComponent, |     PageHeaderComponent, | ||||||
|     AppFrameComponent, |     AppFrameComponent, | ||||||
|   | |||||||
| @@ -134,6 +134,13 @@ | |||||||
|               </svg> <ng-container i18n>Document types</ng-container> |               </svg> <ng-container i18n>Document types</ng-container> | ||||||
|             </a> |             </a> | ||||||
|           </li> |           </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"> |           <li class="nav-item"> | ||||||
|             <a class="nav-link" routerLink="logs" routerLinkActive="active" (click)="closeMenu()"> |             <a class="nav-link" routerLink="logs" routerLinkActive="active" (click)="closeMenu()"> | ||||||
|               <svg class="sidebaricon" fill="currentColor"> |               <svg class="sidebaricon" fill="currentColor"> | ||||||
|   | |||||||
| @@ -5,7 +5,7 @@ | |||||||
|     </div> |     </div> | ||||||
|     <div class="modal-body"> |     <div class="modal-body"> | ||||||
|       <p *ngIf="messageBold"><b>{{messageBold}}</b></p> |       <p *ngIf="messageBold"><b>{{messageBold}}</b></p> | ||||||
|       <p *ngIf="message">{{message}}</p> |       <p class="mb-0" *ngIf="message" [innerHTML]="message | safeHtml"></p> | ||||||
|     </div> |     </div> | ||||||
|     <div class="modal-footer"> |     <div class="modal-footer"> | ||||||
|       <button type="button" class="btn btn-outline-secondary" (click)="cancel()" [disabled]="!buttonsEnabled" i18n> |       <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" |         [items]="items" | ||||||
|         [addTag]="allowCreateNew && addItemRef" |         [addTag]="allowCreateNew && addItemRef" | ||||||
|         addTagText="Add item" |         addTagText="Add item" | ||||||
|         i18n-addTagText="Used for both types and correspondents" |         i18n-addTagText="Used for both types, correspondents, storage paths" | ||||||
|  |         [placeholder]="placeholder" | ||||||
|         bindLabel="name" |         bindLabel="name" | ||||||
|         bindValue="id" |         bindValue="id" | ||||||
|         (change)="onChange(value)" |         (change)="onChange(value)" | ||||||
|   | |||||||
| @@ -41,6 +41,9 @@ export class SelectComponent extends AbstractInputComponent<number> { | |||||||
|   @Input() |   @Input() | ||||||
|   suggestions: number[] |   suggestions: number[] | ||||||
|  |  | ||||||
|  |   @Input() | ||||||
|  |   placeholder: string | ||||||
|  |  | ||||||
|   @Output() |   @Output() | ||||||
|   createNew = new EventEmitter<string>() |   createNew = new EventEmitter<string>() | ||||||
|  |  | ||||||
|   | |||||||
| @@ -1,7 +1,7 @@ | |||||||
| <div class="mb-3"> | <div class="mb-3"> | ||||||
|   <label class="form-label" [for]="inputId">{{title}}</label> |   <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)"> |   <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"> |   <div class="invalid-feedback"> | ||||||
|     {{error}} |     {{error}} | ||||||
|   </div> |   </div> | ||||||
|   | |||||||
| @@ -8,4 +8,4 @@ | |||||||
|  |  | ||||||
| .toast:not(.show) { | .toast:not(.show) { | ||||||
|   display: block; // this corrects an ng-bootstrap bug that prevented animations |   display: block; // this corrects an ng-bootstrap bug that prevented animations | ||||||
| } | } | ||||||
|   | |||||||
| @@ -73,6 +73,8 @@ | |||||||
|                             (createNew)="createCorrespondent($event)" [suggestions]="suggestions?.correspondents"></app-input-select> |                             (createNew)="createCorrespondent($event)" [suggestions]="suggestions?.correspondents"></app-input-select> | ||||||
|                         <app-input-select [items]="documentTypes" i18n-title title="Document type" formControlName="document_type" [allowNull]="true" |                         <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> |                             (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> |                         <app-input-tags formControlName="tags" [suggestions]="suggestions?.tags"></app-input-tags> | ||||||
|  |  | ||||||
|                     </ng-template> |                     </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 { FILTER_FULLTEXT_MORELIKE } from 'src/app/data/filter-rule-type' | ||||||
| import { normalizeDateStr } from 'src/app/utils/date' | import { normalizeDateStr } from 'src/app/utils/date' | ||||||
| import { QueryParamsService } from 'src/app/services/query-params.service' | 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' | import { SETTINGS_KEYS } from 'src/app/data/paperless-uisettings' | ||||||
|  |  | ||||||
| @Component({ | @Component({ | ||||||
| @@ -66,6 +69,7 @@ export class DocumentDetailComponent | |||||||
|  |  | ||||||
|   correspondents: PaperlessCorrespondent[] |   correspondents: PaperlessCorrespondent[] | ||||||
|   documentTypes: PaperlessDocumentType[] |   documentTypes: PaperlessDocumentType[] | ||||||
|  |   storagePaths: PaperlessStoragePath[] | ||||||
|  |  | ||||||
|   documentForm: FormGroup = new FormGroup({ |   documentForm: FormGroup = new FormGroup({ | ||||||
|     title: new FormControl(''), |     title: new FormControl(''), | ||||||
| @@ -73,6 +77,7 @@ export class DocumentDetailComponent | |||||||
|     created: new FormControl(), |     created: new FormControl(), | ||||||
|     correspondent: new FormControl(), |     correspondent: new FormControl(), | ||||||
|     document_type: new FormControl(), |     document_type: new FormControl(), | ||||||
|  |     storage_path: new FormControl(), | ||||||
|     archive_serial_number: new FormControl(), |     archive_serial_number: new FormControl(), | ||||||
|     tags: new FormControl([]), |     tags: new FormControl([]), | ||||||
|   }) |   }) | ||||||
| @@ -115,6 +120,7 @@ export class DocumentDetailComponent | |||||||
|     private documentTitlePipe: DocumentTitlePipe, |     private documentTitlePipe: DocumentTitlePipe, | ||||||
|     private toastService: ToastService, |     private toastService: ToastService, | ||||||
|     private settings: SettingsService, |     private settings: SettingsService, | ||||||
|  |     private storagePathService: StoragePathService, | ||||||
|     private queryParamsService: QueryParamsService |     private queryParamsService: QueryParamsService | ||||||
|   ) {} |   ) {} | ||||||
|  |  | ||||||
| @@ -163,11 +169,17 @@ export class DocumentDetailComponent | |||||||
|       .listAll() |       .listAll() | ||||||
|       .pipe(first()) |       .pipe(first()) | ||||||
|       .subscribe((result) => (this.correspondents = result.results)) |       .subscribe((result) => (this.correspondents = result.results)) | ||||||
|  |  | ||||||
|     this.documentTypeService |     this.documentTypeService | ||||||
|       .listAll() |       .listAll() | ||||||
|       .pipe(first()) |       .pipe(first()) | ||||||
|       .subscribe((result) => (this.documentTypes = result.results)) |       .subscribe((result) => (this.documentTypes = result.results)) | ||||||
|  |  | ||||||
|  |     this.storagePathService | ||||||
|  |       .listAll() | ||||||
|  |       .pipe(first()) | ||||||
|  |       .subscribe((result) => (this.storagePaths = result.results)) | ||||||
|  |  | ||||||
|     this.route.paramMap |     this.route.paramMap | ||||||
|       .pipe( |       .pipe( | ||||||
|         takeUntil(this.unsubscribeNotifier), |         takeUntil(this.unsubscribeNotifier), | ||||||
| @@ -230,6 +242,7 @@ export class DocumentDetailComponent | |||||||
|             created: this.ogDate.toISOString(), |             created: this.ogDate.toISOString(), | ||||||
|             correspondent: doc.correspondent, |             correspondent: doc.correspondent, | ||||||
|             document_type: doc.document_type, |             document_type: doc.document_type, | ||||||
|  |             storage_path: doc.storage_path, | ||||||
|             archive_serial_number: doc.archive_serial_number, |             archive_serial_number: doc.archive_serial_number, | ||||||
|             tags: [...doc.tags], |             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() { |   discard() { | ||||||
|     this.documentsService |     this.documentsService | ||||||
|       .get(this.documentId) |       .get(this.documentId) | ||||||
|   | |||||||
| @@ -53,6 +53,15 @@ | |||||||
|         [(selectionModel)]="documentTypeSelectionModel" |         [(selectionModel)]="documentTypeSelectionModel" | ||||||
|         (apply)="setDocumentTypes($event)"> |         (apply)="setDocumentTypes($event)"> | ||||||
|       </app-filterable-dropdown> |       </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> |   </div> | ||||||
|   <div class="col-auto ms-auto mb-2 mb-xl-0 d-flex"> |   <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 { SettingsService } from 'src/app/services/settings.service' | ||||||
| import { ToastService } from 'src/app/services/toast.service' | import { ToastService } from 'src/app/services/toast.service' | ||||||
| import { saveAs } from 'file-saver' | 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' | import { SETTINGS_KEYS } from 'src/app/data/paperless-uisettings' | ||||||
|  |  | ||||||
| @Component({ | @Component({ | ||||||
| @@ -33,10 +35,12 @@ export class BulkEditorComponent { | |||||||
|   tags: PaperlessTag[] |   tags: PaperlessTag[] | ||||||
|   correspondents: PaperlessCorrespondent[] |   correspondents: PaperlessCorrespondent[] | ||||||
|   documentTypes: PaperlessDocumentType[] |   documentTypes: PaperlessDocumentType[] | ||||||
|  |   storagePaths: PaperlessStoragePath[] | ||||||
|  |  | ||||||
|   tagSelectionModel = new FilterableDropdownSelectionModel() |   tagSelectionModel = new FilterableDropdownSelectionModel() | ||||||
|   correspondentSelectionModel = new FilterableDropdownSelectionModel() |   correspondentSelectionModel = new FilterableDropdownSelectionModel() | ||||||
|   documentTypeSelectionModel = new FilterableDropdownSelectionModel() |   documentTypeSelectionModel = new FilterableDropdownSelectionModel() | ||||||
|  |   storagePathsSelectionModel = new FilterableDropdownSelectionModel() | ||||||
|   awaitingDownload: boolean |   awaitingDownload: boolean | ||||||
|  |  | ||||||
|   constructor( |   constructor( | ||||||
| @@ -48,7 +52,8 @@ export class BulkEditorComponent { | |||||||
|     private modalService: NgbModal, |     private modalService: NgbModal, | ||||||
|     private openDocumentService: OpenDocumentsService, |     private openDocumentService: OpenDocumentsService, | ||||||
|     private settings: SettingsService, |     private settings: SettingsService, | ||||||
|     private toastService: ToastService |     private toastService: ToastService, | ||||||
|  |     private storagePathService: StoragePathService | ||||||
|   ) {} |   ) {} | ||||||
|  |  | ||||||
|   applyOnClose: boolean = this.settings.get( |   applyOnClose: boolean = this.settings.get( | ||||||
| @@ -68,6 +73,9 @@ export class BulkEditorComponent { | |||||||
|     this.documentTypeService |     this.documentTypeService | ||||||
|       .listAll() |       .listAll() | ||||||
|       .subscribe((result) => (this.documentTypes = result.results)) |       .subscribe((result) => (this.documentTypes = result.results)) | ||||||
|  |     this.storagePathService | ||||||
|  |       .listAll() | ||||||
|  |       .subscribe((result) => (this.storagePaths = result.results)) | ||||||
|   } |   } | ||||||
|  |  | ||||||
|   private executeBulkOperation(modal, method: string, args) { |   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[]) { |   private _localizeList(items: MatchingModel[]) { | ||||||
|     if (items.length == 0) { |     if (items.length == 0) { | ||||||
|       return '' |       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() { |   applyDelete() { | ||||||
|     let modal = this.modalService.open(ConfirmDialogComponent, { |     let modal = this.modalService.open(ConfirmDialogComponent, { | ||||||
|       backdrop: 'static', |       backdrop: 'static', | ||||||
|   | |||||||
| @@ -67,6 +67,13 @@ | |||||||
|               </svg> |               </svg> | ||||||
|               <small>{{(document.document_type$ | async)?.name}}</small> |               <small>{{(document.document_type$ | async)?.name}}</small> | ||||||
|             </button> |             </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"> |             <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"> |               <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"/> |                 <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() |   @Output() | ||||||
|   clickDocumentType = new EventEmitter<number>() |   clickDocumentType = new EventEmitter<number>() | ||||||
|  |  | ||||||
|  |   @Output() | ||||||
|  |   clickStoragePath = new EventEmitter<number>() | ||||||
|  |  | ||||||
|   @Output() |   @Output() | ||||||
|   clickMoreLike = new EventEmitter() |   clickMoreLike = new EventEmitter() | ||||||
|  |  | ||||||
|   | |||||||
| @@ -37,6 +37,13 @@ | |||||||
|           </svg> |           </svg> | ||||||
|           <small>{{(document.document_type$ | async)?.name}}</small> |           <small>{{(document.document_type$ | async)?.name}}</small> | ||||||
|         </button> |         </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"> |         <div class="list-group-item bg-transparent p-0 border-0 d-flex flex-wrap-reverse justify-content-between"> | ||||||
|           <ng-template #dateTooltip> |           <ng-template #dateTooltip> | ||||||
|             <div class="d-flex flex-column"> |             <div class="d-flex flex-column"> | ||||||
|   | |||||||
| @@ -47,6 +47,9 @@ export class DocumentCardSmallComponent implements OnInit { | |||||||
|   @Output() |   @Output() | ||||||
|   clickDocumentType = new EventEmitter<number>() |   clickDocumentType = new EventEmitter<number>() | ||||||
|  |  | ||||||
|  |   @Output() | ||||||
|  |   clickStoragePath = new EventEmitter<number>() | ||||||
|  |  | ||||||
|   moreTags: number = null |   moreTags: number = null | ||||||
|  |  | ||||||
|   @ViewChild('popover') popover: NgbPopover |   @ViewChild('popover') popover: NgbPopover | ||||||
|   | |||||||
| @@ -107,7 +107,7 @@ | |||||||
| <ng-template #documentListNoError> | <ng-template #documentListNoError> | ||||||
|  |  | ||||||
|   <div *ngIf="displayMode == 'largeCards'"> |   <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> |     </app-document-card-large> | ||||||
|   </div> |   </div> | ||||||
|  |  | ||||||
| @@ -138,6 +138,12 @@ | |||||||
|         [currentSortReverse]="list.sortReverse" |         [currentSortReverse]="list.sortReverse" | ||||||
|         (sort)="onSort($event)" |         (sort)="onSort($event)" | ||||||
|         i18n>Document type</th> |         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 |       <th | ||||||
|         sortable="created" |         sortable="created" | ||||||
|         [currentSortField]="list.sortField" |         [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> |             <a (click)="clickDocumentType(d.document_type);$event.stopPropagation()" title="Filter by document type">{{(d.document_type$ | async)?.name}}</a> | ||||||
|           </ng-container> |           </ng-container> | ||||||
|         </td> |         </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> |         <td> | ||||||
|           {{d.created | customDate}} |           {{d.created | customDate}} | ||||||
|         </td> |         </td> | ||||||
| @@ -187,7 +198,7 @@ | |||||||
|   </table> |   </table> | ||||||
|  |  | ||||||
|   <div class="row row-cols-paperless-cards" *ngIf="displayMode == 'smallCards'"> |   <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> | ||||||
|   <div *ngIf="list.documents?.length > 15" class="mt-3"> |   <div *ngIf="list.documents?.length > 15" class="mt-3"> | ||||||
|     <ng-container *ngTemplateOutlet="pagination"></ng-container> |     <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) { |   clickMoreLike(documentID: number) { | ||||||
|     this.queryParamsService.navigateWithFilterRules([ |     this.queryParamsService.navigateWithFilterRules([ | ||||||
|       { rule_type: FILTER_FULLTEXT_MORELIKE, value: documentID.toString() }, |       { 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="col mb-2 mb-xl-0"> | ||||||
|      <div class="form-inline d-flex align-items-center"> |      <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> |            <div ngbDropdown> | ||||||
|             <button class="btn btn-sm btn-outline-primary" ngbDropdownToggle>{{textFilterTargetName}}</button> |             <button class="btn btn-sm btn-outline-primary" ngbDropdownToggle>{{textFilterTargetName}}</button> | ||||||
|             <div class="dropdown-menu shadow" ngbDropdownMenu> |             <div class="dropdown-menu shadow" ngbDropdownMenu> | ||||||
| @@ -18,43 +18,54 @@ | |||||||
|   <div class="w-100 d-xl-none"></div> |   <div class="w-100 d-xl-none"></div> | ||||||
|     <div class="col col-xl-auto"> |     <div class="col col-xl-auto"> | ||||||
|       <div class="d-flex flex-wrap"> |       <div class="d-flex flex-wrap"> | ||||||
|         <app-filterable-dropdown class="flex-fill" title="Tags" icon="tag-fill" i18n-title |         <div class="d-flex flex-wrap mb-2 mb-lg-0"> | ||||||
|           filterPlaceholder="Filter tags" i18n-filterPlaceholder |           <app-filterable-dropdown class="flex-fill" title="Tags" icon="tag-fill" i18n-title | ||||||
|           [items]="tags" |             filterPlaceholder="Filter tags" i18n-filterPlaceholder | ||||||
|           [(selectionModel)]="tagSelectionModel" |             [items]="tags" | ||||||
|           (selectionModelChange)="updateRules()" |             [(selectionModel)]="tagSelectionModel" | ||||||
|           [multiple]="true" |             (selectionModelChange)="updateRules()" | ||||||
|           (open)="onTagsDropdownOpen()" |             [multiple]="true" | ||||||
|           [allowSelectNone]="true"></app-filterable-dropdown> |             (open)="onTagsDropdownOpen()" | ||||||
|         <app-filterable-dropdown class="flex-fill" title="Correspondent" icon="person-fill" i18n-title |             [allowSelectNone]="true"></app-filterable-dropdown> | ||||||
|           filterPlaceholder="Filter correspondents" i18n-filterPlaceholder |           <app-filterable-dropdown class="flex-fill" title="Correspondent" icon="person-fill" i18n-title | ||||||
|           [items]="correspondents" |             filterPlaceholder="Filter correspondents" i18n-filterPlaceholder | ||||||
|           [(selectionModel)]="correspondentSelectionModel" |             [items]="correspondents" | ||||||
|           (selectionModelChange)="updateRules()" |             [(selectionModel)]="correspondentSelectionModel" | ||||||
|           (open)="onCorrespondentDropdownOpen()" |             (selectionModelChange)="updateRules()" | ||||||
|           [allowSelectNone]="true"></app-filterable-dropdown> |             (open)="onCorrespondentDropdownOpen()" | ||||||
|         <app-filterable-dropdown class="flex-fill" title="Document type" icon="file-earmark-fill" i18n-title |             [allowSelectNone]="true"></app-filterable-dropdown> | ||||||
|           filterPlaceholder="Filter document types" i18n-filterPlaceholder |           <app-filterable-dropdown class="flex-fill" title="Document type" icon="file-earmark-fill" i18n-title | ||||||
|           [items]="documentTypes" |             filterPlaceholder="Filter document types" i18n-filterPlaceholder | ||||||
|           [(selectionModel)]="documentTypeSelectionModel" |             [items]="documentTypes" | ||||||
|           (open)="onDocumentTypeDropdownOpen()" |             [(selectionModel)]="documentTypeSelectionModel" | ||||||
|           (selectionModelChange)="updateRules()" |             (open)="onDocumentTypeDropdownOpen()" | ||||||
|           [allowSelectNone]="true"></app-filterable-dropdown> |             (selectionModelChange)="updateRules()" | ||||||
|         <app-date-dropdown class="mb-2 mb-xl-0" |             [allowSelectNone]="true"></app-filterable-dropdown> | ||||||
|           title="Created" i18n-title |           <app-filterable-dropdown class="me-2 flex-fill" title="Storage path" icon="folder-fill" i18n-title | ||||||
|           (datesSet)="updateRules()" |             filterPlaceholder="Filter storage path" i18n-filterPlaceholder | ||||||
|           [(dateBefore)]="dateCreatedBefore" |             [items]="storagePaths" | ||||||
|           [(dateAfter)]="dateCreatedAfter"></app-date-dropdown> |             [(selectionModel)]="storagePathSelectionModel" | ||||||
|         <app-date-dropdown class="mb-2 mb-xl-0" |             (open)="onStoragePathDropdownOpen()" | ||||||
|           [(dateBefore)]="dateAddedBefore" |             (selectionModelChange)="updateRules()" | ||||||
|           [(dateAfter)]="dateAddedAfter" |             [allowSelectNone]="true"></app-filterable-dropdown> | ||||||
|           title="Added" i18n-title |         </div> | ||||||
|           (datesSet)="updateRules()"></app-date-dropdown> |         <div class="d-flex flex-wrap"> | ||||||
|  |           <app-date-dropdown class="mb-2 mb-xl-0" | ||||||
|  |             title="Created" i18n-title | ||||||
|  |             (datesSet)="updateRules()" | ||||||
|  |             [(dateBefore)]="dateCreatedBefore" | ||||||
|  |             [(dateAfter)]="dateCreatedAfter"></app-date-dropdown> | ||||||
|  |           <app-date-dropdown class="mb-2 mb-xl-0" | ||||||
|  |             [(dateBefore)]="dateAddedBefore" | ||||||
|  |             [(dateAfter)]="dateAddedAfter" | ||||||
|  |             title="Added" i18n-title | ||||||
|  |             (datesSet)="updateRules()"></app-date-dropdown> | ||||||
|  |         </div> | ||||||
|      </div> |      </div> | ||||||
|    </div> |    </div> | ||||||
|    <div class="w-100 d-xl-none"></div> |    <div class="w-100 d-xl-none"></div> | ||||||
|    <div class="col col-xl-auto"> |    <div class="col col-xl-auto ps-0"> | ||||||
|      <button class="btn btn-link btn-sm px-0 mx-0 ms-xl-n3" [disabled]="!rulesModified" (click)="resetSelected()"> |      <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"> |        <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"/> |          <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> |        </svg><ng-container i18n>Reset filters</ng-container> | ||||||
|   | |||||||
| @@ -33,6 +33,7 @@ import { | |||||||
|   FILTER_DOES_NOT_HAVE_TAG, |   FILTER_DOES_NOT_HAVE_TAG, | ||||||
|   FILTER_TITLE, |   FILTER_TITLE, | ||||||
|   FILTER_TITLE_CONTENT, |   FILTER_TITLE_CONTENT, | ||||||
|  |   FILTER_STORAGE_PATH, | ||||||
|   FILTER_ASN_ISNULL, |   FILTER_ASN_ISNULL, | ||||||
|   FILTER_ASN_GT, |   FILTER_ASN_GT, | ||||||
|   FILTER_ASN_LT, |   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 { ToggleableItemState } from '../../common/filterable-dropdown/toggleable-dropdown-button/toggleable-dropdown-button.component' | ||||||
| import { DocumentService } from 'src/app/services/rest/document.service' | import { DocumentService } from 'src/app/services/rest/document.service' | ||||||
| import { PaperlessDocument } from 'src/app/data/paperless-document' | 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 = 'title' | ||||||
| const TEXT_FILTER_TARGET_TITLE_CONTENT = 'title-content' | const TEXT_FILTER_TARGET_TITLE_CONTENT = 'title-content' | ||||||
| @@ -107,7 +110,8 @@ export class FilterEditorComponent implements OnInit, OnDestroy { | |||||||
|     private documentTypeService: DocumentTypeService, |     private documentTypeService: DocumentTypeService, | ||||||
|     private tagService: TagService, |     private tagService: TagService, | ||||||
|     private correspondentService: CorrespondentService, |     private correspondentService: CorrespondentService, | ||||||
|     private documentService: DocumentService |     private documentService: DocumentService, | ||||||
|  |     private storagePathService: StoragePathService | ||||||
|   ) {} |   ) {} | ||||||
|  |  | ||||||
|   @ViewChild('textFilterInput') |   @ViewChild('textFilterInput') | ||||||
| @@ -116,6 +120,7 @@ export class FilterEditorComponent implements OnInit, OnDestroy { | |||||||
|   tags: PaperlessTag[] = [] |   tags: PaperlessTag[] = [] | ||||||
|   correspondents: PaperlessCorrespondent[] = [] |   correspondents: PaperlessCorrespondent[] = [] | ||||||
|   documentTypes: PaperlessDocumentType[] = [] |   documentTypes: PaperlessDocumentType[] = [] | ||||||
|  |   storagePaths: PaperlessStoragePath[] = [] | ||||||
|  |  | ||||||
|   _textFilter = '' |   _textFilter = '' | ||||||
|   _moreLikeId: number |   _moreLikeId: number | ||||||
| @@ -186,6 +191,7 @@ export class FilterEditorComponent implements OnInit, OnDestroy { | |||||||
|   tagSelectionModel = new FilterableDropdownSelectionModel() |   tagSelectionModel = new FilterableDropdownSelectionModel() | ||||||
|   correspondentSelectionModel = new FilterableDropdownSelectionModel() |   correspondentSelectionModel = new FilterableDropdownSelectionModel() | ||||||
|   documentTypeSelectionModel = new FilterableDropdownSelectionModel() |   documentTypeSelectionModel = new FilterableDropdownSelectionModel() | ||||||
|  |   storagePathSelectionModel = new FilterableDropdownSelectionModel() | ||||||
|  |  | ||||||
|   dateCreatedBefore: string |   dateCreatedBefore: string | ||||||
|   dateCreatedAfter: string |   dateCreatedAfter: string | ||||||
| @@ -210,6 +216,7 @@ export class FilterEditorComponent implements OnInit, OnDestroy { | |||||||
|     this._filterRules = value |     this._filterRules = value | ||||||
|  |  | ||||||
|     this.documentTypeSelectionModel.clear(false) |     this.documentTypeSelectionModel.clear(false) | ||||||
|  |     this.storagePathSelectionModel.clear(false) | ||||||
|     this.tagSelectionModel.clear(false) |     this.tagSelectionModel.clear(false) | ||||||
|     this.correspondentSelectionModel.clear(false) |     this.correspondentSelectionModel.clear(false) | ||||||
|     this._textFilter = null |     this._textFilter = null | ||||||
| @@ -297,6 +304,13 @@ export class FilterEditorComponent implements OnInit, OnDestroy { | |||||||
|             false |             false | ||||||
|           ) |           ) | ||||||
|           break |           break | ||||||
|  |         case FILTER_STORAGE_PATH: | ||||||
|  |           this.storagePathSelectionModel.set( | ||||||
|  |             rule.value ? +rule.value : null, | ||||||
|  |             ToggleableItemState.Selected, | ||||||
|  |             false | ||||||
|  |           ) | ||||||
|  |           break | ||||||
|         case FILTER_ASN_ISNULL: |         case FILTER_ASN_ISNULL: | ||||||
|           this.textFilterTarget = TEXT_FILTER_TARGET_ASN |           this.textFilterTarget = TEXT_FILTER_TARGET_ASN | ||||||
|           this.textFilterModifier = TEXT_FILTER_MODIFIER_NULL |           this.textFilterModifier = TEXT_FILTER_MODIFIER_NULL | ||||||
| @@ -418,6 +432,12 @@ export class FilterEditorComponent implements OnInit, OnDestroy { | |||||||
|           value: documentType.id?.toString(), |           value: documentType.id?.toString(), | ||||||
|         }) |         }) | ||||||
|       }) |       }) | ||||||
|  |     this.storagePathSelectionModel.getSelectedItems().forEach((storagePath) => { | ||||||
|  |       filterRules.push({ | ||||||
|  |         rule_type: FILTER_STORAGE_PATH, | ||||||
|  |         value: storagePath.id?.toString(), | ||||||
|  |       }) | ||||||
|  |     }) | ||||||
|     if (this.dateCreatedBefore) { |     if (this.dateCreatedBefore) { | ||||||
|       filterRules.push({ |       filterRules.push({ | ||||||
|         rule_type: FILTER_CREATED_BEFORE, |         rule_type: FILTER_CREATED_BEFORE, | ||||||
| @@ -500,6 +520,9 @@ export class FilterEditorComponent implements OnInit, OnDestroy { | |||||||
|     this.documentTypeService |     this.documentTypeService | ||||||
|       .listAll() |       .listAll() | ||||||
|       .subscribe((result) => (this.documentTypes = result.results)) |       .subscribe((result) => (this.documentTypes = result.results)) | ||||||
|  |     this.storagePathService | ||||||
|  |       .listAll() | ||||||
|  |       .subscribe((result) => (this.storagePaths = result.results)) | ||||||
|  |  | ||||||
|     this.textFilterDebounce = new Subject<string>() |     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() { |   onTagsDropdownOpen() { | ||||||
|     this.tagSelectionModel.apply() |     this.tagSelectionModel.apply() | ||||||
|   } |   } | ||||||
| @@ -554,6 +584,10 @@ export class FilterEditorComponent implements OnInit, OnDestroy { | |||||||
|     this.documentTypeSelectionModel.apply() |     this.documentTypeSelectionModel.apply() | ||||||
|   } |   } | ||||||
|  |  | ||||||
|  |   onStoragePathDropdownOpen() { | ||||||
|  |     this.storagePathSelectionModel.apply() | ||||||
|  |   } | ||||||
|  |  | ||||||
|   updateTextFilter(text) { |   updateTextFilter(text) { | ||||||
|     this._textFilter = text |     this._textFilter = text | ||||||
|     this.documentService.searchQuery = 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_QUERY = 22 | ||||||
| export const FILTER_FULLTEXT_MORELIKE = 23 | export const FILTER_FULLTEXT_MORELIKE = 23 | ||||||
|  |  | ||||||
|  | export const FILTER_STORAGE_PATH = 30 | ||||||
|  |  | ||||||
| export const FILTER_RULE_TYPES: FilterRuleType[] = [ | export const FILTER_RULE_TYPES: FilterRuleType[] = [ | ||||||
|   { |   { | ||||||
|     id: FILTER_TITLE, |     id: FILTER_TITLE, | ||||||
| @@ -56,6 +58,13 @@ export const FILTER_RULE_TYPES: FilterRuleType[] = [ | |||||||
|     datatype: 'correspondent', |     datatype: 'correspondent', | ||||||
|     multi: false, |     multi: false, | ||||||
|   }, |   }, | ||||||
|  |   { | ||||||
|  |     id: FILTER_STORAGE_PATH, | ||||||
|  |     filtervar: 'storage_path__id', | ||||||
|  |     isnull_filtervar: 'storage_path__isnull', | ||||||
|  |     datatype: 'storage_path', | ||||||
|  |     multi: false, | ||||||
|  |   }, | ||||||
|   { |   { | ||||||
|     id: FILTER_DOCUMENT_TYPE, |     id: FILTER_DOCUMENT_TYPE, | ||||||
|     filtervar: 'document_type__id', |     filtervar: 'document_type__id', | ||||||
| @@ -180,7 +189,6 @@ export const FILTER_RULE_TYPES: FilterRuleType[] = [ | |||||||
|     datatype: 'string', |     datatype: 'string', | ||||||
|     multi: false, |     multi: false, | ||||||
|   }, |   }, | ||||||
|  |  | ||||||
|   { |   { | ||||||
|     id: FILTER_FULLTEXT_MORELIKE, |     id: FILTER_FULLTEXT_MORELIKE, | ||||||
|     filtervar: 'more_like_id', |     filtervar: 'more_like_id', | ||||||
|   | |||||||
| @@ -4,4 +4,6 @@ export interface PaperlessDocumentSuggestions { | |||||||
|   correspondents?: number[] |   correspondents?: number[] | ||||||
|  |  | ||||||
|   document_types?: number[] |   document_types?: number[] | ||||||
|  |  | ||||||
|  |   storage_paths?: number[] | ||||||
| } | } | ||||||
|   | |||||||
| @@ -3,6 +3,7 @@ import { ObjectWithId } from './object-with-id' | |||||||
| import { PaperlessTag } from './paperless-tag' | import { PaperlessTag } from './paperless-tag' | ||||||
| import { PaperlessDocumentType } from './paperless-document-type' | import { PaperlessDocumentType } from './paperless-document-type' | ||||||
| import { Observable } from 'rxjs' | import { Observable } from 'rxjs' | ||||||
|  | import { PaperlessStoragePath } from './paperless-storage-path' | ||||||
|  |  | ||||||
| export interface SearchHit { | export interface SearchHit { | ||||||
|   score?: number |   score?: number | ||||||
| @@ -20,6 +21,10 @@ export interface PaperlessDocument extends ObjectWithId { | |||||||
|  |  | ||||||
|   document_type?: number |   document_type?: number | ||||||
|  |  | ||||||
|  |   storage_path$?: Observable<PaperlessStoragePath> | ||||||
|  |  | ||||||
|  |   storage_path?: number | ||||||
|  |  | ||||||
|   title?: string |   title?: string | ||||||
|  |  | ||||||
|   content?: 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 { TagService } from './tag.service' | ||||||
| import { PaperlessDocumentSuggestions } from 'src/app/data/paperless-document-suggestions' | import { PaperlessDocumentSuggestions } from 'src/app/data/paperless-document-suggestions' | ||||||
| import { filterRulesToQueryParams } from '../query-params.service' | import { filterRulesToQueryParams } from '../query-params.service' | ||||||
|  | import { StoragePathService } from './storage-path.service' | ||||||
|  |  | ||||||
| export const DOCUMENT_SORT_FIELDS = [ | export const DOCUMENT_SORT_FIELDS = [ | ||||||
|   { field: 'archive_serial_number', name: $localize`ASN` }, |   { field: 'archive_serial_number', name: $localize`ASN` }, | ||||||
| @@ -37,6 +38,7 @@ export interface SelectionDataItem { | |||||||
| } | } | ||||||
|  |  | ||||||
| export interface SelectionData { | export interface SelectionData { | ||||||
|  |   selected_storage_paths: SelectionDataItem[] | ||||||
|   selected_correspondents: SelectionDataItem[] |   selected_correspondents: SelectionDataItem[] | ||||||
|   selected_tags: SelectionDataItem[] |   selected_tags: SelectionDataItem[] | ||||||
|   selected_document_types: SelectionDataItem[] |   selected_document_types: SelectionDataItem[] | ||||||
| @@ -52,7 +54,8 @@ export class DocumentService extends AbstractPaperlessService<PaperlessDocument> | |||||||
|     http: HttpClient, |     http: HttpClient, | ||||||
|     private correspondentService: CorrespondentService, |     private correspondentService: CorrespondentService, | ||||||
|     private documentTypeService: DocumentTypeService, |     private documentTypeService: DocumentTypeService, | ||||||
|     private tagService: TagService |     private tagService: TagService, | ||||||
|  |     private storagePathService: StoragePathService | ||||||
|   ) { |   ) { | ||||||
|     super(http, 'documents') |     super(http, 'documents') | ||||||
|   } |   } | ||||||
| @@ -69,6 +72,9 @@ export class DocumentService extends AbstractPaperlessService<PaperlessDocument> | |||||||
|     if (doc.tags) { |     if (doc.tags) { | ||||||
|       doc.tags$ = this.tagService.getCachedMany(doc.tags) |       doc.tags$ = this.tagService.getCachedMany(doc.tags) | ||||||
|     } |     } | ||||||
|  |     if (doc.storage_path) { | ||||||
|  |       doc.storage_path$ = this.storagePathService.getCached(doc.storage_path) | ||||||
|  |     } | ||||||
|     return doc |     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,16 +198,12 @@ a, a:hover, .btn-link, .btn-link:hover { | |||||||
|     min-height: calc(1.5em + 0.75rem + 5px); |     min-height: calc(1.5em + 0.75rem + 5px); | ||||||
|     line-height: 1.5; |     line-height: 1.5; | ||||||
|  |  | ||||||
|     .ng-select-container { |     .ng-select-container .ng-value-container .ng-input { | ||||||
|       height: 100%; |       top: 7px; | ||||||
|       border-top-right-radius: 0; |  | ||||||
|       border-bottom-right-radius: 0; |  | ||||||
|  |  | ||||||
|       .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 { |     .ng-dropdown-panel .ng-dropdown-panel-items .ng-option.ng-option-marked { | ||||||
|       background-color: var(--pngx-bg-darker) !important; |       background-color: var(--pngx-bg-darker) !important; | ||||||
|       color: var(--pngx-body-color-accent) !important; |       color: var(--pngx-body-color-accent) !important; | ||||||
| @@ -218,6 +214,14 @@ a, a:hover, .btn-link, .btn-link:hover { | |||||||
|       background: none; |       background: none; | ||||||
|     } |     } | ||||||
|   } |   } | ||||||
|  |  | ||||||
|  |   .input-group { | ||||||
|  |     .ng-select-container { | ||||||
|  |       height: 100%; | ||||||
|  |       border-top-right-radius: 0; | ||||||
|  |       border-bottom-right-radius: 0; | ||||||
|  |     } | ||||||
|  |   } | ||||||
| } | } | ||||||
|  |  | ||||||
| .paperless-input-tags { | .paperless-input-tags { | ||||||
| @@ -506,3 +510,7 @@ a.badge { | |||||||
|     background-color: var(--bs-primary); |     background-color: var(--bs-primary); | ||||||
|     border-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 DocumentType | ||||||
| from .models import SavedView | from .models import SavedView | ||||||
| from .models import SavedViewFilterRule | from .models import SavedViewFilterRule | ||||||
|  | from .models import StoragePath | ||||||
| from .models import Tag | from .models import Tag | ||||||
|  |  | ||||||
|  |  | ||||||
| @@ -100,8 +101,19 @@ class SavedViewAdmin(admin.ModelAdmin): | |||||||
|     inlines = [RuleInline] |     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(Correspondent, CorrespondentAdmin) | ||||||
| admin.site.register(Tag, TagAdmin) | admin.site.register(Tag, TagAdmin) | ||||||
| admin.site.register(DocumentType, DocumentTypeAdmin) | admin.site.register(DocumentType, DocumentTypeAdmin) | ||||||
| admin.site.register(Document, DocumentAdmin) | admin.site.register(Document, DocumentAdmin) | ||||||
| admin.site.register(SavedView, SavedViewAdmin) | admin.site.register(SavedView, SavedViewAdmin) | ||||||
|  | admin.site.register(StoragePath, StoragePathAdmin) | ||||||
|   | |||||||
| @@ -16,6 +16,7 @@ class DocumentsConfig(AppConfig): | |||||||
|             set_correspondent, |             set_correspondent, | ||||||
|             set_document_type, |             set_document_type, | ||||||
|             set_tags, |             set_tags, | ||||||
|  |             set_storage_path, | ||||||
|             add_to_index, |             add_to_index, | ||||||
|         ) |         ) | ||||||
|  |  | ||||||
| @@ -23,6 +24,7 @@ class DocumentsConfig(AppConfig): | |||||||
|         document_consumption_finished.connect(set_correspondent) |         document_consumption_finished.connect(set_correspondent) | ||||||
|         document_consumption_finished.connect(set_document_type) |         document_consumption_finished.connect(set_document_type) | ||||||
|         document_consumption_finished.connect(set_tags) |         document_consumption_finished.connect(set_tags) | ||||||
|  |         document_consumption_finished.connect(set_storage_path) | ||||||
|         document_consumption_finished.connect(set_log_entry) |         document_consumption_finished.connect(set_log_entry) | ||||||
|         document_consumption_finished.connect(add_to_index) |         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 Correspondent | ||||||
| from documents.models import Document | from documents.models import Document | ||||||
| from documents.models import DocumentType | from documents.models import DocumentType | ||||||
|  | from documents.models import StoragePath | ||||||
|  |  | ||||||
|  |  | ||||||
| def set_correspondent(doc_ids, correspondent): | def set_correspondent(doc_ids, correspondent): | ||||||
| @@ -20,6 +21,24 @@ def set_correspondent(doc_ids, correspondent): | |||||||
|     return "OK" |     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): | def set_document_type(doc_ids, document_type): | ||||||
|     if document_type: |     if document_type: | ||||||
|         document_type = DocumentType.objects.get(id=document_type) |         document_type = DocumentType.objects.get(id=document_type) | ||||||
|   | |||||||
| @@ -59,8 +59,8 @@ def load_classifier(): | |||||||
|  |  | ||||||
| class DocumentClassifier: | class DocumentClassifier: | ||||||
|  |  | ||||||
|     # v7 - Updated scikit-learn package version |     # v8 - Added storage path classifier | ||||||
|     FORMAT_VERSION = 7 |     FORMAT_VERSION = 8 | ||||||
|  |  | ||||||
|     def __init__(self): |     def __init__(self): | ||||||
|         # hash of the training data. used to prevent re-training when the |         # hash of the training data. used to prevent re-training when the | ||||||
| @@ -72,6 +72,7 @@ class DocumentClassifier: | |||||||
|         self.tags_classifier = None |         self.tags_classifier = None | ||||||
|         self.correspondent_classifier = None |         self.correspondent_classifier = None | ||||||
|         self.document_type_classifier = None |         self.document_type_classifier = None | ||||||
|  |         self.storage_path_classifier = None | ||||||
|  |  | ||||||
|     def load(self): |     def load(self): | ||||||
|         with open(settings.MODEL_FILE, "rb") as f: |         with open(settings.MODEL_FILE, "rb") as f: | ||||||
| @@ -90,6 +91,7 @@ class DocumentClassifier: | |||||||
|                     self.tags_classifier = pickle.load(f) |                     self.tags_classifier = pickle.load(f) | ||||||
|                     self.correspondent_classifier = pickle.load(f) |                     self.correspondent_classifier = pickle.load(f) | ||||||
|                     self.document_type_classifier = pickle.load(f) |                     self.document_type_classifier = pickle.load(f) | ||||||
|  |                     self.storage_path_classifier = pickle.load(f) | ||||||
|                 except Exception: |                 except Exception: | ||||||
|                     raise ClassifierModelCorruptError() |                     raise ClassifierModelCorruptError() | ||||||
|  |  | ||||||
| @@ -107,6 +109,7 @@ class DocumentClassifier: | |||||||
|             pickle.dump(self.tags_classifier, f) |             pickle.dump(self.tags_classifier, f) | ||||||
|             pickle.dump(self.correspondent_classifier, f) |             pickle.dump(self.correspondent_classifier, f) | ||||||
|             pickle.dump(self.document_type_classifier, f) |             pickle.dump(self.document_type_classifier, f) | ||||||
|  |             pickle.dump(self.storage_path_classifier, f) | ||||||
|  |  | ||||||
|         if os.path.isfile(target_file): |         if os.path.isfile(target_file): | ||||||
|             os.unlink(target_file) |             os.unlink(target_file) | ||||||
| @@ -118,6 +121,7 @@ class DocumentClassifier: | |||||||
|         labels_tags = list() |         labels_tags = list() | ||||||
|         labels_correspondent = list() |         labels_correspondent = list() | ||||||
|         labels_document_type = list() |         labels_document_type = list() | ||||||
|  |         labels_storage_path = list() | ||||||
|  |  | ||||||
|         # Step 1: Extract and preprocess training data from the database. |         # Step 1: Extract and preprocess training data from the database. | ||||||
|         logger.debug("Gathering data from database...") |         logger.debug("Gathering data from database...") | ||||||
| @@ -153,6 +157,13 @@ class DocumentClassifier: | |||||||
|                 m.update(tag.to_bytes(4, "little", signed=True)) |                 m.update(tag.to_bytes(4, "little", signed=True)) | ||||||
|             labels_tags.append(tags) |             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: |         if not data: | ||||||
|             raise ValueError("No training data available.") |             raise ValueError("No training data available.") | ||||||
|  |  | ||||||
| @@ -172,14 +183,16 @@ class DocumentClassifier: | |||||||
|         # it usually is. |         # it usually is. | ||||||
|         num_correspondents = len(set(labels_correspondent) | {-1}) - 1 |         num_correspondents = len(set(labels_correspondent) | {-1}) - 1 | ||||||
|         num_document_types = len(set(labels_document_type) | {-1}) - 1 |         num_document_types = len(set(labels_document_type) | {-1}) - 1 | ||||||
|  |         num_storage_paths = len(set(labels_storage_path) | {-1}) - 1 | ||||||
|  |  | ||||||
|         logger.debug( |         logger.debug( | ||||||
|             "{} documents, {} tag(s), {} correspondent(s), " |             "{} documents, {} tag(s), {} correspondent(s), " | ||||||
|             "{} document type(s).".format( |             "{} document type(s). {} storage path(es)".format( | ||||||
|                 len(data), |                 len(data), | ||||||
|                 num_tags, |                 num_tags, | ||||||
|                 num_correspondents, |                 num_correspondents, | ||||||
|                 num_document_types, |                 num_document_types, | ||||||
|  |                 num_storage_paths, | ||||||
|             ), |             ), | ||||||
|         ) |         ) | ||||||
|  |  | ||||||
| @@ -242,6 +255,21 @@ class DocumentClassifier: | |||||||
|                 "classifier.", |                 "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 |         self.data_hash = new_data_hash | ||||||
|  |  | ||||||
|         return True |         return True | ||||||
| @@ -288,3 +316,14 @@ class DocumentClassifier: | |||||||
|                 return [] |                 return [] | ||||||
|         else: |         else: | ||||||
|             return [] |             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): | def generate_filename(doc, counter=0, append_gpg=True, archive_filename=False): | ||||||
|     path = "" |     path = "" | ||||||
|  |     filename_format = settings.FILENAME_FORMAT | ||||||
|  |  | ||||||
|     try: |     try: | ||||||
|         if settings.PAPERLESS_FILENAME_FORMAT is not None: |         if doc.storage_path is not None: | ||||||
|             tags = defaultdictNoStr(lambda: slugify(None), many_to_dictionary(doc.tags)) |             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( |             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="-", |                 replacement_text="-", | ||||||
|             ) |             ) | ||||||
|  |  | ||||||
| @@ -144,7 +157,7 @@ def generate_filename(doc, counter=0, append_gpg=True, archive_filename=False): | |||||||
|                     replacement_text="-", |                     replacement_text="-", | ||||||
|                 ) |                 ) | ||||||
|             else: |             else: | ||||||
|                 correspondent = "none" |                 correspondent = "-none-" | ||||||
|  |  | ||||||
|             if doc.document_type: |             if doc.document_type: | ||||||
|                 document_type = pathvalidate.sanitize_filename( |                 document_type = pathvalidate.sanitize_filename( | ||||||
| @@ -152,18 +165,18 @@ def generate_filename(doc, counter=0, append_gpg=True, archive_filename=False): | |||||||
|                     replacement_text="-", |                     replacement_text="-", | ||||||
|                 ) |                 ) | ||||||
|             else: |             else: | ||||||
|                 document_type = "none" |                 document_type = "-none-" | ||||||
|  |  | ||||||
|             if doc.archive_serial_number: |             if doc.archive_serial_number: | ||||||
|                 asn = str(doc.archive_serial_number) |                 asn = str(doc.archive_serial_number) | ||||||
|             else: |             else: | ||||||
|                 asn = "none" |                 asn = "-none-" | ||||||
|  |  | ||||||
|             # Convert UTC database date to localized date |             # Convert UTC database date to localized date | ||||||
|             local_added = timezone.localdate(doc.added) |             local_added = timezone.localdate(doc.added) | ||||||
|             local_created = timezone.localdate(doc.created) |             local_created = timezone.localdate(doc.created) | ||||||
|  |  | ||||||
|             path = settings.PAPERLESS_FILENAME_FORMAT.format( |             path = filename_format.format( | ||||||
|                 title=pathvalidate.sanitize_filename(doc.title, replacement_text="-"), |                 title=pathvalidate.sanitize_filename(doc.title, replacement_text="-"), | ||||||
|                 correspondent=correspondent, |                 correspondent=correspondent, | ||||||
|                 document_type=document_type, |                 document_type=document_type, | ||||||
| @@ -180,12 +193,17 @@ def generate_filename(doc, counter=0, append_gpg=True, archive_filename=False): | |||||||
|                 tag_list=tag_list, |                 tag_list=tag_list, | ||||||
|             ).strip() |             ).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) |             path = path.strip(os.sep) | ||||||
|  |  | ||||||
|     except (ValueError, KeyError, IndexError): |     except (ValueError, KeyError, IndexError): | ||||||
|         logger.warning( |         logger.warning( | ||||||
|             f"Invalid PAPERLESS_FILENAME_FORMAT: " |             f"Invalid filename_format '{filename_format}', falling back to default", | ||||||
|             f"{settings.PAPERLESS_FILENAME_FORMAT}, falling back to default", |  | ||||||
|         ) |         ) | ||||||
|  |  | ||||||
|     counter_str = f"_{counter:02}" if counter else "" |     counter_str = f"_{counter:02}" if counter else "" | ||||||
|   | |||||||
| @@ -7,6 +7,7 @@ from .models import Correspondent | |||||||
| from .models import Document | from .models import Document | ||||||
| from .models import DocumentType | from .models import DocumentType | ||||||
| from .models import Log | from .models import Log | ||||||
|  | from .models import StoragePath | ||||||
| from .models import Tag | from .models import Tag | ||||||
|  |  | ||||||
| CHAR_KWARGS = ["istartswith", "iendswith", "icontains", "iexact"] | CHAR_KWARGS = ["istartswith", "iendswith", "icontains", "iexact"] | ||||||
| @@ -114,6 +115,9 @@ class DocumentFilterSet(FilterSet): | |||||||
|             "document_type": ["isnull"], |             "document_type": ["isnull"], | ||||||
|             "document_type__id": ID_KWARGS, |             "document_type__id": ID_KWARGS, | ||||||
|             "document_type__name": CHAR_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: |     class Meta: | ||||||
|         model = Log |         model = Log | ||||||
|         fields = {"level": INT_KWARGS, "created": DATE_KWARGS, "group": ID_KWARGS} |         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), |         created=DATETIME(sortable=True), | ||||||
|         modified=DATETIME(sortable=True), |         modified=DATETIME(sortable=True), | ||||||
|         added=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, |         added=doc.added, | ||||||
|         asn=doc.archive_serial_number, |         asn=doc.archive_serial_number, | ||||||
|         modified=doc.modified, |         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)) |                 criterias.append(query.DateRange("added", start=isoparse(v), end=None)) | ||||||
|             elif k == "added__date__lt": |             elif k == "added__date__lt": | ||||||
|                 criterias.append(query.DateRange("added", start=None, end=isoparse(v))) |                 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: |         if len(criterias) > 0: | ||||||
|             return query.And(criterias) |             return query.And(criterias) | ||||||
|         else: |         else: | ||||||
|   | |||||||
| @@ -152,4 +152,4 @@ class Command(BaseCommand): | |||||||
|                     ), |                     ), | ||||||
|                 ) |                 ) | ||||||
|         except KeyboardInterrupt: |         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 Correspondent | ||||||
| from documents.models import DocumentType | from documents.models import DocumentType | ||||||
| from documents.models import MatchingModel | from documents.models import MatchingModel | ||||||
|  | from documents.models import StoragePath | ||||||
| from documents.models import Tag | 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): | def matches(matching_model, document): | ||||||
|     search_kwargs = {} |     search_kwargs = {} | ||||||
|  |  | ||||||
|   | |||||||
| @@ -83,7 +83,7 @@ def generate_filename(doc, counter=0, append_gpg=True, archive_filename=False): | |||||||
|     path = "" |     path = "" | ||||||
|  |  | ||||||
|     try: |     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)) |             tags = defaultdictNoStr(lambda: slugify(None), many_to_dictionary(doc.tags)) | ||||||
|  |  | ||||||
|             tag_list = pathvalidate.sanitize_filename( |             tag_list = pathvalidate.sanitize_filename( | ||||||
| @@ -105,7 +105,7 @@ def generate_filename(doc, counter=0, append_gpg=True, archive_filename=False): | |||||||
|             else: |             else: | ||||||
|                 document_type = "none" |                 document_type = "none" | ||||||
|  |  | ||||||
|             path = settings.PAPERLESS_FILENAME_FORMAT.format( |             path = settings.FILENAME_FORMAT.format( | ||||||
|                 title=pathvalidate.sanitize_filename(doc.title, replacement_text="-"), |                 title=pathvalidate.sanitize_filename(doc.title, replacement_text="-"), | ||||||
|                 correspondent=correspondent, |                 correspondent=correspondent, | ||||||
|                 document_type=document_type, |                 document_type=document_type, | ||||||
| @@ -128,7 +128,7 @@ def generate_filename(doc, counter=0, append_gpg=True, archive_filename=False): | |||||||
|     except (ValueError, KeyError, IndexError): |     except (ValueError, KeyError, IndexError): | ||||||
|         logger.warning( |         logger.warning( | ||||||
|             f"Invalid PAPERLESS_FILENAME_FORMAT: " |             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 "" |     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") |         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): | class Document(models.Model): | ||||||
|  |  | ||||||
|     STORAGE_TYPE_UNENCRYPTED = "unencrypted" |     STORAGE_TYPE_UNENCRYPTED = "unencrypted" | ||||||
| @@ -101,6 +113,15 @@ class Document(models.Model): | |||||||
|         verbose_name=_("correspondent"), |         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) |     title = models.CharField(_("title"), max_length=128, blank=True, db_index=True) | ||||||
|  |  | ||||||
|     document_type = models.ForeignKey( |     document_type = models.ForeignKey( | ||||||
|   | |||||||
| @@ -14,6 +14,7 @@ from .models import DocumentType | |||||||
| from .models import MatchingModel | from .models import MatchingModel | ||||||
| from .models import SavedView | from .models import SavedView | ||||||
| from .models import SavedViewFilterRule | from .models import SavedViewFilterRule | ||||||
|  | from .models import StoragePath | ||||||
| from .models import Tag | from .models import Tag | ||||||
| from .models import UiSettings | from .models import UiSettings | ||||||
| from .parsers import is_mime_type_supported | from .parsers import is_mime_type_supported | ||||||
| @@ -199,11 +200,17 @@ class DocumentTypeField(serializers.PrimaryKeyRelatedField): | |||||||
|         return DocumentType.objects.all() |         return DocumentType.objects.all() | ||||||
|  |  | ||||||
|  |  | ||||||
|  | class StoragePathField(serializers.PrimaryKeyRelatedField): | ||||||
|  |     def get_queryset(self): | ||||||
|  |         return StoragePath.objects.all() | ||||||
|  |  | ||||||
|  |  | ||||||
| class DocumentSerializer(DynamicFieldsModelSerializer): | class DocumentSerializer(DynamicFieldsModelSerializer): | ||||||
|  |  | ||||||
|     correspondent = CorrespondentField(allow_null=True) |     correspondent = CorrespondentField(allow_null=True) | ||||||
|     tags = TagsField(many=True) |     tags = TagsField(many=True) | ||||||
|     document_type = DocumentTypeField(allow_null=True) |     document_type = DocumentTypeField(allow_null=True) | ||||||
|  |     storage_path = StoragePathField(allow_null=True) | ||||||
|  |  | ||||||
|     original_file_name = SerializerMethodField() |     original_file_name = SerializerMethodField() | ||||||
|     archived_file_name = SerializerMethodField() |     archived_file_name = SerializerMethodField() | ||||||
| @@ -224,6 +231,7 @@ class DocumentSerializer(DynamicFieldsModelSerializer): | |||||||
|             "id", |             "id", | ||||||
|             "correspondent", |             "correspondent", | ||||||
|             "document_type", |             "document_type", | ||||||
|  |             "storage_path", | ||||||
|             "title", |             "title", | ||||||
|             "content", |             "content", | ||||||
|             "tags", |             "tags", | ||||||
| @@ -310,6 +318,7 @@ class BulkEditSerializer(DocumentListSerializer): | |||||||
|         choices=[ |         choices=[ | ||||||
|             "set_correspondent", |             "set_correspondent", | ||||||
|             "set_document_type", |             "set_document_type", | ||||||
|  |             "set_storage_path", | ||||||
|             "add_tag", |             "add_tag", | ||||||
|             "remove_tag", |             "remove_tag", | ||||||
|             "modify_tags", |             "modify_tags", | ||||||
| @@ -337,6 +346,8 @@ class BulkEditSerializer(DocumentListSerializer): | |||||||
|             return bulk_edit.set_correspondent |             return bulk_edit.set_correspondent | ||||||
|         elif method == "set_document_type": |         elif method == "set_document_type": | ||||||
|             return bulk_edit.set_document_type |             return bulk_edit.set_document_type | ||||||
|  |         elif method == "set_storage_path": | ||||||
|  |             return bulk_edit.set_storage_path | ||||||
|         elif method == "add_tag": |         elif method == "add_tag": | ||||||
|             return bulk_edit.add_tag |             return bulk_edit.add_tag | ||||||
|         elif method == "remove_tag": |         elif method == "remove_tag": | ||||||
| @@ -383,6 +394,20 @@ class BulkEditSerializer(DocumentListSerializer): | |||||||
|         else: |         else: | ||||||
|             raise serializers.ValidationError("correspondent not specified") |             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): |     def _validate_parameters_modify_tags(self, parameters): | ||||||
|         if "add_tags" in parameters: |         if "add_tags" in parameters: | ||||||
|             self._validate_tag_id_list(parameters["add_tags"], "add_tags") |             self._validate_tag_id_list(parameters["add_tags"], "add_tags") | ||||||
| @@ -407,6 +432,8 @@ class BulkEditSerializer(DocumentListSerializer): | |||||||
|             self._validate_parameters_tags(parameters) |             self._validate_parameters_tags(parameters) | ||||||
|         elif method == bulk_edit.modify_tags: |         elif method == bulk_edit.modify_tags: | ||||||
|             self._validate_parameters_modify_tags(parameters) |             self._validate_parameters_modify_tags(parameters) | ||||||
|  |         elif method == bulk_edit.set_storage_path: | ||||||
|  |             self._validate_storage_path(parameters) | ||||||
|  |  | ||||||
|         return attrs |         return attrs | ||||||
|  |  | ||||||
| @@ -508,6 +535,47 @@ class BulkDownloadSerializer(DocumentListSerializer): | |||||||
|         }[compression] |         }[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 UiSettingsViewSerializer(serializers.ModelSerializer): | ||||||
|     class Meta: |     class Meta: | ||||||
|         model = UiSettings |         model = UiSettings | ||||||
|   | |||||||
| @@ -230,6 +230,76 @@ def set_tags( | |||||||
|         document.tags.add(*relevant_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) | @receiver(models.signals.post_delete, sender=Document) | ||||||
| def cleanup_document_deletion(sender, instance, using, **kwargs): | def cleanup_document_deletion(sender, instance, using, **kwargs): | ||||||
|     with FileLock(settings.MEDIA_LOCK): |     with FileLock(settings.MEDIA_LOCK): | ||||||
|   | |||||||
| @@ -19,6 +19,7 @@ from documents.consumer import ConsumerError | |||||||
| from documents.models import Correspondent | from documents.models import Correspondent | ||||||
| from documents.models import Document | from documents.models import Document | ||||||
| from documents.models import DocumentType | from documents.models import DocumentType | ||||||
|  | from documents.models import StoragePath | ||||||
| from documents.models import Tag | from documents.models import Tag | ||||||
| from documents.sanity_checker import SanityCheckFailedException | from documents.sanity_checker import SanityCheckFailedException | ||||||
| from pdf2image import convert_from_path | from pdf2image import convert_from_path | ||||||
| @@ -53,6 +54,7 @@ def train_classifier(): | |||||||
|         not Tag.objects.filter(matching_algorithm=Tag.MATCH_AUTO).exists() |         not Tag.objects.filter(matching_algorithm=Tag.MATCH_AUTO).exists() | ||||||
|         and not DocumentType.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 Correspondent.objects.filter(matching_algorithm=Tag.MATCH_AUTO).exists() | ||||||
|  |         and not StoragePath.objects.filter(matching_algorithm=Tag.MATCH_AUTO).exists() | ||||||
|     ): |     ): | ||||||
|  |  | ||||||
|         return |         return | ||||||
|   | |||||||
										
											Binary file not shown.
										
									
								
							| @@ -26,8 +26,10 @@ from documents.models import Document | |||||||
| from documents.models import DocumentType | from documents.models import DocumentType | ||||||
| from documents.models import MatchingModel | from documents.models import MatchingModel | ||||||
| from documents.models import SavedView | from documents.models import SavedView | ||||||
|  | from documents.models import StoragePath | ||||||
| from documents.models import Tag | from documents.models import Tag | ||||||
| from documents.models import UiSettings | from documents.models import UiSettings | ||||||
|  | from documents.models import StoragePath | ||||||
| from documents.tests.utils import DirectoriesMixin | from documents.tests.utils import DirectoriesMixin | ||||||
| from paperless import version | from paperless import version | ||||||
| from rest_framework.test import APITestCase | from rest_framework.test import APITestCase | ||||||
| @@ -99,6 +101,7 @@ class TestDocumentApi(DirectoriesMixin, APITestCase): | |||||||
|         c = Correspondent.objects.create(name="c", pk=41) |         c = Correspondent.objects.create(name="c", pk=41) | ||||||
|         dt = DocumentType.objects.create(name="dt", pk=63) |         dt = DocumentType.objects.create(name="dt", pk=63) | ||||||
|         tag = Tag.objects.create(name="t", pk=85) |         tag = Tag.objects.create(name="t", pk=85) | ||||||
|  |         storage_path = StoragePath.objects.create(name="sp", pk=77, path="p") | ||||||
|         doc = Document.objects.create( |         doc = Document.objects.create( | ||||||
|             title="WOW", |             title="WOW", | ||||||
|             content="the content", |             content="the content", | ||||||
| @@ -106,6 +109,7 @@ class TestDocumentApi(DirectoriesMixin, APITestCase): | |||||||
|             document_type=dt, |             document_type=dt, | ||||||
|             checksum="123", |             checksum="123", | ||||||
|             mime_type="application/pdf", |             mime_type="application/pdf", | ||||||
|  |             storage_path=storage_path, | ||||||
|         ) |         ) | ||||||
|  |  | ||||||
|         response = self.client.get("/api/documents/", format="json") |         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.status_code, 200) | ||||||
|         self.assertEqual(response.content, content_thumbnail) |         self.assertEqual(response.content, content_thumbnail) | ||||||
|  |  | ||||||
|     @override_settings(PAPERLESS_FILENAME_FORMAT="") |     @override_settings(FILENAME_FORMAT="") | ||||||
|     def test_download_with_archive(self): |     def test_download_with_archive(self): | ||||||
|  |  | ||||||
|         content = b"This is a test" |         content = b"This is a test" | ||||||
| @@ -580,10 +584,12 @@ class TestDocumentApi(DirectoriesMixin, APITestCase): | |||||||
|         t2 = Tag.objects.create(name="tag2") |         t2 = Tag.objects.create(name="tag2") | ||||||
|         c = Correspondent.objects.create(name="correspondent") |         c = Correspondent.objects.create(name="correspondent") | ||||||
|         dt = DocumentType.objects.create(name="type") |         dt = DocumentType.objects.create(name="type") | ||||||
|  |         sp = StoragePath.objects.create(name="path") | ||||||
|  |  | ||||||
|         d1 = Document.objects.create(checksum="1", correspondent=c, content="test") |         d1 = Document.objects.create(checksum="1", correspondent=c, content="test") | ||||||
|         d2 = Document.objects.create(checksum="2", document_type=dt, content="test") |         d2 = Document.objects.create(checksum="2", document_type=dt, content="test") | ||||||
|         d3 = Document.objects.create(checksum="3", content="test") |         d3 = Document.objects.create(checksum="3", content="test") | ||||||
|  |  | ||||||
|         d3.tags.add(t) |         d3.tags.add(t) | ||||||
|         d3.tags.add(t2) |         d3.tags.add(t2) | ||||||
|         d4 = Document.objects.create( |         d4 = Document.objects.create( | ||||||
| @@ -598,6 +604,7 @@ class TestDocumentApi(DirectoriesMixin, APITestCase): | |||||||
|             content="test", |             content="test", | ||||||
|         ) |         ) | ||||||
|         d6 = Document.objects.create(checksum="6", content="test2") |         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: |         with AsyncWriter(index.open_index()) as writer: | ||||||
|             for doc in Document.objects.all(): |             for doc in Document.objects.all(): | ||||||
| @@ -608,18 +615,30 @@ class TestDocumentApi(DirectoriesMixin, APITestCase): | |||||||
|             self.assertEqual(r.status_code, 200) |             self.assertEqual(r.status_code, 200) | ||||||
|             return [hit["id"] for hit in r.data["results"]] |             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=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("&correspondent__id=" + str(c.id)), [d1.id]) | ||||||
|         self.assertCountEqual(search_query("&document_type__id=" + str(dt.id)), [d2.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( |         self.assertCountEqual( | ||||||
|             search_query("&correspondent__isnull"), |             search_query("&correspondent__isnull"), | ||||||
|             [d2.id, d3.id, d4.id, d5.id], |             [d2.id, d3.id, d4.id, d5.id, d7.id], | ||||||
|         ) |         ) | ||||||
|         self.assertCountEqual( |         self.assertCountEqual( | ||||||
|             search_query("&document_type__isnull"), |             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( |         self.assertCountEqual( | ||||||
|             search_query("&tags__id__all=" + str(t.id) + "," + str(t2.id)), |             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.status_code, 200) | ||||||
|         self.assertEqual( |         self.assertEqual( | ||||||
|             response.data, |             response.data, | ||||||
|             {"correspondents": [], "tags": [], "document_types": []}, |             { | ||||||
|  |                 "correspondents": [], | ||||||
|  |                 "tags": [], | ||||||
|  |                 "document_types": [], | ||||||
|  |                 "storage_paths": [], | ||||||
|  |             }, | ||||||
|         ) |         ) | ||||||
|  |  | ||||||
|     def test_get_suggestions_invalid_doc(self): |     def test_get_suggestions_invalid_doc(self): | ||||||
|         response = self.client.get(f"/api/documents/34676/suggestions/") |         response = self.client.get(f"/api/documents/34676/suggestions/") | ||||||
|         self.assertEqual(response.status_code, 404) |         self.assertEqual(response.status_code, 404) | ||||||
|  |  | ||||||
|     @mock.patch("documents.views.match_correspondents") |     @mock.patch("documents.views.match_storage_paths") | ||||||
|     @mock.patch("documents.views.match_tags") |  | ||||||
|     @mock.patch("documents.views.match_document_types") |     @mock.patch("documents.views.match_document_types") | ||||||
|  |     @mock.patch("documents.views.match_tags") | ||||||
|  |     @mock.patch("documents.views.match_correspondents") | ||||||
|     def test_get_suggestions( |     def test_get_suggestions( | ||||||
|         self, |         self, | ||||||
|         match_document_types, |  | ||||||
|         match_tags, |  | ||||||
|         match_correspondents, |         match_correspondents, | ||||||
|  |         match_tags, | ||||||
|  |         match_document_types, | ||||||
|  |         match_storage_paths, | ||||||
|     ): |     ): | ||||||
|         doc = Document.objects.create( |         doc = Document.objects.create( | ||||||
|             title="test", |             title="test", | ||||||
|             mime_type="application/pdf", |             mime_type="application/pdf", | ||||||
|             content="this is an invoice!", |             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_tags.return_value = [Tag(id=56), Tag(id=123)] | ||||||
|         match_document_types.return_value = [DocumentType(id=23)] |         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/") |         response = self.client.get(f"/api/documents/{doc.pk}/suggestions/") | ||||||
|         self.assertEqual( |         self.assertEqual( | ||||||
|             response.data, |             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): |     def test_saved_views(self): | ||||||
| @@ -1469,6 +1502,7 @@ class TestBulkEdit(DirectoriesMixin, APITestCase): | |||||||
|         self.doc2.tags.add(self.t1) |         self.doc2.tags.add(self.t1) | ||||||
|         self.doc3.tags.add(self.t2) |         self.doc3.tags.add(self.t2) | ||||||
|         self.doc4.tags.add(self.t1, 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): |     def test_set_correspondent(self): | ||||||
|         self.assertEqual(Document.objects.filter(correspondent=self.c2).count(), 1) |         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 |         args, kwargs = self.async_task.call_args | ||||||
|         self.assertCountEqual(kwargs["document_ids"], [self.doc2.id, self.doc3.id]) |         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): |     def test_add_tag(self): | ||||||
|         self.assertEqual(Document.objects.filter(tags__id=self.t1.id).count(), 2) |         self.assertEqual(Document.objects.filter(tags__id=self.t1.id).count(), 2) | ||||||
|         bulk_edit.add_tag( |         bulk_edit.add_tag( | ||||||
|   | |||||||
| @@ -13,6 +13,7 @@ from documents.classifier import load_classifier | |||||||
| from documents.models import Correspondent | from documents.models import Correspondent | ||||||
| from documents.models import Document | from documents.models import Document | ||||||
| from documents.models import DocumentType | from documents.models import DocumentType | ||||||
|  | from documents.models import StoragePath | ||||||
| from documents.models import Tag | from documents.models import Tag | ||||||
| from documents.tests.utils import DirectoriesMixin | from documents.tests.utils import DirectoriesMixin | ||||||
|  |  | ||||||
| @@ -56,6 +57,16 @@ class TestClassifier(DirectoriesMixin, TestCase): | |||||||
|             name="dt2", |             name="dt2", | ||||||
|             matching_algorithm=DocumentType.MATCH_AUTO, |             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( |         self.doc1 = Document.objects.create( | ||||||
|             title="doc1", |             title="doc1", | ||||||
| @@ -64,12 +75,14 @@ class TestClassifier(DirectoriesMixin, TestCase): | |||||||
|             checksum="A", |             checksum="A", | ||||||
|             document_type=self.dt, |             document_type=self.dt, | ||||||
|         ) |         ) | ||||||
|  |  | ||||||
|         self.doc2 = Document.objects.create( |         self.doc2 = Document.objects.create( | ||||||
|             title="doc1", |             title="doc1", | ||||||
|             content="this is another document, but from c2", |             content="this is another document, but from c2", | ||||||
|             correspondent=self.c2, |             correspondent=self.c2, | ||||||
|             checksum="B", |             checksum="B", | ||||||
|         ) |         ) | ||||||
|  |  | ||||||
|         self.doc_inbox = Document.objects.create( |         self.doc_inbox = Document.objects.create( | ||||||
|             title="doc235", |             title="doc235", | ||||||
|             content="aa", |             content="aa", | ||||||
| @@ -81,6 +94,8 @@ class TestClassifier(DirectoriesMixin, TestCase): | |||||||
|         self.doc2.tags.add(self.t3) |         self.doc2.tags.add(self.t3) | ||||||
|         self.doc_inbox.tags.add(self.t2) |         self.doc_inbox.tags.add(self.t2) | ||||||
|  |  | ||||||
|  |         self.doc1.storage_path = self.sp1 | ||||||
|  |  | ||||||
|     def testNoTrainingData(self): |     def testNoTrainingData(self): | ||||||
|         try: |         try: | ||||||
|             self.classifier.train() |             self.classifier.train() | ||||||
| @@ -177,6 +192,14 @@ class TestClassifier(DirectoriesMixin, TestCase): | |||||||
|         new_classifier.load() |         new_classifier.load() | ||||||
|         self.assertFalse(new_classifier.train()) |         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( |     @override_settings( | ||||||
|         MODEL_FILE=os.path.join(os.path.dirname(__file__), "data", "model.pickle"), |         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.assertEqual(self.classifier.predict_document_type(doc1.content), dt.pk) | ||||||
|         self.assertIsNone(self.classifier.predict_document_type(doc2.content)) |         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): |     def test_one_tag_predict(self): | ||||||
|         t1 = Tag.objects.create(name="t1", matching_algorithm=Tag.MATCH_AUTO, pk=12) |         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) |         shutil.copy(src, dst) | ||||||
|         return 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): |     def testNormalOperation(self): | ||||||
|  |  | ||||||
|         filename = self.get_test_file() |         filename = self.get_test_file() | ||||||
| @@ -351,7 +351,7 @@ class TestConsumer(DirectoriesMixin, TestCase): | |||||||
|  |  | ||||||
|         self.assertEqual(document.created.tzinfo, zoneinfo.ZoneInfo("America/Chicago")) |         self.assertEqual(document.created.tzinfo, zoneinfo.ZoneInfo("America/Chicago")) | ||||||
|  |  | ||||||
|     @override_settings(PAPERLESS_FILENAME_FORMAT=None) |     @override_settings(FILENAME_FORMAT=None) | ||||||
|     def testDeleteMacFiles(self): |     def testDeleteMacFiles(self): | ||||||
|         # https://github.com/jonaswinkler/paperless-ng/discussions/1037 |         # https://github.com/jonaswinkler/paperless-ng/discussions/1037 | ||||||
|  |  | ||||||
| @@ -518,7 +518,7 @@ class TestConsumer(DirectoriesMixin, TestCase): | |||||||
|         # Database empty |         # Database empty | ||||||
|         self.assertEqual(len(Document.objects.all()), 0) |         self.assertEqual(len(Document.objects.all()), 0) | ||||||
|  |  | ||||||
|     @override_settings(PAPERLESS_FILENAME_FORMAT="{correspondent}/{title}") |     @override_settings(FILENAME_FORMAT="{correspondent}/{title}") | ||||||
|     def testFilenameHandling(self): |     def testFilenameHandling(self): | ||||||
|         filename = self.get_test_file() |         filename = self.get_test_file() | ||||||
|  |  | ||||||
| @@ -530,7 +530,7 @@ class TestConsumer(DirectoriesMixin, TestCase): | |||||||
|  |  | ||||||
|         self._assert_first_last_send_progress() |         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") |     @mock.patch("documents.signals.handlers.generate_unique_filename") | ||||||
|     def testFilenameHandlingUnstableFormat(self, m): |     def testFilenameHandlingUnstableFormat(self, m): | ||||||
|  |  | ||||||
| @@ -612,7 +612,7 @@ class TestConsumer(DirectoriesMixin, TestCase): | |||||||
|  |  | ||||||
|         self._assert_first_last_send_progress(last_status="FAILED") |         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") |     @mock.patch("documents.parsers.document_consumer_declaration.send") | ||||||
|     def test_similar_filenames(self, m): |     def test_similar_filenames(self, m): | ||||||
|         shutil.copy( |         shutil.copy( | ||||||
| @@ -660,7 +660,7 @@ class TestConsumer(DirectoriesMixin, TestCase): | |||||||
| @mock.patch("documents.consumer.magic.from_file", fake_magic_from_file) | @mock.patch("documents.consumer.magic.from_file", fake_magic_from_file) | ||||||
| class TestConsumerCreatedDate(DirectoriesMixin, TestCase): | class TestConsumerCreatedDate(DirectoriesMixin, TestCase): | ||||||
|     def setUp(self): |     def setUp(self): | ||||||
|         super(TestConsumerCreatedDate, self).setUp() |         super().setUp() | ||||||
|  |  | ||||||
|         # this prevents websocket message reports during testing. |         # this prevents websocket message reports during testing. | ||||||
|         patcher = mock.patch("documents.consumer.Consumer._send_progress") |         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 Correspondent | ||||||
| from ..models import Document | from ..models import Document | ||||||
| from ..models import DocumentType | from ..models import DocumentType | ||||||
| from ..models import Tag | from ..models import StoragePath | ||||||
| from .utils import DirectoriesMixin | from .utils import DirectoriesMixin | ||||||
|  |  | ||||||
|  |  | ||||||
| class TestFileHandling(DirectoriesMixin, TestCase): | class TestFileHandling(DirectoriesMixin, TestCase): | ||||||
|     @override_settings(PAPERLESS_FILENAME_FORMAT="") |     @override_settings(FILENAME_FORMAT="") | ||||||
|     def test_generate_source_filename(self): |     def test_generate_source_filename(self): | ||||||
|         document = Document() |         document = Document() | ||||||
|         document.mime_type = "application/pdf" |         document.mime_type = "application/pdf" | ||||||
| @@ -40,7 +40,7 @@ class TestFileHandling(DirectoriesMixin, TestCase): | |||||||
|             f"{document.pk:07d}.pdf.gpg", |             f"{document.pk:07d}.pdf.gpg", | ||||||
|         ) |         ) | ||||||
|  |  | ||||||
|     @override_settings(PAPERLESS_FILENAME_FORMAT="{correspondent}/{correspondent}") |     @override_settings(FILENAME_FORMAT="{correspondent}/{correspondent}") | ||||||
|     def test_file_renaming(self): |     def test_file_renaming(self): | ||||||
|         document = Document() |         document = Document() | ||||||
|         document.mime_type = "application/pdf" |         document.mime_type = "application/pdf" | ||||||
| @@ -82,7 +82,7 @@ class TestFileHandling(DirectoriesMixin, TestCase): | |||||||
|             True, |             True, | ||||||
|         ) |         ) | ||||||
|  |  | ||||||
|     @override_settings(PAPERLESS_FILENAME_FORMAT="{correspondent}/{correspondent}") |     @override_settings(FILENAME_FORMAT="{correspondent}/{correspondent}") | ||||||
|     def test_file_renaming_missing_permissions(self): |     def test_file_renaming_missing_permissions(self): | ||||||
|         document = Document() |         document = Document() | ||||||
|         document.mime_type = "application/pdf" |         document.mime_type = "application/pdf" | ||||||
| @@ -117,7 +117,7 @@ class TestFileHandling(DirectoriesMixin, TestCase): | |||||||
|  |  | ||||||
|         os.chmod(settings.ORIGINALS_DIR + "/none", 0o777) |         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): |     def test_file_renaming_database_error(self): | ||||||
|  |  | ||||||
|         document1 = Document.objects.create( |         document1 = Document.objects.create( | ||||||
| @@ -156,7 +156,7 @@ class TestFileHandling(DirectoriesMixin, TestCase): | |||||||
|             ) |             ) | ||||||
|             self.assertEqual(document.filename, "none/none.pdf") |             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): |     def test_document_delete(self): | ||||||
|         document = Document() |         document = Document() | ||||||
|         document.mime_type = "application/pdf" |         document.mime_type = "application/pdf" | ||||||
| @@ -180,7 +180,7 @@ class TestFileHandling(DirectoriesMixin, TestCase): | |||||||
|         self.assertEqual(os.path.isdir(settings.ORIGINALS_DIR + "/none"), False) |         self.assertEqual(os.path.isdir(settings.ORIGINALS_DIR + "/none"), False) | ||||||
|  |  | ||||||
|     @override_settings( |     @override_settings( | ||||||
|         PAPERLESS_FILENAME_FORMAT="{correspondent}/{correspondent}", |         FILENAME_FORMAT="{correspondent}/{correspondent}", | ||||||
|         TRASH_DIR=tempfile.mkdtemp(), |         TRASH_DIR=tempfile.mkdtemp(), | ||||||
|     ) |     ) | ||||||
|     def test_document_delete_trash(self): |     def test_document_delete_trash(self): | ||||||
| @@ -218,7 +218,7 @@ class TestFileHandling(DirectoriesMixin, TestCase): | |||||||
|         document.delete() |         document.delete() | ||||||
|         self.assertEqual(os.path.isfile(settings.TRASH_DIR + "/none_01.pdf"), True) |         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): |     def test_document_delete_nofile(self): | ||||||
|         document = Document() |         document = Document() | ||||||
|         document.mime_type = "application/pdf" |         document.mime_type = "application/pdf" | ||||||
| @@ -227,7 +227,7 @@ class TestFileHandling(DirectoriesMixin, TestCase): | |||||||
|  |  | ||||||
|         document.delete() |         document.delete() | ||||||
|  |  | ||||||
|     @override_settings(PAPERLESS_FILENAME_FORMAT="{correspondent}/{correspondent}") |     @override_settings(FILENAME_FORMAT="{correspondent}/{correspondent}") | ||||||
|     def test_directory_not_empty(self): |     def test_directory_not_empty(self): | ||||||
|         document = Document() |         document = Document() | ||||||
|         document.mime_type = "application/pdf" |         document.mime_type = "application/pdf" | ||||||
| @@ -253,7 +253,7 @@ class TestFileHandling(DirectoriesMixin, TestCase): | |||||||
|         self.assertEqual(os.path.isdir(settings.ORIGINALS_DIR + "/none"), True) |         self.assertEqual(os.path.isdir(settings.ORIGINALS_DIR + "/none"), True) | ||||||
|         self.assertTrue(os.path.isfile(important_file)) |         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): |     def test_document_type(self): | ||||||
|         dt = DocumentType.objects.create(name="my_doc_type") |         dt = DocumentType.objects.create(name="my_doc_type") | ||||||
|         d = Document.objects.create(title="the_doc", mime_type="application/pdf") |         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") |         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): |     def test_asn(self): | ||||||
|         d1 = Document.objects.create( |         d1 = Document.objects.create( | ||||||
|             title="the_doc", |             title="the_doc", | ||||||
| @@ -281,7 +281,7 @@ class TestFileHandling(DirectoriesMixin, TestCase): | |||||||
|         self.assertEqual(generate_filename(d1), "652 - the_doc.pdf") |         self.assertEqual(generate_filename(d1), "652 - the_doc.pdf") | ||||||
|         self.assertEqual(generate_filename(d2), "none - the_doc.pdf") |         self.assertEqual(generate_filename(d2), "none - the_doc.pdf") | ||||||
|  |  | ||||||
|     @override_settings(PAPERLESS_FILENAME_FORMAT="{tags[type]}") |     @override_settings(FILENAME_FORMAT="{tags[type]}") | ||||||
|     def test_tags_with_underscore(self): |     def test_tags_with_underscore(self): | ||||||
|         document = Document() |         document = Document() | ||||||
|         document.mime_type = "application/pdf" |         document.mime_type = "application/pdf" | ||||||
| @@ -296,7 +296,7 @@ class TestFileHandling(DirectoriesMixin, TestCase): | |||||||
|         # Ensure that filename is properly generated |         # Ensure that filename is properly generated | ||||||
|         self.assertEqual(generate_filename(document), "demo.pdf") |         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): |     def test_tags_with_dash(self): | ||||||
|         document = Document() |         document = Document() | ||||||
|         document.mime_type = "application/pdf" |         document.mime_type = "application/pdf" | ||||||
| @@ -311,7 +311,7 @@ class TestFileHandling(DirectoriesMixin, TestCase): | |||||||
|         # Ensure that filename is properly generated |         # Ensure that filename is properly generated | ||||||
|         self.assertEqual(generate_filename(document), "demo.pdf") |         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): |     def test_tags_malformed(self): | ||||||
|         document = Document() |         document = Document() | ||||||
|         document.mime_type = "application/pdf" |         document.mime_type = "application/pdf" | ||||||
| @@ -326,7 +326,7 @@ class TestFileHandling(DirectoriesMixin, TestCase): | |||||||
|         # Ensure that filename is properly generated |         # Ensure that filename is properly generated | ||||||
|         self.assertEqual(generate_filename(document), "none.pdf") |         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): |     def test_tags_all(self): | ||||||
|         document = Document() |         document = Document() | ||||||
|         document.mime_type = "application/pdf" |         document.mime_type = "application/pdf" | ||||||
| @@ -340,7 +340,7 @@ class TestFileHandling(DirectoriesMixin, TestCase): | |||||||
|         # Ensure that filename is properly generated |         # Ensure that filename is properly generated | ||||||
|         self.assertEqual(generate_filename(document), "demo.pdf") |         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): |     def test_tags_out_of_bounds(self): | ||||||
|         document = Document() |         document = Document() | ||||||
|         document.mime_type = "application/pdf" |         document.mime_type = "application/pdf" | ||||||
| @@ -354,7 +354,7 @@ class TestFileHandling(DirectoriesMixin, TestCase): | |||||||
|         # Ensure that filename is properly generated |         # Ensure that filename is properly generated | ||||||
|         self.assertEqual(generate_filename(document), "none.pdf") |         self.assertEqual(generate_filename(document), "none.pdf") | ||||||
|  |  | ||||||
|     @override_settings(PAPERLESS_FILENAME_FORMAT="{tags}") |     @override_settings(FILENAME_FORMAT="{tags}") | ||||||
|     def test_tags_without_args(self): |     def test_tags_without_args(self): | ||||||
|         document = Document() |         document = Document() | ||||||
|         document.mime_type = "application/pdf" |         document.mime_type = "application/pdf" | ||||||
| @@ -363,7 +363,7 @@ class TestFileHandling(DirectoriesMixin, TestCase): | |||||||
|  |  | ||||||
|         self.assertEqual(generate_filename(document), f"{document.pk:07}.pdf") |         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): |     def test_tag_list(self): | ||||||
|         doc = Document.objects.create(title="doc1", mime_type="application/pdf") |         doc = Document.objects.create(title="doc1", mime_type="application/pdf") | ||||||
|         doc.tags.create(name="tag2") |         doc.tags.create(name="tag2") | ||||||
| @@ -379,7 +379,7 @@ class TestFileHandling(DirectoriesMixin, TestCase): | |||||||
|  |  | ||||||
|         self.assertEqual(generate_filename(doc), "doc2.pdf") |         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): |     def test_filename_relative(self): | ||||||
|         doc = Document.objects.create(title="doc1", mime_type="application/pdf") |         doc = Document.objects.create(title="doc1", mime_type="application/pdf") | ||||||
|         doc.filename = generate_filename(doc) |         doc.filename = generate_filename(doc) | ||||||
| @@ -391,7 +391,7 @@ class TestFileHandling(DirectoriesMixin, TestCase): | |||||||
|         ) |         ) | ||||||
|  |  | ||||||
|     @override_settings( |     @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): |     def test_created_year_month_day(self): | ||||||
|         d1 = timezone.make_aware(datetime.datetime(2020, 3, 6, 1, 1, 1)) |         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") |         self.assertEqual(generate_filename(doc1), "2020-11-16.pdf") | ||||||
|  |  | ||||||
|     @override_settings( |     @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): |     def test_added_year_month_day(self): | ||||||
|         d1 = timezone.make_aware(datetime.datetime(232, 1, 9, 1, 1, 1)) |         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") |         self.assertEqual(generate_filename(doc1), "2020-11-16.pdf") | ||||||
|  |  | ||||||
|     @override_settings( |     @override_settings( | ||||||
|         PAPERLESS_FILENAME_FORMAT="{correspondent}/{correspondent}/{correspondent}", |         FILENAME_FORMAT="{correspondent}/{correspondent}/{correspondent}", | ||||||
|     ) |     ) | ||||||
|     def test_nested_directory_cleanup(self): |     def test_nested_directory_cleanup(self): | ||||||
|         document = Document() |         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 + "/none"), False) | ||||||
|         self.assertEqual(os.path.isdir(settings.ORIGINALS_DIR), True) |         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): |     def test_format_none(self): | ||||||
|         document = Document() |         document = Document() | ||||||
|         document.pk = 1 |         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.isfile(os.path.join(tmp, "notempty", "file")), True) | ||||||
|         self.assertEqual(os.path.isdir(os.path.join(tmp, "notempty", "empty")), False) |         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): |     def test_invalid_format(self): | ||||||
|         document = Document() |         document = Document() | ||||||
|         document.pk = 1 |         document.pk = 1 | ||||||
| @@ -488,7 +488,7 @@ class TestFileHandling(DirectoriesMixin, TestCase): | |||||||
|  |  | ||||||
|         self.assertEqual(generate_filename(document), "0000001.pdf") |         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): |     def test_invalid_format_key(self): | ||||||
|         document = Document() |         document = Document() | ||||||
|         document.pk = 1 |         document.pk = 1 | ||||||
| @@ -497,7 +497,7 @@ class TestFileHandling(DirectoriesMixin, TestCase): | |||||||
|  |  | ||||||
|         self.assertEqual(generate_filename(document), "0000001.pdf") |         self.assertEqual(generate_filename(document), "0000001.pdf") | ||||||
|  |  | ||||||
|     @override_settings(PAPERLESS_FILENAME_FORMAT="{title}") |     @override_settings(FILENAME_FORMAT="{title}") | ||||||
|     def test_duplicates(self): |     def test_duplicates(self): | ||||||
|         document = Document.objects.create( |         document = Document.objects.create( | ||||||
|             mime_type="application/pdf", |             mime_type="application/pdf", | ||||||
| @@ -548,7 +548,7 @@ class TestFileHandling(DirectoriesMixin, TestCase): | |||||||
|         self.assertTrue(os.path.isfile(document.source_path)) |         self.assertTrue(os.path.isfile(document.source_path)) | ||||||
|         self.assertEqual(document2.filename, "qwe.pdf") |         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") |     @mock.patch("documents.signals.handlers.Document.objects.filter") | ||||||
|     def test_no_update_without_change(self, m): |     def test_no_update_without_change(self, m): | ||||||
|         doc = Document.objects.create( |         doc = Document.objects.create( | ||||||
| @@ -568,7 +568,7 @@ class TestFileHandling(DirectoriesMixin, TestCase): | |||||||
|  |  | ||||||
|  |  | ||||||
| class TestFileHandlingWithArchive(DirectoriesMixin, TestCase): | class TestFileHandlingWithArchive(DirectoriesMixin, TestCase): | ||||||
|     @override_settings(PAPERLESS_FILENAME_FORMAT=None) |     @override_settings(FILENAME_FORMAT=None) | ||||||
|     def test_create_no_format(self): |     def test_create_no_format(self): | ||||||
|         original = os.path.join(settings.ORIGINALS_DIR, "0000001.pdf") |         original = os.path.join(settings.ORIGINALS_DIR, "0000001.pdf") | ||||||
|         archive = os.path.join(settings.ARCHIVE_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.source_path)) | ||||||
|         self.assertTrue(os.path.isfile(doc.archive_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): |     def test_create_with_format(self): | ||||||
|         original = os.path.join(settings.ORIGINALS_DIR, "0000001.pdf") |         original = os.path.join(settings.ORIGINALS_DIR, "0000001.pdf") | ||||||
|         archive = os.path.join(settings.ARCHIVE_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"), |             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): |     def test_move_archive_gone(self): | ||||||
|         original = os.path.join(settings.ORIGINALS_DIR, "0000001.pdf") |         original = os.path.join(settings.ORIGINALS_DIR, "0000001.pdf") | ||||||
|         archive = os.path.join(settings.ARCHIVE_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.assertTrue(os.path.isfile(doc.source_path)) | ||||||
|         self.assertFalse(os.path.isfile(doc.archive_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): |     def test_move_archive_exists(self): | ||||||
|         original = os.path.join(settings.ORIGINALS_DIR, "0000001.pdf") |         original = os.path.join(settings.ORIGINALS_DIR, "0000001.pdf") | ||||||
|         archive = os.path.join(settings.ARCHIVE_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.assertTrue(os.path.isfile(existing_archive_file)) | ||||||
|         self.assertEqual(doc.archive_filename, "none/my_doc_01.pdf") |         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): |     def test_move_original_only(self): | ||||||
|         original = os.path.join(settings.ORIGINALS_DIR, "document_01.pdf") |         original = os.path.join(settings.ORIGINALS_DIR, "document_01.pdf") | ||||||
|         archive = os.path.join(settings.ARCHIVE_DIR, "document.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.source_path)) | ||||||
|         self.assertTrue(os.path.isfile(doc.archive_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): |     def test_move_archive_only(self): | ||||||
|         original = os.path.join(settings.ORIGINALS_DIR, "document.pdf") |         original = os.path.join(settings.ORIGINALS_DIR, "document.pdf") | ||||||
|         archive = os.path.join(settings.ARCHIVE_DIR, "document_01.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.source_path)) | ||||||
|         self.assertTrue(os.path.isfile(doc.archive_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") |     @mock.patch("documents.signals.handlers.os.rename") | ||||||
|     def test_move_archive_error(self, m): |     def test_move_archive_error(self, m): | ||||||
|         def fake_rename(src, dst): |         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.source_path)) | ||||||
|         self.assertTrue(os.path.isfile(doc.archive_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): |     def test_move_file_gone(self): | ||||||
|         original = os.path.join(settings.ORIGINALS_DIR, "0000001.pdf") |         original = os.path.join(settings.ORIGINALS_DIR, "0000001.pdf") | ||||||
|         archive = os.path.join(settings.ARCHIVE_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.assertFalse(os.path.isfile(doc.source_path)) | ||||||
|         self.assertTrue(os.path.isfile(doc.archive_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") |     @mock.patch("documents.signals.handlers.os.rename") | ||||||
|     def test_move_file_error(self, m): |     def test_move_file_error(self, m): | ||||||
|         def fake_rename(src, dst): |         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.source_path)) | ||||||
|         self.assertTrue(os.path.isfile(doc.archive_path)) |         self.assertTrue(os.path.isfile(doc.archive_path)) | ||||||
|  |  | ||||||
|     @override_settings(PAPERLESS_FILENAME_FORMAT="") |     @override_settings(FILENAME_FORMAT="") | ||||||
|     def test_archive_deleted(self): |     def test_archive_deleted(self): | ||||||
|         original = os.path.join(settings.ORIGINALS_DIR, "0000001.pdf") |         original = os.path.join(settings.ORIGINALS_DIR, "0000001.pdf") | ||||||
|         archive = os.path.join(settings.ARCHIVE_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.source_path)) | ||||||
|         self.assertFalse(os.path.isfile(doc.archive_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): |     def test_archive_deleted2(self): | ||||||
|         original = os.path.join(settings.ORIGINALS_DIR, "document.png") |         original = os.path.join(settings.ORIGINALS_DIR, "document.png") | ||||||
|         original2 = os.path.join(settings.ORIGINALS_DIR, "0000001.pdf") |         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.assertTrue(os.path.isfile(doc1.archive_path)) | ||||||
|         self.assertFalse(os.path.isfile(doc2.source_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): |     def test_database_error(self): | ||||||
|  |  | ||||||
|         original = os.path.join(settings.ORIGINALS_DIR, "0000001.pdf") |         original = os.path.join(settings.ORIGINALS_DIR, "0000001.pdf") | ||||||
| @@ -872,7 +872,7 @@ class TestFileHandlingWithArchive(DirectoriesMixin, TestCase): | |||||||
|  |  | ||||||
|  |  | ||||||
| class TestFilenameGeneration(TestCase): | class TestFilenameGeneration(TestCase): | ||||||
|     @override_settings(PAPERLESS_FILENAME_FORMAT="{title}") |     @override_settings(FILENAME_FORMAT="{title}") | ||||||
|     def test_invalid_characters(self): |     def test_invalid_characters(self): | ||||||
|  |  | ||||||
|         doc = Document.objects.create( |         doc = Document.objects.create( | ||||||
| @@ -891,7 +891,7 @@ class TestFilenameGeneration(TestCase): | |||||||
|         ) |         ) | ||||||
|         self.assertEqual(generate_filename(doc), "my-invalid-..-title-yay.pdf") |         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): |     def test_date(self): | ||||||
|         doc = Document.objects.create( |         doc = Document.objects.create( | ||||||
|             title="does not matter", |             title="does not matter", | ||||||
| @@ -902,6 +902,140 @@ class TestFilenameGeneration(TestCase): | |||||||
|         ) |         ) | ||||||
|         self.assertEqual(generate_filename(doc), "2020-05-21.pdf") |         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(): | def run(): | ||||||
|     doc = Document.objects.create( |     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") | 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): | class TestArchiver(DirectoriesMixin, TestCase): | ||||||
|     def make_models(self): |     def make_models(self): | ||||||
|         return Document.objects.create( |         return Document.objects.create( | ||||||
| @@ -72,7 +72,7 @@ class TestArchiver(DirectoriesMixin, TestCase): | |||||||
|         self.assertIsNone(doc.archive_filename) |         self.assertIsNone(doc.archive_filename) | ||||||
|         self.assertTrue(os.path.isfile(doc.source_path)) |         self.assertTrue(os.path.isfile(doc.source_path)) | ||||||
|  |  | ||||||
|     @override_settings(PAPERLESS_FILENAME_FORMAT="{title}") |     @override_settings(FILENAME_FORMAT="{title}") | ||||||
|     def test_naming_priorities(self): |     def test_naming_priorities(self): | ||||||
|         doc1 = Document.objects.create( |         doc1 = Document.objects.create( | ||||||
|             checksum="A", |             checksum="A", | ||||||
| @@ -109,7 +109,7 @@ class TestDecryptDocuments(TestCase): | |||||||
|         ORIGINALS_DIR=os.path.join(os.path.dirname(__file__), "samples", "originals"), |         ORIGINALS_DIR=os.path.join(os.path.dirname(__file__), "samples", "originals"), | ||||||
|         THUMBNAIL_DIR=os.path.join(os.path.dirname(__file__), "samples", "thumb"), |         THUMBNAIL_DIR=os.path.join(os.path.dirname(__file__), "samples", "thumb"), | ||||||
|         PASSPHRASE="test", |         PASSPHRASE="test", | ||||||
|         PAPERLESS_FILENAME_FORMAT=None, |         FILENAME_FORMAT=None, | ||||||
|     ) |     ) | ||||||
|     @mock.patch("documents.management.commands.decrypt_documents.input") |     @mock.patch("documents.management.commands.decrypt_documents.input") | ||||||
|     def test_decrypt(self, m): |     def test_decrypt(self, m): | ||||||
| @@ -184,7 +184,7 @@ class TestMakeIndex(TestCase): | |||||||
|  |  | ||||||
|  |  | ||||||
| class TestRenamer(DirectoriesMixin, TestCase): | class TestRenamer(DirectoriesMixin, TestCase): | ||||||
|     @override_settings(PAPERLESS_FILENAME_FORMAT="") |     @override_settings(FILENAME_FORMAT="") | ||||||
|     def test_rename(self): |     def test_rename(self): | ||||||
|         doc = Document.objects.create(title="test", mime_type="image/jpeg") |         doc = Document.objects.create(title="test", mime_type="image/jpeg") | ||||||
|         doc.filename = generate_filename(doc) |         doc.filename = generate_filename(doc) | ||||||
| @@ -194,7 +194,7 @@ class TestRenamer(DirectoriesMixin, TestCase): | |||||||
|         Path(doc.source_path).touch() |         Path(doc.source_path).touch() | ||||||
|         Path(doc.archive_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") |             call_command("document_renamer") | ||||||
|  |  | ||||||
|         doc2 = Document.objects.get(id=doc.id) |         doc2 = Document.objects.get(id=doc.id) | ||||||
|   | |||||||
| @@ -200,7 +200,7 @@ class TestExportImport(DirectoriesMixin, TestCase): | |||||||
|         ) |         ) | ||||||
|  |  | ||||||
|         with override_settings( |         with override_settings( | ||||||
|             PAPERLESS_FILENAME_FORMAT="{created_year}/{correspondent}/{title}", |             FILENAME_FORMAT="{created_year}/{correspondent}/{title}", | ||||||
|         ): |         ): | ||||||
|             self.test_exporter(use_filename_format=True) |             self.test_exporter(use_filename_format=True) | ||||||
|  |  | ||||||
| @@ -309,7 +309,7 @@ class TestExportImport(DirectoriesMixin, TestCase): | |||||||
|  |  | ||||||
|         self.assertTrue(len(manifest), 6) |         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): |     def test_update_export_changed_location(self): | ||||||
|         shutil.rmtree(os.path.join(self.dirs.media_dir, "documents")) |         shutil.rmtree(os.path.join(self.dirs.media_dir, "documents")) | ||||||
|         shutil.copytree( |         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") | 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): | class TestMigrateArchiveFiles(DirectoriesMixin, TestMigrations): | ||||||
|  |  | ||||||
|     migrate_from = "1011_auto_20210101_2340" |     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): | class TestMigrateArchiveFilesWithFilenameFormat(TestMigrateArchiveFiles): | ||||||
|     def test_filenames(self): |     def test_filenames(self): | ||||||
|         Document = self.apps.get_model("documents", "Document") |         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" |     parser.text = "the text" | ||||||
|  |  | ||||||
|  |  | ||||||
| @override_settings(PAPERLESS_FILENAME_FORMAT="") | @override_settings(FILENAME_FORMAT="") | ||||||
| class TestMigrateArchiveFilesErrors(DirectoriesMixin, TestMigrations): | class TestMigrateArchiveFilesErrors(DirectoriesMixin, TestMigrations): | ||||||
|  |  | ||||||
|     migrate_from = "1011_auto_20210101_2340" |     migrate_from = "1011_auto_20210101_2340" | ||||||
| @@ -447,7 +447,7 @@ class TestMigrateArchiveFilesErrors(DirectoriesMixin, TestMigrations): | |||||||
|         self.assertIsNone(doc2.archive_filename) |         self.assertIsNone(doc2.archive_filename) | ||||||
|  |  | ||||||
|  |  | ||||||
| @override_settings(PAPERLESS_FILENAME_FORMAT="") | @override_settings(FILENAME_FORMAT="") | ||||||
| class TestMigrateArchiveFilesBackwards(DirectoriesMixin, TestMigrations): | class TestMigrateArchiveFilesBackwards(DirectoriesMixin, TestMigrations): | ||||||
|  |  | ||||||
|     migrate_from = "1012_fix_archive_files" |     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( | class TestMigrateArchiveFilesBackwardsWithFilenameFormat( | ||||||
|     TestMigrateArchiveFilesBackwards, |     TestMigrateArchiveFilesBackwards, | ||||||
| ): | ): | ||||||
|     pass |     pass | ||||||
|  |  | ||||||
|  |  | ||||||
| @override_settings(PAPERLESS_FILENAME_FORMAT="") | @override_settings(FILENAME_FORMAT="") | ||||||
| class TestMigrateArchiveFilesBackwardsErrors(DirectoriesMixin, TestMigrations): | class TestMigrateArchiveFilesBackwardsErrors(DirectoriesMixin, TestMigrations): | ||||||
|  |  | ||||||
|     migrate_from = "1012_fix_archive_files" |     migrate_from = "1012_fix_archive_files" | ||||||
|   | |||||||
| @@ -55,14 +55,17 @@ from .classifier import load_classifier | |||||||
| from .filters import CorrespondentFilterSet | from .filters import CorrespondentFilterSet | ||||||
| from .filters import DocumentFilterSet | from .filters import DocumentFilterSet | ||||||
| from .filters import DocumentTypeFilterSet | from .filters import DocumentTypeFilterSet | ||||||
|  | from .filters import StoragePathFilterSet | ||||||
| from .filters import TagFilterSet | from .filters import TagFilterSet | ||||||
| from .matching import match_correspondents | from .matching import match_correspondents | ||||||
| from .matching import match_document_types | from .matching import match_document_types | ||||||
|  | from .matching import match_storage_paths | ||||||
| from .matching import match_tags | from .matching import match_tags | ||||||
| from .models import Correspondent | from .models import Correspondent | ||||||
| from .models import Document | from .models import Document | ||||||
| from .models import DocumentType | from .models import DocumentType | ||||||
| from .models import SavedView | from .models import SavedView | ||||||
|  | from .models import StoragePath | ||||||
| from .models import Tag | from .models import Tag | ||||||
| from .parsers import get_parser_class_for_mime_type | from .parsers import get_parser_class_for_mime_type | ||||||
| from .serialisers import BulkDownloadSerializer | from .serialisers import BulkDownloadSerializer | ||||||
| @@ -73,6 +76,7 @@ from .serialisers import DocumentSerializer | |||||||
| from .serialisers import DocumentTypeSerializer | from .serialisers import DocumentTypeSerializer | ||||||
| from .serialisers import PostDocumentSerializer | from .serialisers import PostDocumentSerializer | ||||||
| from .serialisers import SavedViewSerializer | from .serialisers import SavedViewSerializer | ||||||
|  | from .serialisers import StoragePathSerializer | ||||||
| from .serialisers import TagSerializer | from .serialisers import TagSerializer | ||||||
| from .serialisers import TagSerializerVersion1 | from .serialisers import TagSerializerVersion1 | ||||||
| from .serialisers import UiSettingsViewSerializer | from .serialisers import UiSettingsViewSerializer | ||||||
| @@ -335,6 +339,7 @@ class DocumentViewSet( | |||||||
|                 "document_types": [ |                 "document_types": [ | ||||||
|                     dt.id for dt in match_document_types(doc, classifier) |                     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( |         r = Response( | ||||||
|             { |             { | ||||||
|                 "selected_correspondents": [ |                 "selected_correspondents": [ | ||||||
| @@ -589,6 +600,10 @@ class SelectionDataView(GenericAPIView): | |||||||
|                 "selected_document_types": [ |                 "selected_document_types": [ | ||||||
|                     {"id": t.id, "document_count": t.document_count} for t in 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): | class UiSettingsView(GenericAPIView): | ||||||
|  |  | ||||||
|     permission_classes = (IsAuthenticated,) |     permission_classes = (IsAuthenticated,) | ||||||
|   | |||||||
| @@ -597,15 +597,22 @@ FILENAME_PARSE_TRANSFORMS = [] | |||||||
| for t in json.loads(os.getenv("PAPERLESS_FILENAME_PARSE_TRANSFORMS", "[]")): | for t in json.loads(os.getenv("PAPERLESS_FILENAME_PARSE_TRANSFORMS", "[]")): | ||||||
|     FILENAME_PARSE_TRANSFORMS.append((re.compile(t["pattern"]), t["repl"])) |     FILENAME_PARSE_TRANSFORMS.append((re.compile(t["pattern"]), t["repl"])) | ||||||
|  |  | ||||||
| # TODO: this should not have a prefix. |  | ||||||
| # Specify the filename format for out files | # 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( | THUMBNAIL_FONT_NAME = os.getenv( | ||||||
|     "PAPERLESS_THUMBNAIL_FONT_NAME", |     "PAPERLESS_THUMBNAIL_FONT_NAME", | ||||||
|     "/usr/share/fonts/liberation/LiberationSerif-Regular.ttf", |     "/usr/share/fonts/liberation/LiberationSerif-Regular.ttf", | ||||||
| ) | ) | ||||||
|  |  | ||||||
|  | # TODO: this should not have a prefix. | ||||||
| # Tika settings | # Tika settings | ||||||
| PAPERLESS_TIKA_ENABLED = __get_boolean("PAPERLESS_TIKA_ENABLED", "NO") | PAPERLESS_TIKA_ENABLED = __get_boolean("PAPERLESS_TIKA_ENABLED", "NO") | ||||||
| PAPERLESS_TIKA_ENDPOINT = os.getenv("PAPERLESS_TIKA_ENDPOINT", "http://localhost:9998") | 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 SearchAutoCompleteView | ||||||
| from documents.views import SelectionDataView | from documents.views import SelectionDataView | ||||||
| from documents.views import StatisticsView | from documents.views import StatisticsView | ||||||
|  | from documents.views import StoragePathViewSet | ||||||
| from documents.views import TagViewSet | from documents.views import TagViewSet | ||||||
| from documents.views import UiSettingsView | from documents.views import UiSettingsView | ||||||
| from documents.views import UnifiedSearchViewSet | 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"logs", LogViewSet, basename="logs") | ||||||
| api_router.register(r"tags", TagViewSet) | api_router.register(r"tags", TagViewSet) | ||||||
| api_router.register(r"saved_views", SavedViewViewSet) | api_router.register(r"saved_views", SavedViewViewSet) | ||||||
|  | api_router.register(r"storage_paths", StoragePathViewSet) | ||||||
|  |  | ||||||
|  |  | ||||||
| urlpatterns = [ | urlpatterns = [ | ||||||
|   | |||||||
		Reference in New Issue
	
	Block a user
	 Markus
					Markus