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