Merge remote-tracking branch 'origin/dev'

This commit is contained in:
Trenton H 2023-08-24 11:35:16 -07:00
commit 8165071edf
20 changed files with 600 additions and 169 deletions

View File

@ -589,6 +589,12 @@ case, Paperless will remove the staging copy as well as the scan, and give you a
message asking you to restart the process from scratch, by scanning the odd pages again, message asking you to restart the process from scratch, by scanning the odd pages again,
followed by the even pages. followed by the even pages.
It's important that the scan files get consumed in the correct order, and one at a time.
You therefore need to make sure that Paperless is running while you upload the files into
the directory; and if you're using [polling](/configuration#polling), make sure that
`CONSUMER_POLLING` is set to a value lower than it takes for the second scan to appear,
like 5-10 or even lower.
Another thing that might happen is that you start a double sided scan, but then forget Another thing that might happen is that you start a double sided scan, but then forget
to upload the second file. To avoid collating the wrong documents if you then come back to upload the second file. To avoid collating the wrong documents if you then come back
a day later to scan a new double-sided document, Paperless will only keep an "odd numbered a day later to scan a new double-sided document, Paperless will only keep an "odd numbered
@ -597,11 +603,11 @@ scan a completely new "odd numbered pages" one. The old staging file will get di
### Interaction with "subdirs as tags" ### Interaction with "subdirs as tags"
The collation feature can be used together with the "subdirs as tags" feature (but this is not The collation feature can be used together with the [subdirs as tags](/configuration#consume_config)
a requirement). Just create a correctly named double-sided subdir in the hierachy and upload feature (but this is not a requirement). Just create a correctly named double-sided subdir
your scans there. For example, both `double-sided/foo/bar` as well as `foo/bar/double-sided` will in the hierachy and upload your scans there. For example, both `double-sided/foo/bar` as
cause the collated document to be treated as if it were uploaded into `foo/bar` and receive both well as `foo/bar/double-sided` will cause the collated document to be treated as if it
`foo` and `bar` tags, but not `double-sided`. were uploaded into `foo/bar` and receive both `foo` and `bar` tags, but not `double-sided`.
### Interaction with document splitting ### Interaction with document splitting

View File

@ -35,6 +35,12 @@ matcher.
Defaults to `redis://localhost:6379`. Defaults to `redis://localhost:6379`.
`PAPERLESS_REDIS_PREFIX=<prefix>`
: Prefix to be used in Redis for keys and channels. Useful for sharing one Redis server among multiple Paperless instances.
Defaults to no prefix.
### Database ### Database
`PAPERLESS_DBENGINE=<engine_name>` `PAPERLESS_DBENGINE=<engine_name>`
@ -495,6 +501,19 @@ HTTP header/value expected by Django, eg `'["HTTP_X_FORWARDED_PROTO", "https"]'`
Settings this value has security implications. Read the Django documentation Settings this value has security implications. Read the Django documentation
and be sure you understand its usage before setting it. and be sure you understand its usage before setting it.
`PAPERLESS_EMAIL_CERTIFICATE_FILE=<path>`
: Configures an additional SSL certificate file containing a [certificate](https://docs.python.org/3/library/ssl.html#certificates)
or certificate chain which should be trusted for validating SSL connections against mail providers.
This is for use with self-signed certificates against local IMAP servers.
Defaults to None.
!!! warning
Settings this value has security implications for the security of your email.
Understand what it does and be sure you need to before setting.
## OCR settings {#ocr} ## OCR settings {#ocr}
Paperless uses [OCRmyPDF](https://ocrmypdf.readthedocs.io/en/latest/) Paperless uses [OCRmyPDF](https://ocrmypdf.readthedocs.io/en/latest/)

View File

@ -723,7 +723,7 @@
</context-group> </context-group>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/manage/settings/settings.component.ts</context> <context context-type="sourcefile">src/app/components/manage/settings/settings.component.ts</context>
<context context-type="linenumber">600</context> <context context-type="linenumber">648</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="2526035785704676448" datatype="html"> <trans-unit id="2526035785704676448" datatype="html">
@ -2913,19 +2913,19 @@
</context-group> </context-group>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/manage/settings/settings.component.ts</context> <context context-type="sourcefile">src/app/components/manage/settings/settings.component.ts</context>
<context context-type="linenumber">711</context> <context context-type="linenumber">759</context>
</context-group> </context-group>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/manage/settings/settings.component.ts</context> <context context-type="sourcefile">src/app/components/manage/settings/settings.component.ts</context>
<context context-type="linenumber">771</context> <context context-type="linenumber">819</context>
</context-group> </context-group>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/manage/settings/settings.component.ts</context> <context context-type="sourcefile">src/app/components/manage/settings/settings.component.ts</context>
<context context-type="linenumber">838</context> <context context-type="linenumber">886</context>
</context-group> </context-group>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/manage/settings/settings.component.ts</context> <context context-type="sourcefile">src/app/components/manage/settings/settings.component.ts</context>
<context context-type="linenumber">901</context> <context context-type="linenumber">949</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="1181910457994920507" datatype="html"> <trans-unit id="1181910457994920507" datatype="html">
@ -2940,19 +2940,19 @@
</context-group> </context-group>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/manage/settings/settings.component.ts</context> <context context-type="sourcefile">src/app/components/manage/settings/settings.component.ts</context>
<context context-type="linenumber">713</context> <context context-type="linenumber">761</context>
</context-group> </context-group>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/manage/settings/settings.component.ts</context> <context context-type="sourcefile">src/app/components/manage/settings/settings.component.ts</context>
<context context-type="linenumber">773</context> <context context-type="linenumber">821</context>
</context-group> </context-group>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/manage/settings/settings.component.ts</context> <context context-type="sourcefile">src/app/components/manage/settings/settings.component.ts</context>
<context context-type="linenumber">840</context> <context context-type="linenumber">888</context>
</context-group> </context-group>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/manage/settings/settings.component.ts</context> <context context-type="sourcefile">src/app/components/manage/settings/settings.component.ts</context>
<context context-type="linenumber">903</context> <context context-type="linenumber">951</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="5729001209753056399" datatype="html"> <trans-unit id="5729001209753056399" datatype="html">
@ -4489,235 +4489,263 @@
<context context-type="linenumber">372</context> <context context-type="linenumber">372</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="3066660568529853846" datatype="html">
<source>Error retrieving groups</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/manage/settings/settings.component.ts</context>
<context context-type="linenumber">278</context>
</context-group>
</trans-unit>
<trans-unit id="1235706724900303689" datatype="html">
<source>Error retrieving users</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/manage/settings/settings.component.ts</context>
<context context-type="linenumber">287</context>
</context-group>
</trans-unit>
<trans-unit id="5241231471117657636" datatype="html">
<source>Error retrieving mail rules</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/manage/settings/settings.component.ts</context>
<context context-type="linenumber">314</context>
</context-group>
</trans-unit>
<trans-unit id="3178554336792037159" datatype="html">
<source>Error retrieving mail accounts</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/manage/settings/settings.component.ts</context>
<context context-type="linenumber">323</context>
</context-group>
</trans-unit>
<trans-unit id="5610279464668232148" datatype="html"> <trans-unit id="5610279464668232148" datatype="html">
<source>Saved view &quot;<x id="PH" equiv-text="savedView.name"/>&quot; deleted.</source> <source>Saved view &quot;<x id="PH" equiv-text="savedView.name"/>&quot; deleted.</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/manage/settings/settings.component.ts</context> <context context-type="sourcefile">src/app/components/manage/settings/settings.component.ts</context>
<context context-type="linenumber">482</context> <context context-type="linenumber">530</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="3891152409365583719" datatype="html"> <trans-unit id="3891152409365583719" datatype="html">
<source>Settings saved</source> <source>Settings saved</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/manage/settings/settings.component.ts</context> <context context-type="sourcefile">src/app/components/manage/settings/settings.component.ts</context>
<context context-type="linenumber">584</context> <context context-type="linenumber">632</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="7217000812750597833" datatype="html"> <trans-unit id="7217000812750597833" datatype="html">
<source>Settings were saved successfully.</source> <source>Settings were saved successfully.</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/manage/settings/settings.component.ts</context> <context context-type="sourcefile">src/app/components/manage/settings/settings.component.ts</context>
<context context-type="linenumber">585</context> <context context-type="linenumber">633</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="525012668859298131" datatype="html"> <trans-unit id="525012668859298131" datatype="html">
<source>Settings were saved successfully. Reload is required to apply some changes.</source> <source>Settings were saved successfully. Reload is required to apply some changes.</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/manage/settings/settings.component.ts</context> <context context-type="sourcefile">src/app/components/manage/settings/settings.component.ts</context>
<context context-type="linenumber">589</context> <context context-type="linenumber">637</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="8491974984518503778" datatype="html"> <trans-unit id="8491974984518503778" datatype="html">
<source>Reload now</source> <source>Reload now</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/manage/settings/settings.component.ts</context> <context context-type="sourcefile">src/app/components/manage/settings/settings.component.ts</context>
<context context-type="linenumber">590</context> <context context-type="linenumber">638</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="6839066544204061364" datatype="html"> <trans-unit id="6839066544204061364" datatype="html">
<source>Use system language</source> <source>Use system language</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/manage/settings/settings.component.ts</context> <context context-type="sourcefile">src/app/components/manage/settings/settings.component.ts</context>
<context context-type="linenumber">609</context> <context context-type="linenumber">657</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="7729897675462249787" datatype="html"> <trans-unit id="7729897675462249787" datatype="html">
<source>Use date format of display language</source> <source>Use date format of display language</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/manage/settings/settings.component.ts</context> <context context-type="sourcefile">src/app/components/manage/settings/settings.component.ts</context>
<context context-type="linenumber">616</context> <context context-type="linenumber">664</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="5260584511980773458" datatype="html"> <trans-unit id="5260584511980773458" datatype="html">
<source>Error while storing settings on server.</source> <source>Error while storing settings on server.</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/manage/settings/settings.component.ts</context> <context context-type="sourcefile">src/app/components/manage/settings/settings.component.ts</context>
<context context-type="linenumber">636</context> <context context-type="linenumber">684</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="4510369340305901516" datatype="html"> <trans-unit id="4510369340305901516" datatype="html">
<source>Password has been changed, you will be logged out momentarily.</source> <source>Password has been changed, you will be logged out momentarily.</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/manage/settings/settings.component.ts</context> <context context-type="sourcefile">src/app/components/manage/settings/settings.component.ts</context>
<context context-type="linenumber">679</context> <context context-type="linenumber">727</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="2753185112875184719" datatype="html"> <trans-unit id="2753185112875184719" datatype="html">
<source>Saved user &quot;<x id="PH" equiv-text="newUser.username"/>&quot;.</source> <source>Saved user &quot;<x id="PH" equiv-text="newUser.username"/>&quot;.</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/manage/settings/settings.component.ts</context> <context context-type="sourcefile">src/app/components/manage/settings/settings.component.ts</context>
<context context-type="linenumber">686</context> <context context-type="linenumber">734</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="3471101514724661554" datatype="html"> <trans-unit id="3471101514724661554" datatype="html">
<source>Error saving user.</source> <source>Error saving user.</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/manage/settings/settings.component.ts</context> <context context-type="sourcefile">src/app/components/manage/settings/settings.component.ts</context>
<context context-type="linenumber">698</context> <context context-type="linenumber">746</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="5565868288871970148" datatype="html"> <trans-unit id="5565868288871970148" datatype="html">
<source>Confirm delete user account</source> <source>Confirm delete user account</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/manage/settings/settings.component.ts</context> <context context-type="sourcefile">src/app/components/manage/settings/settings.component.ts</context>
<context context-type="linenumber">709</context> <context context-type="linenumber">757</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="8133663925694885325" datatype="html"> <trans-unit id="8133663925694885325" datatype="html">
<source>This operation will permanently delete this user account.</source> <source>This operation will permanently delete this user account.</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/manage/settings/settings.component.ts</context> <context context-type="sourcefile">src/app/components/manage/settings/settings.component.ts</context>
<context context-type="linenumber">710</context> <context context-type="linenumber">758</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="857903183180440990" datatype="html"> <trans-unit id="857903183180440990" datatype="html">
<source>Deleted user</source> <source>Deleted user</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/manage/settings/settings.component.ts</context> <context context-type="sourcefile">src/app/components/manage/settings/settings.component.ts</context>
<context context-type="linenumber">719</context> <context context-type="linenumber">767</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="1942566571910298572" datatype="html"> <trans-unit id="1942566571910298572" datatype="html">
<source>Error deleting user.</source> <source>Error deleting user.</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/manage/settings/settings.component.ts</context> <context context-type="sourcefile">src/app/components/manage/settings/settings.component.ts</context>
<context context-type="linenumber">727</context> <context context-type="linenumber">775</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="5766640174051730159" datatype="html"> <trans-unit id="5766640174051730159" datatype="html">
<source>Saved group &quot;<x id="PH" equiv-text="newGroup.name"/>&quot;.</source> <source>Saved group &quot;<x id="PH" equiv-text="newGroup.name"/>&quot;.</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/manage/settings/settings.component.ts</context> <context context-type="sourcefile">src/app/components/manage/settings/settings.component.ts</context>
<context context-type="linenumber">748</context> <context context-type="linenumber">796</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="8382042988405122578" datatype="html"> <trans-unit id="8382042988405122578" datatype="html">
<source>Error saving group.</source> <source>Error saving group.</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/manage/settings/settings.component.ts</context> <context context-type="sourcefile">src/app/components/manage/settings/settings.component.ts</context>
<context context-type="linenumber">758</context> <context context-type="linenumber">806</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="6538873300613683004" datatype="html"> <trans-unit id="6538873300613683004" datatype="html">
<source>Confirm delete user group</source> <source>Confirm delete user group</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/manage/settings/settings.component.ts</context> <context context-type="sourcefile">src/app/components/manage/settings/settings.component.ts</context>
<context context-type="linenumber">769</context> <context context-type="linenumber">817</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="7710984639498518244" datatype="html"> <trans-unit id="7710984639498518244" datatype="html">
<source>This operation will permanently delete this user group.</source> <source>This operation will permanently delete this user group.</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/manage/settings/settings.component.ts</context> <context context-type="sourcefile">src/app/components/manage/settings/settings.component.ts</context>
<context context-type="linenumber">770</context> <context context-type="linenumber">818</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="6834066329827670963" datatype="html"> <trans-unit id="6834066329827670963" datatype="html">
<source>Deleted group</source> <source>Deleted group</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/manage/settings/settings.component.ts</context> <context context-type="sourcefile">src/app/components/manage/settings/settings.component.ts</context>
<context context-type="linenumber">779</context> <context context-type="linenumber">827</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="8850738980935204840" datatype="html"> <trans-unit id="8850738980935204840" datatype="html">
<source>Error deleting group.</source> <source>Error deleting group.</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/manage/settings/settings.component.ts</context> <context context-type="sourcefile">src/app/components/manage/settings/settings.component.ts</context>
<context context-type="linenumber">787</context> <context context-type="linenumber">835</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="6327501535846658797" datatype="html"> <trans-unit id="6327501535846658797" datatype="html">
<source>Saved account &quot;<x id="PH" equiv-text="newMailAccount.name"/>&quot;.</source> <source>Saved account &quot;<x id="PH" equiv-text="newMailAccount.name"/>&quot;.</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/manage/settings/settings.component.ts</context> <context context-type="sourcefile">src/app/components/manage/settings/settings.component.ts</context>
<context context-type="linenumber">813</context> <context context-type="linenumber">861</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="8067594003836508139" datatype="html"> <trans-unit id="8067594003836508139" datatype="html">
<source>Error saving account.</source> <source>Error saving account.</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/manage/settings/settings.component.ts</context> <context context-type="sourcefile">src/app/components/manage/settings/settings.component.ts</context>
<context context-type="linenumber">825</context> <context context-type="linenumber">873</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="5641934153807844674" datatype="html"> <trans-unit id="5641934153807844674" datatype="html">
<source>Confirm delete mail account</source> <source>Confirm delete mail account</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/manage/settings/settings.component.ts</context> <context context-type="sourcefile">src/app/components/manage/settings/settings.component.ts</context>
<context context-type="linenumber">836</context> <context context-type="linenumber">884</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="7176985344323395435" datatype="html"> <trans-unit id="7176985344323395435" datatype="html">
<source>This operation will permanently delete this mail account.</source> <source>This operation will permanently delete this mail account.</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/manage/settings/settings.component.ts</context> <context context-type="sourcefile">src/app/components/manage/settings/settings.component.ts</context>
<context context-type="linenumber">837</context> <context context-type="linenumber">885</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="4233826387148482123" datatype="html"> <trans-unit id="4233826387148482123" datatype="html">
<source>Deleted mail account</source> <source>Deleted mail account</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/manage/settings/settings.component.ts</context> <context context-type="sourcefile">src/app/components/manage/settings/settings.component.ts</context>
<context context-type="linenumber">846</context> <context context-type="linenumber">894</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="6202503362522392111" datatype="html"> <trans-unit id="6202503362522392111" datatype="html">
<source>Error deleting mail account.</source> <source>Error deleting mail account.</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/manage/settings/settings.component.ts</context> <context context-type="sourcefile">src/app/components/manage/settings/settings.component.ts</context>
<context context-type="linenumber">855</context> <context context-type="linenumber">903</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="123368655395433699" datatype="html"> <trans-unit id="123368655395433699" datatype="html">
<source>Saved rule &quot;<x id="PH" equiv-text="newMailRule.name"/>&quot;.</source> <source>Saved rule &quot;<x id="PH" equiv-text="newMailRule.name"/>&quot;.</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/manage/settings/settings.component.ts</context> <context context-type="sourcefile">src/app/components/manage/settings/settings.component.ts</context>
<context context-type="linenumber">876</context> <context context-type="linenumber">924</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="8951124554918814321" datatype="html"> <trans-unit id="8951124554918814321" datatype="html">
<source>Error saving rule.</source> <source>Error saving rule.</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/manage/settings/settings.component.ts</context> <context context-type="sourcefile">src/app/components/manage/settings/settings.component.ts</context>
<context context-type="linenumber">888</context> <context context-type="linenumber">936</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="3896080636020672118" datatype="html"> <trans-unit id="3896080636020672118" datatype="html">
<source>Confirm delete mail rule</source> <source>Confirm delete mail rule</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/manage/settings/settings.component.ts</context> <context context-type="sourcefile">src/app/components/manage/settings/settings.component.ts</context>
<context context-type="linenumber">899</context> <context context-type="linenumber">947</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="2250372580580310337" datatype="html"> <trans-unit id="2250372580580310337" datatype="html">
<source>This operation will permanently delete this mail rule.</source> <source>This operation will permanently delete this mail rule.</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/manage/settings/settings.component.ts</context> <context context-type="sourcefile">src/app/components/manage/settings/settings.component.ts</context>
<context context-type="linenumber">900</context> <context context-type="linenumber">948</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="9077981247971516916" datatype="html"> <trans-unit id="9077981247971516916" datatype="html">
<source>Deleted mail rule</source> <source>Deleted mail rule</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/manage/settings/settings.component.ts</context> <context context-type="sourcefile">src/app/components/manage/settings/settings.component.ts</context>
<context context-type="linenumber">909</context> <context context-type="linenumber">957</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="2033194641751367552" datatype="html"> <trans-unit id="2033194641751367552" datatype="html">
<source>Error deleting mail rule.</source> <source>Error deleting mail rule.</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/manage/settings/settings.component.ts</context> <context context-type="sourcefile">src/app/components/manage/settings/settings.component.ts</context>
<context context-type="linenumber">918</context> <context context-type="linenumber">966</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="5101757640976222639" datatype="html"> <trans-unit id="5101757640976222639" datatype="html">

View File

@ -2,7 +2,7 @@
<label class="form-label" for="tags" i18n>Tags</label> <label class="form-label" for="tags" i18n>Tags</label>
<div class="input-group flex-nowrap"> <div class="input-group flex-nowrap">
<ng-select name="tags" [items]="tags" bindLabel="name" bindValue="id" [(ngModel)]="value" <ng-select #tagSelect name="tags" [items]="tags" bindLabel="name" bindValue="id" [(ngModel)]="value"
[disabled]="disabled" [disabled]="disabled"
[multiple]="true" [multiple]="true"
[closeOnSelect]="false" [closeOnSelect]="false"
@ -11,11 +11,7 @@
[addTag]="allowCreate ? createTagRef : false" [addTag]="allowCreate ? createTagRef : false"
addTagText="Add tag" addTagText="Add tag"
i18n-addTagText i18n-addTagText
(change)="onChange(value)" (change)="onChange(value)">
(search)="onSearch($event)"
(focus)="clearLastSearchTerm()"
(clear)="clearLastSearchTerm()"
(blur)="onBlur()">
<ng-template ng-label-tmp let-item="item"> <ng-template ng-label-tmp let-item="item">
<span class="tag-wrap tag-wrap-delete" (mousedown)="removeTag($event, item.id)"> <span class="tag-wrap tag-wrap-delete" (mousedown)="removeTag($event, item.id)">

View File

@ -15,16 +15,28 @@ import {
DEFAULT_MATCHING_ALGORITHM, DEFAULT_MATCHING_ALGORITHM,
MATCH_ALL, MATCH_ALL,
} from 'src/app/data/matching-model' } from 'src/app/data/matching-model'
import { NgSelectModule } from '@ng-select/ng-select' import { NgSelectComponent, NgSelectModule } from '@ng-select/ng-select'
import { RouterTestingModule } from '@angular/router/testing' import { RouterTestingModule } from '@angular/router/testing'
import { HttpClientTestingModule } from '@angular/common/http/testing' import { HttpClientTestingModule } from '@angular/common/http/testing'
import { of } from 'rxjs' import { of } from 'rxjs'
import { TagService } from 'src/app/services/rest/tag.service' import { TagService } from 'src/app/services/rest/tag.service'
import { import {
NgbAccordionModule,
NgbModal, NgbModal,
NgbModalModule, NgbModalModule,
NgbModalRef, NgbModalRef,
NgbPopoverModule,
} from '@ng-bootstrap/ng-bootstrap' } from '@ng-bootstrap/ng-bootstrap'
import { TagEditDialogComponent } from '../../edit-dialog/tag-edit-dialog/tag-edit-dialog.component'
import { CheckComponent } from '../check/check.component'
import { IfOwnerDirective } from 'src/app/directives/if-owner.directive'
import { TextComponent } from '../text/text.component'
import { ColorComponent } from '../color/color.component'
import { IfPermissionsDirective } from 'src/app/directives/if-permissions.directive'
import { PermissionsFormComponent } from '../permissions/permissions-form/permissions-form.component'
import { SelectComponent } from '../select/select.component'
import { ColorSliderModule } from 'ngx-color/slider'
import { By } from '@angular/platform-browser'
const tags: PaperlessTag[] = [ const tags: PaperlessTag[] = [
{ {
@ -56,12 +68,32 @@ describe('TagsComponent', () => {
beforeEach(async () => { beforeEach(async () => {
TestBed.configureTestingModule({ TestBed.configureTestingModule({
declarations: [TagsComponent], declarations: [
TagsComponent,
TagEditDialogComponent,
TextComponent,
ColorComponent,
IfOwnerDirective,
SelectComponent,
TextComponent,
PermissionsFormComponent,
ColorComponent,
CheckComponent,
],
providers: [ providers: [
{ {
provide: TagService, provide: TagService,
useValue: { useValue: {
listAll: () => of(tags), listAll: () =>
of({
results: tags,
}),
create: () =>
of({
name: 'bar',
id: 99,
color: '#fff000',
}),
}, },
}, },
], ],
@ -72,6 +104,8 @@ describe('TagsComponent', () => {
RouterTestingModule, RouterTestingModule,
HttpClientTestingModule, HttpClientTestingModule,
NgbModalModule, NgbModalModule,
NgbAccordionModule,
NgbPopoverModule,
], ],
}).compileComponents() }).compileComponents()
@ -85,7 +119,7 @@ describe('TagsComponent', () => {
}) })
it('should support suggestions', () => { it('should support suggestions', () => {
expect(component.value).toBeUndefined() expect(component.value).toHaveLength(0)
component.value = [] component.value = []
component.tags = tags component.tags = tags
component.suggestions = [1, 2] component.suggestions = [1, 2]
@ -107,18 +141,18 @@ describe('TagsComponent', () => {
it('should support create new using last search term and open a modal', () => { it('should support create new using last search term and open a modal', () => {
let activeInstances: NgbModalRef[] let activeInstances: NgbModalRef[]
modalService.activeInstances.subscribe((v) => (activeInstances = v)) modalService.activeInstances.subscribe((v) => (activeInstances = v))
component.onSearch({ term: 'bar' }) component.select.searchTerm = 'foobar'
component.createTag() component.createTag()
expect(modalService.hasOpenModals()).toBeTruthy() expect(modalService.hasOpenModals()).toBeTruthy()
expect(activeInstances[0].componentInstance.object.name).toEqual('bar') expect(activeInstances[0].componentInstance.object.name).toEqual('foobar')
const editDialog = activeInstances[0]
.componentInstance as TagEditDialogComponent
editDialog.save() // create is mocked
fixture.detectChanges()
fixture.whenStable().then(() => {
expect(fixture.debugElement.nativeElement.textContent).toContain('foobar')
})
}) })
it('should clear search term on blur after delay', fakeAsync(() => {
const clearSpy = jest.spyOn(component, 'clearLastSearchTerm')
component.onBlur()
tick(3000)
expect(clearSpy).toHaveBeenCalled()
}))
it('support remove tags', () => { it('support remove tags', () => {
component.tags = tags component.tags = tags
@ -132,6 +166,7 @@ describe('TagsComponent', () => {
}) })
it('should get tags', () => { it('should get tags', () => {
component.tags = null
expect(component.getTag(2)).toBeNull() expect(component.getTag(2)).toBeNull()
component.tags = tags component.tags = tags
expect(component.getTag(2)).toEqual(tags[1]) expect(component.getTag(2)).toEqual(tags[1])

View File

@ -5,6 +5,7 @@ import {
Input, Input,
OnInit, OnInit,
Output, Output,
ViewChild,
} from '@angular/core' } from '@angular/core'
import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms' import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms'
import { NgbModal } from '@ng-bootstrap/ng-bootstrap' import { NgbModal } from '@ng-bootstrap/ng-bootstrap'
@ -12,6 +13,8 @@ import { PaperlessTag } from 'src/app/data/paperless-tag'
import { TagEditDialogComponent } from '../../edit-dialog/tag-edit-dialog/tag-edit-dialog.component' import { TagEditDialogComponent } from '../../edit-dialog/tag-edit-dialog/tag-edit-dialog.component'
import { TagService } from 'src/app/services/rest/tag.service' import { TagService } from 'src/app/services/rest/tag.service'
import { EditDialogMode } from '../../edit-dialog/edit-dialog.component' import { EditDialogMode } from '../../edit-dialog/edit-dialog.component'
import { first, firstValueFrom, tap } from 'rxjs'
import { NgSelectComponent } from '@ng-select/ng-select'
@Component({ @Component({
providers: [ providers: [
@ -74,14 +77,14 @@ export class TagsComponent implements OnInit, ControlValueAccessor {
@Output() @Output()
filterDocuments = new EventEmitter<PaperlessTag[]>() filterDocuments = new EventEmitter<PaperlessTag[]>()
value: number[] @ViewChild('tagSelect') select: NgSelectComponent
tags: PaperlessTag[] value: number[] = []
tags: PaperlessTag[] = []
public createTagRef: (name) => void public createTagRef: (name) => void
private _lastSearchTerm: string
getTag(id: number) { getTag(id: number) {
if (this.tags) { if (this.tags) {
return this.tags.find((tag) => tag.id == id) return this.tags.find((tag) => tag.id == id)
@ -111,15 +114,20 @@ export class TagsComponent implements OnInit, ControlValueAccessor {
}) })
modal.componentInstance.dialogMode = EditDialogMode.CREATE modal.componentInstance.dialogMode = EditDialogMode.CREATE
if (name) modal.componentInstance.object = { name: name } if (name) modal.componentInstance.object = { name: name }
else if (this._lastSearchTerm) else if (this.select.searchTerm)
modal.componentInstance.object = { name: this._lastSearchTerm } modal.componentInstance.object = { name: this.select.searchTerm }
modal.componentInstance.succeeded.subscribe((newTag) => { this.select.searchTerm = null
this.select.detectChanges()
return firstValueFrom(
(modal.componentInstance as TagEditDialogComponent).succeeded.pipe(
first(),
tap(() => {
this.tagService.listAll().subscribe((tags) => { this.tagService.listAll().subscribe((tags) => {
this.tags = tags.results this.tags = tags.results
this.value = [...this.value, newTag.id]
this.onChange(this.value)
}) })
}) })
)
)
} }
getSuggestions() { getSuggestions() {
@ -137,20 +145,6 @@ export class TagsComponent implements OnInit, ControlValueAccessor {
this.onChange(this.value) this.onChange(this.value)
} }
clearLastSearchTerm() {
this._lastSearchTerm = null
}
onSearch($event) {
this._lastSearchTerm = $event.term
}
onBlur() {
setTimeout(() => {
this.clearLastSearchTerm()
}, 3000)
}
get hasPrivate(): boolean { get hasPrivate(): boolean {
return this.value.some( return this.value.some(
(t) => this.tags?.find((t2) => t2.id === t) === undefined (t) => this.tags?.find((t2) => t2.id === t) === undefined

View File

@ -243,7 +243,7 @@
<ng-container *appIfPermissions="{ action: PermissionAction.View, type: PermissionType.MailAccount }"> <ng-container *appIfPermissions="{ action: PermissionAction.View, type: PermissionType.MailAccount }">
<h4> <h4>
<ng-container i18n>Mail accounts</ng-container> <ng-container i18n>Mail accounts</ng-container>
<button type="button" class="btn btn-sm btn-primary ms-4" (click)="editMailAccount()"> <button type="button" class="btn btn-sm btn-primary ms-4" (click)="editMailAccount()" *appIfPermissions="{ action: PermissionAction.Add, type: PermissionType.MailAccount }">
<svg class="sidebaricon me-1" fill="currentColor"> <svg class="sidebaricon me-1" fill="currentColor">
<use xlink:href="assets/bootstrap-icons.svg#plus-circle" /> <use xlink:href="assets/bootstrap-icons.svg#plus-circle" />
</svg> </svg>
@ -262,7 +262,7 @@
<li *ngFor="let account of mailAccounts" class="list-group-item" [formGroupName]="account.id"> <li *ngFor="let account of mailAccounts" class="list-group-item" [formGroupName]="account.id">
<div class="row"> <div class="row">
<div class="col d-flex align-items-center"><button class="btn btn-link p-0" type="button" (click)="editMailAccount(account)">{{account.name}}</button></div> <div class="col d-flex align-items-center"><button class="btn btn-link p-0" type="button" (click)="editMailAccount(account)" [disabled]="!permissionsService.currentUserCan(PermissionAction.Change, PermissionType.MailAccount)">{{account.name}}</button></div>
<div class="col d-flex align-items-center">{{account.imap_server}}</div> <div class="col d-flex align-items-center">{{account.imap_server}}</div>
<div class="col"> <div class="col">
<div class="btn-group"> <div class="btn-group">
@ -280,7 +280,7 @@
<ng-container *appIfPermissions="{ action: PermissionAction.View, type: PermissionType.MailRule }"> <ng-container *appIfPermissions="{ action: PermissionAction.View, type: PermissionType.MailRule }">
<h4 class="mt-4"> <h4 class="mt-4">
<ng-container i18n>Mail rules</ng-container> <ng-container i18n>Mail rules</ng-container>
<button type="button" class="btn btn-sm btn-primary ms-4" (click)="editMailRule()"> <button type="button" class="btn btn-sm btn-primary ms-4" (click)="editMailRule()" *appIfPermissions="{ action: PermissionAction.Add, type: PermissionType.MailRule }">
<svg class="sidebaricon me-1" fill="currentColor"> <svg class="sidebaricon me-1" fill="currentColor">
<use xlink:href="assets/bootstrap-icons.svg#plus-circle" /> <use xlink:href="assets/bootstrap-icons.svg#plus-circle" />
</svg> </svg>
@ -299,7 +299,7 @@
<li *ngFor="let rule of mailRules" class="list-group-item" [formGroupName]="rule.id"> <li *ngFor="let rule of mailRules" class="list-group-item" [formGroupName]="rule.id">
<div class="row"> <div class="row">
<div class="col d-flex align-items-center"><button class="btn btn-link p-0" type="button" (click)="editMailRule(rule)">{{rule.name}}</button></div> <div class="col d-flex align-items-center"><button class="btn btn-link p-0" type="button" (click)="editMailRule(rule)" [disabled]="!permissionsService.currentUserCan(PermissionAction.Change, PermissionType.MailRule)">{{rule.name}}</button></div>
<div class="col d-flex align-items-center">{{(mailAccountService.getCached(rule.account) | async)?.name}}</div> <div class="col d-flex align-items-center">{{(mailAccountService.getCached(rule.account) | async)?.name}}</div>
<div class="col"> <div class="col">
<div class="btn-group"> <div class="btn-group">
@ -323,7 +323,7 @@
</ng-template> </ng-template>
</li> </li>
<li [ngbNavItem]="SettingsNavIDs.UsersGroups" *appIfPermissions="{ action: PermissionAction.Add, type: PermissionType.User }" (mouseover)="maybeInitializeTab(SettingsNavIDs.UsersGroups)" (focusin)="maybeInitializeTab(SettingsNavIDs.UsersGroups)"> <li [ngbNavItem]="SettingsNavIDs.UsersGroups" *appIfPermissions="{ action: PermissionAction.View, type: PermissionType.User }" (mouseover)="maybeInitializeTab(SettingsNavIDs.UsersGroups)" (focusin)="maybeInitializeTab(SettingsNavIDs.UsersGroups)">
<a ngbNavLink i18n>Users & Groups</a> <a ngbNavLink i18n>Users & Groups</a>
<ng-template ngbNavContent> <ng-template ngbNavContent>
@ -334,7 +334,7 @@
<svg class="sidebaricon me-1" fill="currentColor"> <svg class="sidebaricon me-1" fill="currentColor">
<use xlink:href="assets/bootstrap-icons.svg#plus-circle" /> <use xlink:href="assets/bootstrap-icons.svg#plus-circle" />
</svg> </svg>
<ng-container i18n>Add User</ng-container> <ng-container *appIfPermissions="{ action: PermissionAction.Add, type: PermissionType.User }" i18n>Add User</ng-container>
</button> </button>
</h4> </h4>
<ul class="list-group" formGroupName="usersGroup"> <ul class="list-group" formGroupName="usersGroup">
@ -350,13 +350,13 @@
<li *ngFor="let user of users" class="list-group-item" [formGroupName]="user.id"> <li *ngFor="let user of users" class="list-group-item" [formGroupName]="user.id">
<div class="row"> <div class="row">
<div class="col d-flex align-items-center"><button class="btn btn-link p-0" type="button" (click)="editUser(user)">{{user.username}}</button></div> <div class="col d-flex align-items-center"><button class="btn btn-link p-0" type="button" (click)="editUser(user)" [disabled]="!permissionsService.currentUserCan(PermissionAction.Change, PermissionType.User)">{{user.username}}</button></div>
<div class="col d-flex align-items-center">{{user.first_name}} {{user.last_name}}</div> <div class="col d-flex align-items-center">{{user.first_name}} {{user.last_name}}</div>
<div class="col d-flex align-items-center">{{user.groups?.map(getGroupName, this).join(', ')}}</div> <div class="col d-flex align-items-center">{{user.groups?.map(getGroupName, this).join(', ')}}</div>
<div class="col"> <div class="col">
<div class="btn-group"> <div class="btn-group">
<button class="btn btn-sm btn-primary" type="button" (click)="editUser(user)" i18n>Edit</button> <button class="btn btn-sm btn-primary" type="button" (click)="editUser(user)" *appIfPermissions="{ action: PermissionAction.Change, type: PermissionType.User }" i18n>Edit</button>
<button class="btn btn-sm btn-outline-danger" type="button" (click)="deleteUser(user)" i18n>Delete</button> <button class="btn btn-sm btn-outline-danger" type="button" (click)="deleteUser(user)" *appIfPermissions="{ action: PermissionAction.Delete, type: PermissionType.User }" i18n>Delete</button>
</div> </div>
</div> </div>
</div> </div>
@ -369,7 +369,7 @@
<svg class="sidebaricon me-1" fill="currentColor"> <svg class="sidebaricon me-1" fill="currentColor">
<use xlink:href="assets/bootstrap-icons.svg#plus-circle" /> <use xlink:href="assets/bootstrap-icons.svg#plus-circle" />
</svg> </svg>
<ng-container i18n>Add Group</ng-container> <ng-container *appIfPermissions="{ action: PermissionAction.Add, type: PermissionType.Group }" i18n>Add Group</ng-container>
</button> </button>
</h4> </h4>
<ul *ngIf="groups.length > 0" class="list-group" formGroupName="groupsGroup"> <ul *ngIf="groups.length > 0" class="list-group" formGroupName="groupsGroup">
@ -385,13 +385,13 @@
<li *ngFor="let group of groups" class="list-group-item" [formGroupName]="group.id"> <li *ngFor="let group of groups" class="list-group-item" [formGroupName]="group.id">
<div class="row"> <div class="row">
<div class="col d-flex align-items-center"><button class="btn btn-link p-0" type="button" (click)="editGroup(group)">{{group.name}}</button></div> <div class="col d-flex align-items-center"><button class="btn btn-link p-0" type="button" (click)="editGroup(group)" [disabled]="!permissionsService.currentUserCan(PermissionAction.Change, PermissionType.Group)">{{group.name}}</button></div>
<div class="col"></div> <div class="col"></div>
<div class="col"></div> <div class="col"></div>
<div class="col"> <div class="col">
<div class="btn-group"> <div class="btn-group">
<button class="btn btn-sm btn-primary" type="button" (click)="editGroup(group)" i18n>Edit</button> <button class="btn btn-sm btn-primary" type="button" (click)="editGroup(group)" *appIfPermissions="{ action: PermissionAction.Change, type: PermissionType.Group }" i18n>Edit</button>
<button class="btn btn-sm btn-outline-danger" type="button" (click)="deleteGroup(group)" i18n>Delete</button> <button class="btn btn-sm btn-outline-danger" type="button" (click)="deleteGroup(group)" *appIfPermissions="{ action: PermissionAction.Delete, type: PermissionType.Group }" i18n>Delete</button>
</div> </div>
</div> </div>
</div> </div>

View File

@ -15,6 +15,7 @@ import {
NgbModule, NgbModule,
NgbNavLink, NgbNavLink,
NgbModalRef, NgbModalRef,
NgbAlertModule,
} from '@ng-bootstrap/ng-bootstrap' } from '@ng-bootstrap/ng-bootstrap'
import { of, throwError } from 'rxjs' import { of, throwError } from 'rxjs'
import { routes } from 'src/app/app-routing.module' import { routes } from 'src/app/app-routing.module'
@ -42,6 +43,13 @@ import { CheckComponent } from '../../common/input/check/check.component'
import { ColorComponent } from '../../common/input/color/color.component' import { ColorComponent } from '../../common/input/color/color.component'
import { PageHeaderComponent } from '../../common/page-header/page-header.component' import { PageHeaderComponent } from '../../common/page-header/page-header.component'
import { SettingsComponent } from './settings.component' import { SettingsComponent } from './settings.component'
import { SafeHtmlPipe } from 'src/app/pipes/safehtml.pipe'
import { SelectComponent } from '../../common/input/select/select.component'
import { TextComponent } from '../../common/input/text/text.component'
import { PasswordComponent } from '../../common/input/password/password.component'
import { NumberComponent } from '../../common/input/number/number.component'
import { TagsComponent } from '../../common/input/tags/tags.component'
import { NgSelectModule } from '@ng-select/ng-select'
const savedViews = [ const savedViews = [
{ id: 1, name: 'view1' }, { id: 1, name: 'view1' },
@ -90,6 +98,14 @@ describe('SettingsComponent', () => {
ConfirmDialogComponent, ConfirmDialogComponent,
CheckComponent, CheckComponent,
ColorComponent, ColorComponent,
SafeHtmlPipe,
SelectComponent,
TextComponent,
PasswordComponent,
NumberComponent,
TagsComponent,
MailAccountEditDialogComponent,
MailRuleEditDialogComponent,
], ],
providers: [CustomDatePipe, DatePipe, PermissionsGuard], providers: [CustomDatePipe, DatePipe, PermissionsGuard],
imports: [ imports: [
@ -98,6 +114,8 @@ describe('SettingsComponent', () => {
RouterTestingModule.withRoutes(routes), RouterTestingModule.withRoutes(routes),
FormsModule, FormsModule,
ReactiveFormsModule, ReactiveFormsModule,
NgbAlertModule,
NgSelectModule,
], ],
}).compileComponents() }).compileComponents()
@ -116,6 +134,14 @@ describe('SettingsComponent', () => {
jest jest
.spyOn(permissionsService, 'currentUserOwnsObject') .spyOn(permissionsService, 'currentUserOwnsObject')
.mockReturnValue(true) .mockReturnValue(true)
groupService = TestBed.inject(GroupService)
savedViewService = TestBed.inject(SavedViewService)
mailAccountService = TestBed.inject(MailAccountService)
mailRuleService = TestBed.inject(MailRuleService)
})
function completeSetup(excludeService = null) {
if (excludeService !== userService) {
jest.spyOn(userService, 'listAll').mockReturnValue( jest.spyOn(userService, 'listAll').mockReturnValue(
of({ of({
all: users.map((u) => u.id), all: users.map((u) => u.id),
@ -123,7 +149,8 @@ describe('SettingsComponent', () => {
results: users.concat([]), results: users.concat([]),
}) })
) )
groupService = TestBed.inject(GroupService) }
if (excludeService !== groupService) {
jest.spyOn(groupService, 'listAll').mockReturnValue( jest.spyOn(groupService, 'listAll').mockReturnValue(
of({ of({
all: groups.map((g) => g.id), all: groups.map((g) => g.id),
@ -131,7 +158,8 @@ describe('SettingsComponent', () => {
results: groups.concat([]), results: groups.concat([]),
}) })
) )
savedViewService = TestBed.inject(SavedViewService) }
if (excludeService !== savedViewService) {
jest.spyOn(savedViewService, 'listAll').mockReturnValue( jest.spyOn(savedViewService, 'listAll').mockReturnValue(
of({ of({
all: savedViews.map((v) => v.id), all: savedViews.map((v) => v.id),
@ -139,7 +167,8 @@ describe('SettingsComponent', () => {
results: (savedViews as PaperlessSavedView[]).concat([]), results: (savedViews as PaperlessSavedView[]).concat([]),
}) })
) )
mailAccountService = TestBed.inject(MailAccountService) }
if (excludeService !== mailAccountService) {
jest.spyOn(mailAccountService, 'listAll').mockReturnValue( jest.spyOn(mailAccountService, 'listAll').mockReturnValue(
of({ of({
all: mailAccounts.map((a) => a.id), all: mailAccounts.map((a) => a.id),
@ -147,7 +176,8 @@ describe('SettingsComponent', () => {
results: (mailAccounts as PaperlessMailAccount[]).concat([]), results: (mailAccounts as PaperlessMailAccount[]).concat([]),
}) })
) )
mailRuleService = TestBed.inject(MailRuleService) }
if (excludeService !== mailRuleService) {
jest.spyOn(mailRuleService, 'listAll').mockReturnValue( jest.spyOn(mailRuleService, 'listAll').mockReturnValue(
of({ of({
all: mailRules.map((r) => r.id), all: mailRules.map((r) => r.id),
@ -155,13 +185,15 @@ describe('SettingsComponent', () => {
results: (mailRules as PaperlessMailRule[]).concat([]), results: (mailRules as PaperlessMailRule[]).concat([]),
}) })
) )
}
fixture = TestBed.createComponent(SettingsComponent) fixture = TestBed.createComponent(SettingsComponent)
component = fixture.componentInstance component = fixture.componentInstance
fixture.detectChanges() fixture.detectChanges()
}) }
it('should support tabbed settings & change URL, prevent navigation if dirty confirmation rejected', () => { it('should support tabbed settings & change URL, prevent navigation if dirty confirmation rejected', () => {
completeSetup()
const navigateSpy = jest.spyOn(router, 'navigate') const navigateSpy = jest.spyOn(router, 'navigate')
const tabButtons = fixture.debugElement.queryAll(By.directive(NgbNavLink)) const tabButtons = fixture.debugElement.queryAll(By.directive(NgbNavLink))
tabButtons[1].nativeElement.dispatchEvent(new MouseEvent('click')) tabButtons[1].nativeElement.dispatchEvent(new MouseEvent('click'))
@ -187,6 +219,7 @@ describe('SettingsComponent', () => {
}) })
it('should support direct link to tab by URL, scroll if needed', () => { it('should support direct link to tab by URL, scroll if needed', () => {
completeSetup()
jest jest
.spyOn(activatedRoute, 'paramMap', 'get') .spyOn(activatedRoute, 'paramMap', 'get')
.mockReturnValue(of(convertToParamMap({ section: 'mail' }))) .mockReturnValue(of(convertToParamMap({ section: 'mail' })))
@ -199,6 +232,7 @@ describe('SettingsComponent', () => {
}) })
it('should lazy load tab data', () => { it('should lazy load tab data', () => {
completeSetup()
const tabButtons = fixture.debugElement.queryAll(By.directive(NgbNavLink)) const tabButtons = fixture.debugElement.queryAll(By.directive(NgbNavLink))
expect(component.savedViews).toBeUndefined() expect(component.savedViews).toBeUndefined()
@ -221,6 +255,7 @@ describe('SettingsComponent', () => {
}) })
it('should support save saved views, show error', () => { it('should support save saved views, show error', () => {
completeSetup()
component.maybeInitializeTab(3) // SavedViews component.maybeInitializeTab(3) // SavedViews
const toastErrorSpy = jest.spyOn(toastService, 'showError') const toastErrorSpy = jest.spyOn(toastService, 'showError')
@ -248,6 +283,7 @@ describe('SettingsComponent', () => {
}) })
it('should support save local settings updating appearance settings and calling API, show error', () => { it('should support save local settings updating appearance settings and calling API, show error', () => {
completeSetup()
const toastErrorSpy = jest.spyOn(toastService, 'showError') const toastErrorSpy = jest.spyOn(toastService, 'showError')
const toastSpy = jest.spyOn(toastService, 'show') const toastSpy = jest.spyOn(toastService, 'show')
const storeSpy = jest.spyOn(settingsService, 'storeSettings') const storeSpy = jest.spyOn(settingsService, 'storeSettings')
@ -275,6 +311,7 @@ describe('SettingsComponent', () => {
}) })
it('should offer reload if settings changes require', () => { it('should offer reload if settings changes require', () => {
completeSetup()
let toast: Toast let toast: Toast
toastService.getToasts().subscribe((t) => (toast = t[0])) toastService.getToasts().subscribe((t) => (toast = t[0]))
component.initialize(true) // reset component.initialize(true) // reset
@ -288,6 +325,7 @@ describe('SettingsComponent', () => {
}) })
it('should allow setting theme color, visually apply change immediately but not save', () => { it('should allow setting theme color, visually apply change immediately but not save', () => {
completeSetup()
const appearanceSpy = jest.spyOn( const appearanceSpy = jest.spyOn(
settingsService, settingsService,
'updateAppearanceSettings' 'updateAppearanceSettings'
@ -304,6 +342,7 @@ describe('SettingsComponent', () => {
}) })
it('should support delete saved view', () => { it('should support delete saved view', () => {
completeSetup()
component.maybeInitializeTab(3) // SavedViews component.maybeInitializeTab(3) // SavedViews
const toastSpy = jest.spyOn(toastService, 'showInfo') const toastSpy = jest.spyOn(toastService, 'showInfo')
const deleteSpy = jest.spyOn(savedViewService, 'delete') const deleteSpy = jest.spyOn(savedViewService, 'delete')
@ -316,6 +355,7 @@ describe('SettingsComponent', () => {
}) })
it('should support edit / create user, show error if needed', () => { it('should support edit / create user, show error if needed', () => {
completeSetup()
let modal: NgbModalRef let modal: NgbModalRef
modalService.activeInstances.subscribe((refs) => (modal = refs[0])) modalService.activeInstances.subscribe((refs) => (modal = refs[0]))
component.editUser(users[0]) component.editUser(users[0])
@ -332,6 +372,7 @@ describe('SettingsComponent', () => {
}) })
it('should support delete user, show error if needed', () => { it('should support delete user, show error if needed', () => {
completeSetup()
let modal: NgbModalRef let modal: NgbModalRef
modalService.activeInstances.subscribe((refs) => (modal = refs[0])) modalService.activeInstances.subscribe((refs) => (modal = refs[0]))
component.deleteUser(users[0]) component.deleteUser(users[0])
@ -352,6 +393,7 @@ describe('SettingsComponent', () => {
}) })
it('should logout current user if password changed, after delay', fakeAsync(() => { it('should logout current user if password changed, after delay', fakeAsync(() => {
completeSetup()
let modal: NgbModalRef let modal: NgbModalRef
modalService.activeInstances.subscribe((refs) => (modal = refs[0])) modalService.activeInstances.subscribe((refs) => (modal = refs[0]))
component.editUser(users[0]) component.editUser(users[0])
@ -371,6 +413,7 @@ describe('SettingsComponent', () => {
})) }))
it('should support edit / create group, show error if needed', () => { it('should support edit / create group, show error if needed', () => {
completeSetup()
let modal: NgbModalRef let modal: NgbModalRef
modalService.activeInstances.subscribe((refs) => (modal = refs[0])) modalService.activeInstances.subscribe((refs) => (modal = refs[0]))
component.editGroup(groups[0]) component.editGroup(groups[0])
@ -386,6 +429,7 @@ describe('SettingsComponent', () => {
}) })
it('should support delete group, show error if needed', () => { it('should support delete group, show error if needed', () => {
completeSetup()
let modal: NgbModalRef let modal: NgbModalRef
modalService.activeInstances.subscribe((refs) => (modal = refs[0])) modalService.activeInstances.subscribe((refs) => (modal = refs[0]))
component.deleteGroup(users[0]) component.deleteGroup(users[0])
@ -406,12 +450,71 @@ describe('SettingsComponent', () => {
}) })
it('should get group name', () => { it('should get group name', () => {
completeSetup()
component.maybeInitializeTab(5) // UsersGroups component.maybeInitializeTab(5) // UsersGroups
expect(component.getGroupName(1)).toEqual(groups[0].name) expect(component.getGroupName(1)).toEqual(groups[0].name)
expect(component.getGroupName(11)).toEqual('') expect(component.getGroupName(11)).toEqual('')
}) })
it('should show errors on load if load mailAccounts failure', () => {
const toastErrorSpy = jest.spyOn(toastService, 'showError')
jest
.spyOn(mailAccountService, 'listAll')
.mockImplementation(() =>
throwError(() => new Error('failed to load mail accounts'))
)
completeSetup(mailAccountService)
const tabButtons = fixture.debugElement.queryAll(By.directive(NgbNavLink))
tabButtons[3].nativeElement.dispatchEvent(new MouseEvent('click')) // mail tab
fixture.detectChanges()
expect(toastErrorSpy).toBeCalled()
})
it('should show errors on load if load mailRules failure', () => {
const toastErrorSpy = jest.spyOn(toastService, 'showError')
jest
.spyOn(mailRuleService, 'listAll')
.mockImplementation(() =>
throwError(() => new Error('failed to load mail rules'))
)
completeSetup(mailRuleService)
const tabButtons = fixture.debugElement.queryAll(By.directive(NgbNavLink))
tabButtons[3].nativeElement.dispatchEvent(new MouseEvent('click')) // mail tab
fixture.detectChanges()
// tabButtons[4].nativeElement.dispatchEvent(new MouseEvent('click'))
expect(toastErrorSpy).toBeCalled()
})
it('should show errors on load if load users failure', () => {
const toastErrorSpy = jest.spyOn(toastService, 'showError')
jest
.spyOn(userService, 'listAll')
.mockImplementation(() =>
throwError(() => new Error('failed to load users'))
)
completeSetup(userService)
const tabButtons = fixture.debugElement.queryAll(By.directive(NgbNavLink))
tabButtons[4].nativeElement.dispatchEvent(new MouseEvent('click')) // users tab
fixture.detectChanges()
expect(toastErrorSpy).toBeCalled()
})
it('should show errors on load if load groups failure', () => {
const toastErrorSpy = jest.spyOn(toastService, 'showError')
jest
.spyOn(groupService, 'listAll')
.mockImplementation(() =>
throwError(() => new Error('failed to load groups'))
)
completeSetup(groupService)
const tabButtons = fixture.debugElement.queryAll(By.directive(NgbNavLink))
tabButtons[4].nativeElement.dispatchEvent(new MouseEvent('click')) // users tab
fixture.detectChanges()
expect(toastErrorSpy).toBeCalled()
})
it('should support edit / create mail account, show error if needed', () => { it('should support edit / create mail account, show error if needed', () => {
completeSetup()
let modal: NgbModalRef let modal: NgbModalRef
modalService.activeInstances.subscribe((refs) => (modal = refs[0])) modalService.activeInstances.subscribe((refs) => (modal = refs[0]))
component.editMailAccount(mailAccounts[0] as PaperlessMailAccount) component.editMailAccount(mailAccounts[0] as PaperlessMailAccount)
@ -427,6 +530,7 @@ describe('SettingsComponent', () => {
}) })
it('should support delete mail account, show error if needed', () => { it('should support delete mail account, show error if needed', () => {
completeSetup()
let modal: NgbModalRef let modal: NgbModalRef
modalService.activeInstances.subscribe((refs) => (modal = refs[0])) modalService.activeInstances.subscribe((refs) => (modal = refs[0]))
component.deleteMailAccount(mailAccounts[0] as PaperlessMailAccount) component.deleteMailAccount(mailAccounts[0] as PaperlessMailAccount)
@ -447,6 +551,7 @@ describe('SettingsComponent', () => {
}) })
it('should support edit / create mail rule, show error if needed', () => { it('should support edit / create mail rule, show error if needed', () => {
completeSetup()
let modal: NgbModalRef let modal: NgbModalRef
modalService.activeInstances.subscribe((refs) => (modal = refs[0])) modalService.activeInstances.subscribe((refs) => (modal = refs[0]))
component.editMailRule(mailRules[0] as PaperlessMailRule) component.editMailRule(mailRules[0] as PaperlessMailRule)
@ -462,6 +567,7 @@ describe('SettingsComponent', () => {
}) })
it('should support delete mail rule, show error if needed', () => { it('should support delete mail rule, show error if needed', () => {
completeSetup()
let modal: NgbModalRef let modal: NgbModalRef
modalService.activeInstances.subscribe((refs) => (modal = refs[0])) modalService.activeInstances.subscribe((refs) => (modal = refs[0]))
component.deleteMailRule(mailRules[0] as PaperlessMailRule) component.deleteMailRule(mailRules[0] as PaperlessMailRule)

View File

@ -146,7 +146,7 @@ export class SettingsComponent
private groupsService: GroupService, private groupsService: GroupService,
private router: Router, private router: Router,
private modalService: NgbModal, private modalService: NgbModal,
private permissionsService: PermissionsService public permissionsService: PermissionsService
) { ) {
super() super()
this.settings.settingsSaved.subscribe(() => { this.settings.settingsSaved.subscribe(() => {
@ -259,24 +259,72 @@ export class SettingsComponent
navID == SettingsNavIDs.UsersGroups && navID == SettingsNavIDs.UsersGroups &&
(!this.users || !this.groups) (!this.users || !this.groups)
) { ) {
this.usersService.listAll().subscribe((r) => { this.usersService
.listAll()
.pipe(first())
.subscribe({
next: (r) => {
this.users = r.results this.users = r.results
this.groupsService.listAll().subscribe((r) => { this.groupsService
.listAll()
.pipe(first())
.subscribe({
next: (r) => {
this.groups = r.results this.groups = r.results
this.initialize(false) this.initialize(false)
},
error: (e) => {
this.toastService.showError(
$localize`Error retrieving groups`,
10000,
JSON.stringify(e)
)
},
}) })
},
error: (e) => {
this.toastService.showError(
$localize`Error retrieving users`,
10000,
JSON.stringify(e)
)
},
}) })
} else if ( } else if (
navID == SettingsNavIDs.Mail && navID == SettingsNavIDs.Mail &&
(!this.mailAccounts || !this.mailRules) (!this.mailAccounts || !this.mailRules)
) { ) {
this.mailAccountService.listAll().subscribe((r) => { this.mailAccountService
.listAll()
.pipe(first())
.subscribe({
next: (r) => {
this.mailAccounts = r.results this.mailAccounts = r.results
this.mailRuleService.listAll().subscribe((r) => { this.mailRuleService
.listAll()
.pipe(first())
.subscribe({
next: (r) => {
this.mailRules = r.results this.mailRules = r.results
this.initialize(false) this.initialize(false)
},
error: (e) => {
this.toastService.showError(
$localize`Error retrieving mail rules`,
10000,
JSON.stringify(e)
)
},
}) })
},
error: (e) => {
this.toastService.showError(
$localize`Error retrieving mail accounts`,
10000,
JSON.stringify(e)
)
},
}) })
} }
} }

View File

@ -1,5 +1,5 @@
import { Injectable } from '@angular/core' import { Injectable } from '@angular/core'
import { Subject, zip } from 'rxjs' import { Subject } from 'rxjs'
export interface Toast { export interface Toast {
title: string title: string

View File

@ -5,7 +5,7 @@ export const environment = {
apiBaseUrl: document.baseURI + 'api/', apiBaseUrl: document.baseURI + 'api/',
apiVersion: '3', apiVersion: '3',
appTitle: 'Paperless-ngx', appTitle: 'Paperless-ngx',
version: '1.17.1', version: '1.17.1-dev',
webSocketHost: window.location.host, webSocketHost: window.location.host,
webSocketProtocol: window.location.protocol == 'https:' ? 'wss:' : 'ws:', webSocketProtocol: window.location.protocol == 'https:' ? 'wss:' : 'ws:',
webSocketBaseUrl: base_url.pathname + 'ws/', webSocketBaseUrl: base_url.pathname + 'ws/',

View File

@ -80,6 +80,9 @@ $form-check-radio-checked-bg-image-dark: url("data:image/svg+xml,<svg xmlns='htt
.btn { .btn {
--bs-btn-disabled-opacity: 0.35; --bs-btn-disabled-opacity: 0.35;
} }
.btn.btn-link {
--bs-btn-disabled-opacity: 0.85;
}
.btn-primary { .btn-primary {
&:hover, &:focus, &.active, &:active { &:hover, &:focus, &.active, &:active {

View File

@ -3453,6 +3453,110 @@ class TestBulkEdit(DirectoriesMixin, APITestCase):
self.assertCountEqual(args[0], [self.doc2.id, self.doc3.id]) self.assertCountEqual(args[0], [self.doc2.id, self.doc3.id])
self.assertEqual(len(kwargs["set_permissions"]["view"]["users"]), 2) self.assertEqual(len(kwargs["set_permissions"]["view"]["users"]), 2)
@mock.patch("documents.serialisers.bulk_edit.set_permissions")
def test_insufficient_permissions_ownership(self, m):
"""
GIVEN:
- Documents owned by user other than logged in user
WHEN:
- set_permissions bulk edit API endpoint is called
THEN:
- User is not able to change permissions
"""
m.return_value = "OK"
self.doc1.owner = User.objects.get(username="temp_admin")
self.doc1.save()
user1 = User.objects.create(username="user1")
self.client.force_authenticate(user=user1)
permissions = {
"owner": user1.id,
}
response = self.client.post(
"/api/documents/bulk_edit/",
json.dumps(
{
"documents": [self.doc1.id, self.doc2.id, self.doc3.id],
"method": "set_permissions",
"parameters": {"set_permissions": permissions},
},
),
content_type="application/json",
)
self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN)
m.assert_not_called()
self.assertEqual(response.content, b"Insufficient permissions")
response = self.client.post(
"/api/documents/bulk_edit/",
json.dumps(
{
"documents": [self.doc2.id, self.doc3.id],
"method": "set_permissions",
"parameters": {"set_permissions": permissions},
},
),
content_type="application/json",
)
self.assertEqual(response.status_code, status.HTTP_200_OK)
m.assert_called_once()
@mock.patch("documents.serialisers.bulk_edit.set_storage_path")
def test_insufficient_permissions_edit(self, m):
"""
GIVEN:
- Documents for which current user only has view permissions
WHEN:
- API is called
THEN:
- set_storage_path is only called if user can edit all docs
"""
m.return_value = "OK"
self.doc1.owner = User.objects.get(username="temp_admin")
self.doc1.save()
user1 = User.objects.create(username="user1")
assign_perm("view_document", user1, self.doc1)
self.client.force_authenticate(user=user1)
response = self.client.post(
"/api/documents/bulk_edit/",
json.dumps(
{
"documents": [self.doc1.id, self.doc2.id, self.doc3.id],
"method": "set_storage_path",
"parameters": {"storage_path": self.sp1.id},
},
),
content_type="application/json",
)
self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN)
m.assert_not_called()
self.assertEqual(response.content, b"Insufficient permissions")
assign_perm("change_document", user1, self.doc1)
response = self.client.post(
"/api/documents/bulk_edit/",
json.dumps(
{
"documents": [self.doc1.id, self.doc2.id, self.doc3.id],
"method": "set_storage_path",
"parameters": {"storage_path": self.sp1.id},
},
),
content_type="application/json",
)
self.assertEqual(response.status_code, status.HTTP_200_OK)
m.assert_called_once()
class TestBulkDownload(DirectoriesMixin, APITestCase): class TestBulkDownload(DirectoriesMixin, APITestCase):
ENDPOINT = "/api/documents/bulk_download/" ENDPOINT = "/api/documents/bulk_download/"

View File

@ -54,6 +54,7 @@ from rest_framework.viewsets import ModelViewSet
from rest_framework.viewsets import ReadOnlyModelViewSet from rest_framework.viewsets import ReadOnlyModelViewSet
from rest_framework.viewsets import ViewSet from rest_framework.viewsets import ViewSet
from documents import bulk_edit
from documents.filters import ObjectOwnedOrGrantedPermissionsFilter from documents.filters import ObjectOwnedOrGrantedPermissionsFilter
from documents.permissions import PaperlessAdminPermissions from documents.permissions import PaperlessAdminPermissions
from documents.permissions import PaperlessObjectPermissions from documents.permissions import PaperlessObjectPermissions
@ -694,7 +695,7 @@ class SavedViewViewSet(ModelViewSet, PassUserMixin):
serializer.save(owner=self.request.user) serializer.save(owner=self.request.user)
class BulkEditView(GenericAPIView): class BulkEditView(GenericAPIView, PassUserMixin):
permission_classes = (IsAuthenticated,) permission_classes = (IsAuthenticated,)
serializer_class = BulkEditSerializer serializer_class = BulkEditSerializer
parser_classes = (parsers.JSONParser,) parser_classes = (parsers.JSONParser,)
@ -703,10 +704,25 @@ class BulkEditView(GenericAPIView):
serializer = self.get_serializer(data=request.data) serializer = self.get_serializer(data=request.data)
serializer.is_valid(raise_exception=True) serializer.is_valid(raise_exception=True)
user = self.request.user
method = serializer.validated_data.get("method") method = serializer.validated_data.get("method")
parameters = serializer.validated_data.get("parameters") parameters = serializer.validated_data.get("parameters")
documents = serializer.validated_data.get("documents") documents = serializer.validated_data.get("documents")
if not user.is_superuser:
document_objs = Document.objects.filter(pk__in=documents)
has_perms = (
all((doc.owner == user or doc.owner is None) for doc in document_objs)
if method == bulk_edit.set_permissions
else all(
has_perms_owner_aware(user, "change_document", doc)
for doc in document_objs
)
)
if not has_perms:
return HttpResponseForbidden("Insufficient permissions")
try: try:
# TODO: parameter validation # TODO: parameter validation
result = method(documents, **parameters) result = method(documents, **parameters)

View File

@ -177,6 +177,23 @@ def settings_values_check(app_configs, **kwargs):
) )
return msgs return msgs
return ( def _email_certificate_validate():
_ocrmypdf_settings_check() + _timezone_validate() + _barcode_scanner_validate() msgs = []
# Existence checks
if (
settings.EMAIL_CERTIFICATE_FILE is not None
and not settings.EMAIL_CERTIFICATE_FILE.is_file()
):
msgs.append(
Error(
f"Email cert {settings.EMAIL_CERTIFICATE_FILE} is not a file",
),
)
return msgs
return (
_ocrmypdf_settings_check()
+ _timezone_validate()
+ _barcode_scanner_validate()
+ _email_certificate_validate()
) )

View File

@ -67,11 +67,20 @@ def __get_float(key: str, default: float) -> float:
return float(os.getenv(key, default)) return float(os.getenv(key, default))
def __get_path(key: str, default: Union[PathLike, str]) -> Path: def __get_path(
key: str,
default: Optional[Union[PathLike, str]] = None,
) -> Optional[Path]:
""" """
Return a normalized, absolute path based on the environment variable or a default Return a normalized, absolute path based on the environment variable or a default,
if provided. If not set and no default, returns None
""" """
return Path(os.environ.get(key, default)).resolve() if key in os.environ:
return Path(os.environ[key]).resolve()
elif default is not None:
return Path(default).resolve()
else:
return None
def __get_list( def __get_list(
@ -364,6 +373,7 @@ CHANNEL_LAYERS = {
"hosts": [_CHANNELS_REDIS_URL], "hosts": [_CHANNELS_REDIS_URL],
"capacity": 2000, # default 100 "capacity": 2000, # default 100
"expiry": 15, # default 60 "expiry": 15, # default 60
"prefix": os.getenv("PAPERLESS_REDIS_PREFIX", ""),
}, },
}, },
} }
@ -476,6 +486,8 @@ CSRF_COOKIE_NAME = f"{COOKIE_PREFIX}csrftoken"
SESSION_COOKIE_NAME = f"{COOKIE_PREFIX}sessionid" SESSION_COOKIE_NAME = f"{COOKIE_PREFIX}sessionid"
LANGUAGE_COOKIE_NAME = f"{COOKIE_PREFIX}django_language" LANGUAGE_COOKIE_NAME = f"{COOKIE_PREFIX}django_language"
EMAIL_CERTIFICATE_FILE = __get_path("PAPERLESS_EMAIL_CERTIFICATE_FILE")
############################################################################### ###############################################################################
# Database # # Database #
@ -679,6 +691,9 @@ CELERY_TASK_SEND_SENT_EVENT = True
CELERY_SEND_TASK_SENT_EVENT = True CELERY_SEND_TASK_SENT_EVENT = True
CELERY_BROKER_CONNECTION_RETRY = True CELERY_BROKER_CONNECTION_RETRY = True
CELERY_BROKER_CONNECTION_RETRY_ON_STARTUP = True CELERY_BROKER_CONNECTION_RETRY_ON_STARTUP = True
CELERY_BROKER_TRANSPORT_OPTIONS = {
"global_keyprefix": os.getenv("PAPERLESS_REDIS_PREFIX", ""),
}
CELERY_TASK_TRACK_STARTED = True CELERY_TASK_TRACK_STARTED = True
CELERY_TASK_TIME_LIMIT: Final[int] = __get_int("PAPERLESS_WORKER_TIMEOUT", 1800) CELERY_TASK_TIME_LIMIT: Final[int] = __get_int("PAPERLESS_WORKER_TIMEOUT", 1800)

View File

@ -1,9 +1,11 @@
import os import os
from pathlib import Path
from django.test import TestCase from django.test import TestCase
from django.test import override_settings from django.test import override_settings
from documents.tests.utils import DirectoriesMixin from documents.tests.utils import DirectoriesMixin
from documents.tests.utils import FileSystemAssertsMixin
from paperless.checks import binaries_check from paperless.checks import binaries_check
from paperless.checks import debug_mode_check from paperless.checks import debug_mode_check
from paperless.checks import paths_check from paperless.checks import paths_check
@ -57,7 +59,7 @@ class TestChecks(DirectoriesMixin, TestCase):
self.assertEqual(len(debug_mode_check(None)), 1) self.assertEqual(len(debug_mode_check(None)), 1)
class TestSettingsChecks(DirectoriesMixin, TestCase): class TestSettingsChecksAgainstDefaults(DirectoriesMixin, TestCase):
def test_all_valid(self): def test_all_valid(self):
""" """
GIVEN: GIVEN:
@ -70,6 +72,8 @@ class TestSettingsChecks(DirectoriesMixin, TestCase):
msgs = settings_values_check(None) msgs = settings_values_check(None)
self.assertEqual(len(msgs), 0) self.assertEqual(len(msgs), 0)
class TestOcrSettingsChecks(DirectoriesMixin, TestCase):
@override_settings(OCR_OUTPUT_TYPE="notapdf") @override_settings(OCR_OUTPUT_TYPE="notapdf")
def test_invalid_output_type(self): def test_invalid_output_type(self):
""" """
@ -160,6 +164,8 @@ class TestSettingsChecks(DirectoriesMixin, TestCase):
self.assertIn('OCR clean mode "cleanme"', msg.msg) self.assertIn('OCR clean mode "cleanme"', msg.msg)
class TestTimezoneSettingsChecks(DirectoriesMixin, TestCase):
@override_settings(TIME_ZONE="TheMoon\\MyCrater") @override_settings(TIME_ZONE="TheMoon\\MyCrater")
def test_invalid_timezone(self): def test_invalid_timezone(self):
""" """
@ -178,6 +184,8 @@ class TestSettingsChecks(DirectoriesMixin, TestCase):
self.assertIn('Timezone "TheMoon\\MyCrater"', msg.msg) self.assertIn('Timezone "TheMoon\\MyCrater"', msg.msg)
class TestBarcodeSettingsChecks(DirectoriesMixin, TestCase):
@override_settings(CONSUMER_BARCODE_SCANNER="Invalid") @override_settings(CONSUMER_BARCODE_SCANNER="Invalid")
def test_barcode_scanner_invalid(self): def test_barcode_scanner_invalid(self):
msgs = settings_values_check(None) msgs = settings_values_check(None)
@ -200,3 +208,26 @@ class TestSettingsChecks(DirectoriesMixin, TestCase):
def test_barcode_scanner_valid(self): def test_barcode_scanner_valid(self):
msgs = settings_values_check(None) msgs = settings_values_check(None)
self.assertEqual(len(msgs), 0) self.assertEqual(len(msgs), 0)
class TestEmailCertSettingsChecks(DirectoriesMixin, FileSystemAssertsMixin, TestCase):
@override_settings(EMAIL_CERTIFICATE_FILE=Path("/tmp/not_actually_here.pem"))
def test_not_valid_file(self):
"""
GIVEN:
- Default settings
- Email certificate is set
WHEN:
- Email certificate file doesn't exist
THEN:
- system check error reported for email certificate
"""
self.assertIsNotFile("/tmp/not_actually_here.pem")
msgs = settings_values_check(None)
self.assertEqual(len(msgs), 1)
msg = msgs[0]
self.assertIn("Email cert /tmp/not_actually_here.pem is not a file", msg.msg)

View File

@ -395,12 +395,16 @@ def get_mailbox(server, port, security) -> MailBox:
""" """
Returns the correct MailBox instance for the given configuration. Returns the correct MailBox instance for the given configuration.
""" """
ssl_context = ssl.create_default_context()
if settings.EMAIL_CERTIFICATE_FILE is not None: # pragma: nocover
ssl_context.load_verify_locations(cafile=settings.EMAIL_CERTIFICATE_FILE)
if security == MailAccount.ImapSecurity.NONE: if security == MailAccount.ImapSecurity.NONE:
mailbox = MailBoxUnencrypted(server, port) mailbox = MailBoxUnencrypted(server, port)
elif security == MailAccount.ImapSecurity.STARTTLS: elif security == MailAccount.ImapSecurity.STARTTLS:
mailbox = MailBoxTls(server, port, ssl_context=ssl.create_default_context()) mailbox = MailBoxTls(server, port, ssl_context=ssl_context)
elif security == MailAccount.ImapSecurity.SSL: elif security == MailAccount.ImapSecurity.SSL:
mailbox = MailBox(server, port, ssl_context=ssl.create_default_context()) mailbox = MailBox(server, port, ssl_context=ssl_context)
else: else:
raise NotImplementedError("Unknown IMAP security") # pragma: nocover raise NotImplementedError("Unknown IMAP security") # pragma: nocover
return mailbox return mailbox

View File

@ -215,7 +215,11 @@ class MailDocumentParser(DocumentParser):
file_multi_part[2], file_multi_part[2],
) )
response = httpx.post(url_merge, files=pdf_collection, timeout=30.0) response = httpx.post(
url_merge,
files=pdf_collection,
timeout=settings.CELERY_TASK_TIME_LIMIT,
)
response.raise_for_status() # ensure we notice bad responses response.raise_for_status() # ensure we notice bad responses
archive_path.write_bytes(response.content) archive_path.write_bytes(response.content)
@ -330,7 +334,7 @@ class MailDocumentParser(DocumentParser):
files=files, files=files,
headers=headers, headers=headers,
data=data, data=data,
timeout=30.0, timeout=settings.CELERY_TASK_TIME_LIMIT,
) )
response.raise_for_status() # ensure we notice bad responses response.raise_for_status() # ensure we notice bad responses
except Exception as err: except Exception as err:
@ -409,7 +413,12 @@ class MailDocumentParser(DocumentParser):
file_multi_part[2], file_multi_part[2],
) )
response = httpx.post(url, files=files, data=data, timeout=30.0) response = httpx.post(
url,
files=files,
data=data,
timeout=settings.CELERY_TASK_TIME_LIMIT,
)
response.raise_for_status() # ensure we notice bad responses response.raise_for_status() # ensure we notice bad responses
except Exception as err: except Exception as err:
raise ParseError(f"Error while converting document to PDF: {err}") from err raise ParseError(f"Error while converting document to PDF: {err}") from err

View File

@ -100,7 +100,7 @@ class TikaDocumentParser(DocumentParser):
files=files, files=files,
headers=headers, headers=headers,
data=data, data=data,
timeout=30.0, timeout=settings.CELERY_TASK_TIME_LIMIT,
) )
response.raise_for_status() # ensure we notice bad responses response.raise_for_status() # ensure we notice bad responses
except Exception as err: except Exception as err: