Merge branch 'dev' into feature-improve-paperless-task

This commit is contained in:
shamoon 2025-02-26 14:03:14 -08:00
commit 55fcb60f8e
No known key found for this signature in database
49 changed files with 1037 additions and 262 deletions

View File

@ -123,13 +123,13 @@ RUN set -eux \
WORKDIR /usr/src/paperless/src/docker/
COPY [ \
"docker/imagemagick-policy.xml", \
"docker/rootfs/etc/ImageMagick-6/paperless-policy.xml", \
"./" \
]
RUN set -eux \
&& echo "Configuring ImageMagick" \
&& mv imagemagick-policy.xml /etc/ImageMagick-6/policy.xml
&& mv paperless-policy.xml /etc/ImageMagick-6/policy.xml
# Packages needed only for building a few quick Python
# dependencies

View File

@ -65,7 +65,7 @@ services:
command: /bin/sh -c "chown -R paperless:paperless /usr/src/paperless/paperless-ngx/src/documents/static/frontend && chown -R paperless:paperless /usr/src/paperless/paperless-ngx/.ruff_cache && while sleep 1000; do :; done"
gotenberg:
image: docker.io/gotenberg/gotenberg:7.10
image: docker.io/gotenberg/gotenberg:8.17
restart: unless-stopped
# The Gotenberg Chromium route is used to convert .eml files. We do not

View File

@ -26,7 +26,7 @@ extend-select = [
"T20", # https://docs.astral.sh/ruff/rules/#flake8-print-t20
"SIM", # https://docs.astral.sh/ruff/rules/#flake8-simplify-sim
"TID", # https://docs.astral.sh/ruff/rules/#flake8-tidy-imports-tid
"TCH", # https://docs.astral.sh/ruff/rules/#flake8-type-checking-tch
"TC", # https://docs.astral.sh/ruff/rules/#flake8-type-checking-tc
"PLC", # https://docs.astral.sh/ruff/rules/#pylint-pl
"PLE", # https://docs.astral.sh/ruff/rules/#pylint-pl
"RUF", # https://docs.astral.sh/ruff/rules/#ruff-specific-rules-ruf

View File

@ -5,7 +5,7 @@
services:
gotenberg:
image: docker.io/gotenberg/gotenberg:8.7
image: docker.io/gotenberg/gotenberg:8.17
hostname: gotenberg
container_name: gotenberg
network_mode: host

View File

@ -77,7 +77,7 @@ services:
PAPERLESS_TIKA_ENDPOINT: http://tika:9998
gotenberg:
image: docker.io/gotenberg/gotenberg:8.7
image: docker.io/gotenberg/gotenberg:8.17
restart: unless-stopped
# The gotenberg chromium route is used to convert .eml files. We do not
# want to allow external content like tracking pixels or even javascript.

View File

@ -71,7 +71,7 @@ services:
PAPERLESS_TIKA_ENDPOINT: http://tika:9998
gotenberg:
image: docker.io/gotenberg/gotenberg:8.7
image: docker.io/gotenberg/gotenberg:8.17
restart: unless-stopped
# The gotenberg chromium route is used to convert .eml files. We do not

View File

@ -59,7 +59,7 @@ services:
PAPERLESS_TIKA_ENDPOINT: http://tika:9998
gotenberg:
image: docker.io/gotenberg/gotenberg:8.7
image: docker.io/gotenberg/gotenberg:8.17
restart: unless-stopped
# The gotenberg chromium route is used to convert .eml files. We do not

View File

@ -1,10 +1,18 @@
#!/command/with-contenv /usr/bin/bash
# shellcheck shell=bash
cd ${PAPERLESS_SRC_DIR}
if [[ -n "${USER_IS_NON_ROOT}" ]]; then
exec python3 manage.py document_consumer
if [[ -n "${PAPERLESS_CONSUMER_DISABLE}" ]]; then
echo "[svc-consumer] Consumer is disabled, exiting"
# https://skarnet.org/software/s6/s6-svc.html
s6-svc -Od .
else
exec s6-setuidgid paperless python3 manage.py document_consumer
cd ${PAPERLESS_SRC_DIR}
if [[ -n "${USER_IS_NON_ROOT}" ]]; then
exec python3 manage.py document_consumer
else
exec s6-setuidgid paperless python3 manage.py document_consumer
fi
fi

View File

@ -557,6 +557,20 @@ This is for use with self-signed certificates against local IMAP servers.
Settings this value has security implications for the security of your email.
Understand what it does and be sure you need to before setting.
### Authentication & SSO {#authentication}
#### [`PAPERLESS_ACCOUNT_ALLOW_SIGNUPS=<bool>`](#PAPERLESS_ACCOUNT_ALLOW_SIGNUPS) {#PAPERLESS_ACCOUNT_ALLOW_SIGNUPS}
: Allow users to signup for a new Paperless-ngx account.
Defaults to False
#### [`PAPERLESS_ACCOUNT_DEFAULT_GROUPS=<comma-separated-list>`](#PAPERLESS_ACCOUNT_DEFAULT_GROUPS) {#PAPERLESS_ACCOUNT_DEFAULT_GROUPS}
: A list of group names that users will be added to when they sign up for a new account. Groups listed here must already exist.
Defaults to None
#### [`PAPERLESS_SOCIALACCOUNT_PROVIDERS=<json>`](#PAPERLESS_SOCIALACCOUNT_PROVIDERS) {#PAPERLESS_SOCIALACCOUNT_PROVIDERS}
: This variable is used to setup login and signup via social account providers which are compatible with django-allauth.
@ -580,12 +594,25 @@ system. See the corresponding
Defaults to True
#### [`PAPERLESS_ACCOUNT_ALLOW_SIGNUPS=<bool>`](#PAPERLESS_ACCOUNT_ALLOW_SIGNUPS) {#PAPERLESS_ACCOUNT_ALLOW_SIGNUPS}
#### [`PAPERLESS_SOCIAL_ACCOUNT_SYNC_GROUPS=<bool>`](#PAPERLESS_SOCIAL_ACCOUNT_SYNC_GROUPS) {#PAPERLESS_SOCIAL_ACCOUNT_SYNC_GROUPS}
: Allow users to signup for a new Paperless-ngx account.
: Sync groups from the third party authentication system (e.g. OIDC) to Paperless-ngx. When enabled, users will be added or removed from groups based on their group membership in the third party authentication system. Groups must already exist in Paperless-ngx and have the same name as in the third party authentication system. Groups are updated upon logging in via the third party authentication system, see the corresponding [django-allauth documentation](https://docs.allauth.org/en/dev/socialaccount/signals.html).
: In order to pass groups from the authentication system you will need to update your [PAPERLESS_SOCIALACCOUNT_PROVIDERS](#PAPERLESS_SOCIALACCOUNT_PROVIDERS) setting by adding a top-level "SCOPES" setting which includes "groups", e.g.:
```json
{"openid_connect":{"SCOPE": ["openid","profile","email","groups"]...
```
Defaults to False
#### [`PAPERLESS_SOCIAL_ACCOUNT_DEFAULT_GROUPS=<comma-separated-list>`](#PAPERLESS_SOCIAL_ACCOUNT_DEFAULT_GROUPS) {#PAPERLESS_SOCIAL_ACCOUNT_DEFAULT_GROUPS}
: A list of group names that users who signup via social accounts will be added to upon signup. Groups listed here must already exist.
If both the [PAPERLESS_ACCOUNT_DEFAULT_GROUPS](#PAPERLESS_ACCOUNT_DEFAULT_GROUPS) setting and this setting are used, the user will be added to both sets of groups.
Defaults to None
#### [`PAPERLESS_ACCOUNT_DEFAULT_HTTP_PROTOCOL=<string>`](#PAPERLESS_ACCOUNT_DEFAULT_HTTP_PROTOCOL) {#PAPERLESS_ACCOUNT_DEFAULT_HTTP_PROTOCOL}
: The protocol used when generating URLs, e.g. login callback URLs. See the corresponding
@ -1030,6 +1057,11 @@ be used with caution!
## Document Consumption {#consume_config}
#### [`PAPERLESS_CONSUMER_DISABLE=<bool>`](#PAPERLESS_CONSUMER_DISABLE) {#PAPERLESS_CONSUMER_DISABLE}
: Completely disable the directory-based consumer in docker. If you don't plan to consume documents
via the consumption directory, you can disable the consumer to save resources.
#### [`PAPERLESS_CONSUMER_DELETE_DUPLICATES=<bool>`](#PAPERLESS_CONSUMER_DELETE_DUPLICATES) {#PAPERLESS_CONSUMER_DELETE_DUPLICATES}
: When the consumer detects a duplicate document, it will not touch

View File

@ -714,6 +714,8 @@ the Pi and configuring some options in paperless can help improve
performance immensely:
- Stick with SQLite to save some resources.
- If you do not need the filesystem-based consumer, consider disabling it
entirely by setting [`PAPERLESS_CONSUMER_DISABLE`](configuration.md#PAPERLESS_CONSUMER_DISABLE) to `true`.
- Consider setting [`PAPERLESS_OCR_PAGES`](configuration.md#PAPERLESS_OCR_PAGES) to 1, so that paperless will
only OCR the first page of your documents. In most cases, this page
contains enough information to be able to find it.

View File

@ -385,7 +385,7 @@
</context-group>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.html</context>
<context context-type="linenumber">100</context>
<context context-type="linenumber">117</context>
</context-group>
</trans-unit>
<trans-unit id="1241348629231510663" datatype="html">
@ -534,7 +534,7 @@
</context-group>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.html</context>
<context context-type="linenumber">353</context>
<context context-type="linenumber">370</context>
</context-group>
</trans-unit>
<trans-unit id="3768927257183755959" datatype="html">
@ -593,7 +593,7 @@
</context-group>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.html</context>
<context context-type="linenumber">346</context>
<context context-type="linenumber">363</context>
</context-group>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-list/bulk-editor/custom-fields-bulk-edit-dialog/custom-fields-bulk-edit-dialog.component.html</context>
@ -739,7 +739,7 @@
</context-group>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.html</context>
<context context-type="linenumber">366</context>
<context context-type="linenumber">383</context>
</context-group>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-list/document-list.component.html</context>
@ -1190,7 +1190,7 @@
</context-group>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.html</context>
<context context-type="linenumber">322</context>
<context context-type="linenumber">339</context>
</context-group>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.html</context>
@ -2077,8 +2077,8 @@
<context context-type="linenumber">19</context>
</context-group>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/common/share-links-dropdown/share-links-dropdown.component.html</context>
<context context-type="linenumber">37</context>
<context context-type="sourcefile">src/app/components/common/share-links-dialog/share-links-dialog.component.html</context>
<context context-type="linenumber">36</context>
</context-group>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.html</context>
@ -3391,7 +3391,7 @@
</context-group>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.html</context>
<context context-type="linenumber">94</context>
<context context-type="linenumber">111</context>
</context-group>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
@ -4300,7 +4300,7 @@
</context-group>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.html</context>
<context context-type="linenumber">288</context>
<context context-type="linenumber">305</context>
</context-group>
</trans-unit>
<trans-unit id="8057014866157903311" datatype="html">
@ -4402,6 +4402,10 @@
<context context-type="sourcefile">src/app/components/common/profile-edit-dialog/profile-edit-dialog.component.html</context>
<context context-type="linenumber">10</context>
</context-group>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.html</context>
<context context-type="linenumber">96</context>
</context-group>
</trans-unit>
<trans-unit id="5342432350421167093" datatype="html">
<source>First name</source>
@ -5080,6 +5084,62 @@
<context context-type="linenumber">233</context>
</context-group>
</trans-unit>
<trans-unit id="7376342558017986274" datatype="html">
<source>Email address(es)</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/common/email-document-dialog/email-document-dialog.component.html</context>
<context context-type="linenumber">7</context>
</context-group>
</trans-unit>
<trans-unit id="9127604588498960753" datatype="html">
<source>Subject</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/common/email-document-dialog/email-document-dialog.component.html</context>
<context context-type="linenumber">11</context>
</context-group>
</trans-unit>
<trans-unit id="8066608938393600549" datatype="html">
<source>Message</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/common/email-document-dialog/email-document-dialog.component.html</context>
<context context-type="linenumber">15</context>
</context-group>
</trans-unit>
<trans-unit id="5867799091834207531" datatype="html">
<source>Use archive version</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/common/email-document-dialog/email-document-dialog.component.html</context>
<context context-type="linenumber">23</context>
</context-group>
</trans-unit>
<trans-unit id="4312183290449350804" datatype="html">
<source>Send email</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/common/email-document-dialog/email-document-dialog.component.html</context>
<context context-type="linenumber">29</context>
</context-group>
</trans-unit>
<trans-unit id="1342170399958833675" datatype="html">
<source>Email Document</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/common/email-document-dialog/email-document-dialog.component.ts</context>
<context context-type="linenumber">17</context>
</context-group>
</trans-unit>
<trans-unit id="9049148856403142491" datatype="html">
<source>Email sent</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/common/email-document-dialog/email-document-dialog.component.ts</context>
<context context-type="linenumber">65</context>
</context-group>
</trans-unit>
<trans-unit id="3742745894977668908" datatype="html">
<source>Error emailing document</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/common/email-document-dialog/email-document-dialog.component.ts</context>
<context context-type="linenumber">69</context>
</context-group>
</trans-unit>
<trans-unit id="6381578200008167206" datatype="html">
<source>Include</source>
<context-group purpose="location">
@ -5101,8 +5161,8 @@
<context context-type="linenumber">58</context>
</context-group>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/common/share-links-dropdown/share-links-dropdown.component.html</context>
<context context-type="linenumber">64</context>
<context context-type="sourcefile">src/app/components/common/share-links-dialog/share-links-dialog.component.html</context>
<context context-type="linenumber">65</context>
</context-group>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/manage/management-list/management-list.component.html</context>
@ -5562,8 +5622,8 @@
<context context-type="linenumber">155</context>
</context-group>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/common/share-links-dropdown/share-links-dropdown.component.html</context>
<context context-type="linenumber">29</context>
<context context-type="sourcefile">src/app/components/common/share-links-dialog/share-links-dialog.component.html</context>
<context context-type="linenumber">28</context>
</context-group>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/common/system-status-dialog/system-status-dialog.component.html</context>
@ -5604,8 +5664,8 @@
<context context-type="linenumber">162</context>
</context-group>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/common/share-links-dropdown/share-links-dropdown.component.html</context>
<context context-type="linenumber">40</context>
<context context-type="sourcefile">src/app/components/common/share-links-dialog/share-links-dialog.component.html</context>
<context context-type="linenumber">39</context>
</context-group>
</trans-unit>
<trans-unit id="4369881772624105142" datatype="html">
@ -5784,103 +5844,103 @@
<context context-type="linenumber">320</context>
</context-group>
</trans-unit>
<trans-unit id="686374493515618129" datatype="html">
<source>Share Links</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/common/share-links-dropdown/share-links-dropdown.component.html</context>
<context context-type="linenumber">4</context>
</context-group>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/common/share-links-dropdown/share-links-dropdown.component.ts</context>
<context context-type="linenumber">32</context>
</context-group>
</trans-unit>
<trans-unit id="6617773613987957957" datatype="html">
<source> No existing links </source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/common/share-links-dropdown/share-links-dropdown.component.html</context>
<context context-type="linenumber">9,11</context>
<context context-type="sourcefile">src/app/components/common/share-links-dialog/share-links-dialog.component.html</context>
<context context-type="linenumber">8,10</context>
</context-group>
</trans-unit>
<trans-unit id="7419704019640008953" datatype="html">
<source>Share</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/common/share-links-dropdown/share-links-dropdown.component.html</context>
<context context-type="linenumber">33</context>
<context context-type="sourcefile">src/app/components/common/share-links-dialog/share-links-dialog.component.html</context>
<context context-type="linenumber">32</context>
</context-group>
</trans-unit>
<trans-unit id="6811921365829755679" datatype="html">
<source>Share archive version</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/common/share-links-dropdown/share-links-dropdown.component.html</context>
<context context-type="linenumber">47</context>
<context context-type="sourcefile">src/app/components/common/share-links-dialog/share-links-dialog.component.html</context>
<context context-type="linenumber">48</context>
</context-group>
</trans-unit>
<trans-unit id="8037476586059399916" datatype="html">
<source>Expires</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/common/share-links-dropdown/share-links-dropdown.component.html</context>
<context context-type="linenumber">51</context>
<context context-type="sourcefile">src/app/components/common/share-links-dialog/share-links-dialog.component.html</context>
<context context-type="linenumber">52</context>
</context-group>
</trans-unit>
<trans-unit id="4776429682428363094" datatype="html">
<source>1 day</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/common/share-links-dropdown/share-links-dropdown.component.ts</context>
<context context-type="linenumber">25</context>
<context context-type="sourcefile">src/app/components/common/share-links-dialog/share-links-dialog.component.ts</context>
<context context-type="linenumber">20</context>
</context-group>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/common/share-links-dropdown/share-links-dropdown.component.ts</context>
<context context-type="linenumber">111</context>
<context context-type="sourcefile">src/app/components/common/share-links-dialog/share-links-dialog.component.ts</context>
<context context-type="linenumber">104</context>
</context-group>
</trans-unit>
<trans-unit id="8542568275115626925" datatype="html">
<source>7 days</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/common/share-links-dropdown/share-links-dropdown.component.ts</context>
<context context-type="linenumber">26</context>
<context context-type="sourcefile">src/app/components/common/share-links-dialog/share-links-dialog.component.ts</context>
<context context-type="linenumber">21</context>
</context-group>
</trans-unit>
<trans-unit id="7152095234138763013" datatype="html">
<source>30 days</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/common/share-links-dropdown/share-links-dropdown.component.ts</context>
<context context-type="linenumber">27</context>
<context context-type="sourcefile">src/app/components/common/share-links-dialog/share-links-dialog.component.ts</context>
<context context-type="linenumber">22</context>
</context-group>
</trans-unit>
<trans-unit id="8372007266188249803" datatype="html">
<source>Never</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/common/share-links-dropdown/share-links-dropdown.component.ts</context>
<context context-type="linenumber">28</context>
<context context-type="sourcefile">src/app/components/common/share-links-dialog/share-links-dialog.component.ts</context>
<context context-type="linenumber">23</context>
</context-group>
</trans-unit>
<trans-unit id="686374493515618129" datatype="html">
<source>Share Links</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/common/share-links-dialog/share-links-dialog.component.ts</context>
<context context-type="linenumber">27</context>
</context-group>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.html</context>
<context context-type="linenumber">92</context>
</context-group>
</trans-unit>
<trans-unit id="3429210839568770054" datatype="html">
<source>Error retrieving links</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/common/share-links-dropdown/share-links-dropdown.component.ts</context>
<context context-type="linenumber">92</context>
<context context-type="sourcefile">src/app/components/common/share-links-dialog/share-links-dialog.component.ts</context>
<context context-type="linenumber">85</context>
</context-group>
</trans-unit>
<trans-unit id="3242255798983858463" datatype="html">
<source><x id="PH" equiv-text="days"/> days</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/common/share-links-dropdown/share-links-dropdown.component.ts</context>
<context context-type="linenumber">111</context>
<context context-type="sourcefile">src/app/components/common/share-links-dialog/share-links-dialog.component.ts</context>
<context context-type="linenumber">104</context>
</context-group>
</trans-unit>
<trans-unit id="2897042887615940599" datatype="html">
<source>Error deleting link</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/common/share-links-dropdown/share-links-dropdown.component.ts</context>
<context context-type="linenumber">140</context>
<context context-type="sourcefile">src/app/components/common/share-links-dialog/share-links-dialog.component.ts</context>
<context context-type="linenumber">133</context>
</context-group>
</trans-unit>
<trans-unit id="8400747326190565173" datatype="html">
<source>Error creating link</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/common/share-links-dropdown/share-links-dropdown.component.ts</context>
<context context-type="linenumber">168</context>
<context context-type="sourcefile">src/app/components/common/share-links-dialog/share-links-dialog.component.ts</context>
<context context-type="linenumber">161</context>
</context-group>
</trans-unit>
<trans-unit id="9180110319941008393" datatype="html">
@ -6434,25 +6494,32 @@
<context context-type="linenumber">70</context>
</context-group>
</trans-unit>
<trans-unit id="6490688569532630280" datatype="html">
<source>Send</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.html</context>
<context context-type="linenumber">88</context>
</context-group>
</trans-unit>
<trans-unit id="4452427314943113135" datatype="html">
<source>Previous</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.html</context>
<context context-type="linenumber">97</context>
<context context-type="linenumber">114</context>
</context-group>
</trans-unit>
<trans-unit id="5028777105388019087" datatype="html">
<source>Details</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.html</context>
<context context-type="linenumber">110</context>
<context context-type="linenumber">127</context>
</context-group>
</trans-unit>
<trans-unit id="5701618810648052610" datatype="html">
<source>Title</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.html</context>
<context context-type="linenumber">113</context>
<context context-type="linenumber">130</context>
</context-group>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-list/document-list.component.html</context>
@ -6475,21 +6542,21 @@
<source>Archive serial number</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.html</context>
<context context-type="linenumber">114</context>
<context context-type="linenumber">131</context>
</context-group>
</trans-unit>
<trans-unit id="5114742157723900905" datatype="html">
<source>Date created</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.html</context>
<context context-type="linenumber">115</context>
<context context-type="linenumber">132</context>
</context-group>
</trans-unit>
<trans-unit id="2691296884221415710" datatype="html">
<source>Correspondent</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.html</context>
<context context-type="linenumber">117</context>
<context context-type="linenumber">134</context>
</context-group>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.html</context>
@ -6516,7 +6583,7 @@
<source>Document type</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.html</context>
<context context-type="linenumber">119</context>
<context context-type="linenumber">136</context>
</context-group>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.html</context>
@ -6543,7 +6610,7 @@
<source>Storage path</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.html</context>
<context context-type="linenumber">121</context>
<context context-type="linenumber">138</context>
</context-group>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.html</context>
@ -6566,7 +6633,7 @@
<source>Default</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.html</context>
<context context-type="linenumber">122</context>
<context context-type="linenumber">139</context>
</context-group>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/manage/saved-views/saved-views.component.html</context>
@ -6577,14 +6644,14 @@
<source>Content</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.html</context>
<context context-type="linenumber">218</context>
<context context-type="linenumber">235</context>
</context-group>
</trans-unit>
<trans-unit id="218403386307979629" datatype="html">
<source>Metadata</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.html</context>
<context context-type="linenumber">227</context>
<context context-type="linenumber">244</context>
</context-group>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/metadata-collapse/metadata-collapse.component.ts</context>
@ -6595,119 +6662,119 @@
<source>Date modified</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.html</context>
<context context-type="linenumber">234</context>
<context context-type="linenumber">251</context>
</context-group>
</trans-unit>
<trans-unit id="6392918669949841614" datatype="html">
<source>Date added</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.html</context>
<context context-type="linenumber">238</context>
<context context-type="linenumber">255</context>
</context-group>
</trans-unit>
<trans-unit id="146828917013192897" datatype="html">
<source>Media filename</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.html</context>
<context context-type="linenumber">242</context>
<context context-type="linenumber">259</context>
</context-group>
</trans-unit>
<trans-unit id="4500855521601039868" datatype="html">
<source>Original filename</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.html</context>
<context context-type="linenumber">246</context>
<context context-type="linenumber">263</context>
</context-group>
</trans-unit>
<trans-unit id="7985558498848210210" datatype="html">
<source>Original MD5 checksum</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.html</context>
<context context-type="linenumber">250</context>
<context context-type="linenumber">267</context>
</context-group>
</trans-unit>
<trans-unit id="5888243105821763422" datatype="html">
<source>Original file size</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.html</context>
<context context-type="linenumber">254</context>
<context context-type="linenumber">271</context>
</context-group>
</trans-unit>
<trans-unit id="2696647325713149563" datatype="html">
<source>Original mime type</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.html</context>
<context context-type="linenumber">258</context>
<context context-type="linenumber">275</context>
</context-group>
</trans-unit>
<trans-unit id="342875990758166588" datatype="html">
<source>Archive MD5 checksum</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.html</context>
<context context-type="linenumber">263</context>
<context context-type="linenumber">280</context>
</context-group>
</trans-unit>
<trans-unit id="6033581412811562084" datatype="html">
<source>Archive file size</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.html</context>
<context context-type="linenumber">269</context>
<context context-type="linenumber">286</context>
</context-group>
</trans-unit>
<trans-unit id="6992781481378431874" datatype="html">
<source>Original document metadata</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.html</context>
<context context-type="linenumber">278</context>
<context context-type="linenumber">295</context>
</context-group>
</trans-unit>
<trans-unit id="2846565152091361585" datatype="html">
<source>Archived document metadata</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.html</context>
<context context-type="linenumber">281</context>
<context context-type="linenumber">298</context>
</context-group>
</trans-unit>
<trans-unit id="7206723502037428235" datatype="html">
<source>Notes <x id="START_BLOCK_IF" equiv-text="@if (document?.notes.length) {"/><x id="START_TAG_SPAN" ctype="x-span" equiv-text="&lt;span class=&quot;badge text-bg-secondary ms-1&quot;&gt;"/><x id="INTERPOLATION" equiv-text="ngth}}"/><x id="CLOSE_TAG_SPAN" ctype="x-span"/><x id="CLOSE_BLOCK_IF" equiv-text="}"/></source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.html</context>
<context context-type="linenumber">300,303</context>
<context context-type="linenumber">317,320</context>
</context-group>
</trans-unit>
<trans-unit id="186236568870281953" datatype="html">
<source>History</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.html</context>
<context context-type="linenumber">311</context>
<context context-type="linenumber">328</context>
</context-group>
</trans-unit>
<trans-unit id="5129524307369213584" datatype="html">
<source>Save &amp; next</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.html</context>
<context context-type="linenumber">348</context>
<context context-type="linenumber">365</context>
</context-group>
</trans-unit>
<trans-unit id="4910102545766233758" datatype="html">
<source>Save &amp; close</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.html</context>
<context context-type="linenumber">351</context>
<context context-type="linenumber">368</context>
</context-group>
</trans-unit>
<trans-unit id="1309556917227148591" datatype="html">
<source>Document loading...</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.html</context>
<context context-type="linenumber">361</context>
<context context-type="linenumber">378</context>
</context-group>
</trans-unit>
<trans-unit id="8191371354890763172" datatype="html">
<source>Enter Password</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.html</context>
<context context-type="linenumber">415</context>
<context context-type="linenumber">432</context>
</context-group>
</trans-unit>
<trans-unit id="2218903673684131427" datatype="html">
@ -7004,11 +7071,11 @@
<source>An error occurred loading tiff: <x id="PH" equiv-text="err.toString()"/></source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
<context context-type="linenumber">1461</context>
<context context-type="linenumber">1481</context>
</context-group>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
<context context-type="linenumber">1465</context>
<context context-type="linenumber">1485</context>
</context-group>
</trans-unit>
<trans-unit id="4958946940233632319" datatype="html">

View File

@ -84,7 +84,7 @@ export class SplitConfirmDialogComponent
addSplit() {
if (this.page === this.totalPages) return
this.pages.add(this.page)
this.pages = new Set(Array.from(this.pages).sort())
this.pages = new Set(Array.from(this.pages).sort((a, b) => a - b))
this.confirmButtonEnabled = this.pages.size > 0
}

View File

@ -0,0 +1,32 @@
<div class="modal-header">
<h4 class="modal-title" id="modal-basic-title">{{title}}</h4>
<button type="button" class="btn-close" aria-label="Close" (click)="close()"></button>
</div>
<div class="modal-body">
<div class="mb-1">
<label for="email" class="form-label" i18n>Email address(es)</label>
<input type="email" class="form-control" id="email" [(ngModel)]="emailAddress">
</div>
<div class="mb-1">
<label for="email" class="form-label" i18n>Subject</label>
<input type="email" class="form-control" id="subject" [(ngModel)]="emailSubject">
</div>
<div>
<label for="message" class="form-label" i18n>Message</label>
<textarea class="form-control" id="message" rows="3" [(ngModel)]="emailMessage"></textarea>
</div>
</div>
<div class="modal-footer">
<div class="input-group">
<div class="input-group-text flex-grow-1">
<input class="form-check-input mt-0 me-2" type="checkbox" role="switch" id="useArchiveVersion" [disabled]="!hasArchiveVersion" [(ngModel)]="useArchiveVersion">
<label class="form-check-label w-100 text-start" for="useArchiveVersion" i18n>Use archive version</label>
</div>
<button type="submit" class="btn btn-outline-primary" (click)="emailDocument()" [disabled]="loading || emailAddress.length === 0 || emailMessage.length === 0 || emailSubject.length === 0">
@if (loading) {
<div class="spinner-border spinner-border-sm me-2" role="status"></div>
}
<ng-container i18n>Send email</ng-container>
</button>
</div>
</div>

View File

@ -0,0 +1,72 @@
import { ComponentFixture, TestBed } from '@angular/core/testing'
import { provideHttpClient, withInterceptorsFromDi } from '@angular/common/http'
import { provideHttpClientTesting } from '@angular/common/http/testing'
import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap'
import { NgxBootstrapIconsModule, allIcons } from 'ngx-bootstrap-icons'
import { of, throwError } from 'rxjs'
import { IfPermissionsDirective } from 'src/app/directives/if-permissions.directive'
import { PermissionsService } from 'src/app/services/permissions.service'
import { DocumentService } from 'src/app/services/rest/document.service'
import { ToastService } from 'src/app/services/toast.service'
import { EmailDocumentDialogComponent } from './email-document-dialog.component'
describe('EmailDocumentDialogComponent', () => {
let component: EmailDocumentDialogComponent
let fixture: ComponentFixture<EmailDocumentDialogComponent>
let documentService: DocumentService
let permissionsService: PermissionsService
let toastService: ToastService
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [
EmailDocumentDialogComponent,
IfPermissionsDirective,
NgxBootstrapIconsModule.pick(allIcons),
],
providers: [
provideHttpClient(withInterceptorsFromDi()),
provideHttpClientTesting(),
NgbActiveModal,
],
}).compileComponents()
fixture = TestBed.createComponent(EmailDocumentDialogComponent)
documentService = TestBed.inject(DocumentService)
toastService = TestBed.inject(ToastService)
component = fixture.componentInstance
fixture.detectChanges()
})
it('should set hasArchiveVersion and useArchiveVersion', () => {
expect(component.hasArchiveVersion).toBeTruthy()
component.hasArchiveVersion = false
expect(component.hasArchiveVersion).toBeFalsy()
expect(component.useArchiveVersion).toBeFalsy()
})
it('should support sending document via email, showing error if needed', () => {
const toastErrorSpy = jest.spyOn(toastService, 'showError')
const toastSuccessSpy = jest.spyOn(toastService, 'showInfo')
component.emailAddress = 'hello@paperless-ngx.com'
component.emailSubject = 'Hello'
component.emailMessage = 'World'
jest
.spyOn(documentService, 'emailDocument')
.mockReturnValue(throwError(() => new Error('Unable to email document')))
component.emailDocument()
expect(toastErrorSpy).toHaveBeenCalled()
jest.spyOn(documentService, 'emailDocument').mockReturnValue(of(true))
component.emailDocument()
expect(toastSuccessSpy).toHaveBeenCalled()
})
it('should close the dialog', () => {
const activeModal = TestBed.inject(NgbActiveModal)
const closeSpy = jest.spyOn(activeModal, 'close')
component.close()
expect(closeSpy).toHaveBeenCalled()
})
})

View File

@ -0,0 +1,77 @@
import { Component, Input } from '@angular/core'
import { FormsModule } from '@angular/forms'
import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap'
import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons'
import { DocumentService } from 'src/app/services/rest/document.service'
import { ToastService } from 'src/app/services/toast.service'
import { LoadingComponentWithPermissions } from '../../loading-component/loading.component'
@Component({
selector: 'pngx-email-document-dialog',
templateUrl: './email-document-dialog.component.html',
styleUrl: './email-document-dialog.component.scss',
imports: [FormsModule, NgxBootstrapIconsModule],
})
export class EmailDocumentDialogComponent extends LoadingComponentWithPermissions {
@Input()
title = $localize`Email Document`
@Input()
documentId: number
private _hasArchiveVersion: boolean = true
@Input()
set hasArchiveVersion(value: boolean) {
this._hasArchiveVersion = value
this.useArchiveVersion = value
}
get hasArchiveVersion(): boolean {
return this._hasArchiveVersion
}
public useArchiveVersion: boolean = true
public emailAddress: string = ''
public emailSubject: string = ''
public emailMessage: string = ''
constructor(
private activeModal: NgbActiveModal,
private documentService: DocumentService,
private toastService: ToastService
) {
super()
this.loading = false
}
public emailDocument() {
this.loading = true
this.documentService
.emailDocument(
this.documentId,
this.emailAddress,
this.emailSubject,
this.emailMessage,
this.useArchiveVersion
)
.subscribe({
next: () => {
this.loading = false
this.emailAddress = ''
this.emailSubject = ''
this.emailMessage = ''
this.toastService.showInfo($localize`Email sent`)
},
error: (e) => {
this.loading = false
this.toastService.showError($localize`Error emailing document`, e)
},
})
}
public close() {
this.activeModal.close()
}
}

View File

@ -0,0 +1,68 @@
<div class="modal-header">
<h4 class="modal-title" id="modal-basic-title">{{title}}</h4>
<button type="button" class="btn-close" aria-label="Close" (click)="close()"></button>
</div>
<div class="modal-body p-0">
<ul class="list-group list-group-flush">
@if (!shareLinks || shareLinks.length === 0) {
<li class="list-group-item fst-italic small text-center text-secondary" i18n>
No existing links
</li>
}
@for (link of shareLinks; track link) {
<li class="list-group-item">
<div class="input-group w-100">
<input type="text" class="form-control" aria-label="Share link" [value]="getShareUrl(link)" readonly>
@if (link.expiration) {
<span class="input-group-text">
{{ getDaysRemaining(link) }}
</span>
}
<button type="button" class="btn btn-outline-primary" (click)="copy(link)">
@if (copied !== link.id) {
<i-bs width="1.2em" height="1.2em" name="clipboard-fill"></i-bs>
}
@if (copied === link.id) {
<i-bs width="1.2em" height="1.2em" name="clipboard-check-fill"></i-bs>
}
<span class="visually-hidden" i18n>Copy</span>
</button>
@if (canShare(link)) {
<button type="button" class="btn btn-outline-primary" (click)="share(link)">
<i-bs width="1.2em" height="1.2em" name="box-arrow-up"></i-bs><span class="visually-hidden" i18n>Share</span>
</button>
}
<button type="button" class="btn btn-outline-danger" (click)="delete(link)">
<i-bs width="1.2em" height="1.2em" name="trash"></i-bs><span class="visually-hidden" i18n>Delete</span>
</button>
</div>
<span class="badge copied-badge bg-primary small fade ms-4 position-absolute top-50 translate-middle-y pe-none z-3" [class.show]="copied === link.id" i18n>Copied!</span>
</li>
}
</ul>
</div>
<div class="modal-footer">
<div class="input-group w-100">
<div class="form-check form-switch ms-auto">
<input class="form-check-input" type="checkbox" role="switch" id="versionSwitch" [disabled]="!hasArchiveVersion" [(ngModel)]="useArchiveVersion">
<label class="form-check-label" for="versionSwitch" i18n>Share archive version</label>
</div>
</div>
<div class="input-group w-100 mt-2">
<label class="input-group-text" for="addLink"><ng-container i18n>Expires</ng-container>:</label>
<select class="form-select fs-6" [(ngModel)]="expirationDays">
@for (option of EXPIRATION_OPTIONS; track option) {
<option [ngValue]="option.value">{{ option.label }}</option>
}
</select>
<button class="btn btn-outline-primary ms-auto" type="button" (click)="createLink()" [disabled]="loading">
@if (loading) {
<div class="spinner-border spinner-border-sm me-2" role="status"></div>
}
@if (!loading) {
<i-bs name="plus"></i-bs>
}
<ng-container i18n>Create</ng-container>
</button>
</div>
</div>

View File

@ -0,0 +1,3 @@
.copied-badge {
right: 15em;
}

View File

@ -11,17 +11,18 @@ import {
tick,
} from '@angular/core/testing'
import { By } from '@angular/platform-browser'
import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap'
import { NgxBootstrapIconsModule, allIcons } from 'ngx-bootstrap-icons'
import { of, throwError } from 'rxjs'
import { FileVersion, ShareLink } from 'src/app/data/share-link'
import { ShareLinkService } from 'src/app/services/rest/share-link.service'
import { ToastService } from 'src/app/services/toast.service'
import { environment } from 'src/environments/environment'
import { ShareLinksDropdownComponent } from './share-links-dropdown.component'
import { ShareLinksDialogComponent } from './share-links-dialog.component'
describe('ShareLinksDropdownComponent', () => {
let component: ShareLinksDropdownComponent
let fixture: ComponentFixture<ShareLinksDropdownComponent>
describe('ShareLinksDialogComponent', () => {
let component: ShareLinksDialogComponent
let fixture: ComponentFixture<ShareLinksDialogComponent>
let shareLinkService: ShareLinkService
let toastService: ToastService
let httpController: HttpTestingController
@ -30,16 +31,17 @@ describe('ShareLinksDropdownComponent', () => {
beforeEach(() => {
TestBed.configureTestingModule({
imports: [
ShareLinksDropdownComponent,
ShareLinksDialogComponent,
NgxBootstrapIconsModule.pick(allIcons),
],
providers: [
provideHttpClient(withInterceptorsFromDi()),
provideHttpClientTesting(),
NgbActiveModal,
],
})
fixture = TestBed.createComponent(ShareLinksDropdownComponent)
fixture = TestBed.createComponent(ShareLinksDialogComponent)
shareLinkService = TestBed.inject(ShareLinkService)
toastService = TestBed.inject(ToastService)
httpController = TestBed.inject(HttpTestingController)
@ -232,4 +234,11 @@ describe('ShareLinksDropdownComponent', () => {
]
).toBeTruthy()
})
it('should support close', () => {
const activeModal = TestBed.inject(NgbActiveModal)
const closeSpy = jest.spyOn(activeModal, 'close')
component.close()
expect(closeSpy).toHaveBeenCalled()
})
})

View File

@ -1,7 +1,7 @@
import { Clipboard } from '@angular/cdk/clipboard'
import { Component, Input, OnInit } from '@angular/core'
import { FormsModule, ReactiveFormsModule } from '@angular/forms'
import { NgbDropdownModule } from '@ng-bootstrap/ng-bootstrap'
import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap'
import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons'
import { first } from 'rxjs'
import { FileVersion, ShareLink } from 'src/app/data/share-link'
@ -10,17 +10,12 @@ import { ToastService } from 'src/app/services/toast.service'
import { environment } from 'src/environments/environment'
@Component({
selector: 'pngx-share-links-dropdown',
templateUrl: './share-links-dropdown.component.html',
styleUrls: ['./share-links-dropdown.component.scss'],
imports: [
FormsModule,
ReactiveFormsModule,
NgbDropdownModule,
NgxBootstrapIconsModule,
],
selector: 'pngx-share-links-dialog',
templateUrl: './share-links-dialog.component.html',
styleUrls: ['./share-links-dialog.component.scss'],
imports: [FormsModule, ReactiveFormsModule, NgxBootstrapIconsModule],
})
export class ShareLinksDropdownComponent implements OnInit {
export class ShareLinksDialogComponent implements OnInit {
EXPIRATION_OPTIONS = [
{ label: $localize`1 day`, value: 1 },
{ label: $localize`7 days`, value: 7 },
@ -41,9 +36,6 @@ export class ShareLinksDropdownComponent implements OnInit {
}
}
@Input()
disabled: boolean = false
private _hasArchiveVersion: boolean = true
@Input()
@ -67,6 +59,7 @@ export class ShareLinksDropdownComponent implements OnInit {
useArchiveVersion: boolean = true
constructor(
private activeModal: NgbActiveModal,
private shareLinkService: ShareLinkService,
private toastService: ToastService,
private clipboard: Clipboard
@ -169,4 +162,8 @@ export class ShareLinksDropdownComponent implements OnInit {
},
})
}
close() {
this.activeModal.close()
}
}

View File

@ -1,70 +0,0 @@
<div ngbDropdown>
<button class="btn btn-sm btn-outline-primary" id="shareLinksDropdown" [disabled]="disabled" ngbDropdownToggle>
<i-bs name="link"></i-bs>
<div class="d-none d-sm-inline">&nbsp;<ng-container i18n>Share Links</ng-container></div>
</button>
<div ngbDropdownMenu aria-labelledby="shareLinksDropdown" class="shadow share-links-dropdown">
<ul class="list-group list-group-flush">
@if (!shareLinks || shareLinks.length === 0) {
<li class="list-group-item fst-italic small text-center text-secondary" i18n>
No existing links
</li>
}
@for (link of shareLinks; track link) {
<li class="list-group-item">
<div class="input-group input-group-sm w-100">
<input type="text" class="form-control" aria-label="Share link" [value]="getShareUrl(link)" readonly>
@if (link.expiration) {
<span class="input-group-text">
{{ getDaysRemaining(link) }}
</span>
}
<button type="button" class="btn btn-sm btn-outline-primary" (click)="copy(link)">
@if (copied !== link.id) {
<i-bs width="1.2em" height="1.2em" name="clipboard-fill"></i-bs>
}
@if (copied === link.id) {
<i-bs width="1.2em" height="1.2em" name="clipboard-check-fill"></i-bs>
}
<span class="visually-hidden" i18n>Copy</span>
</button>
@if (canShare(link)) {
<button type="button" class="btn btn-sm btn-outline-primary" (click)="share(link)">
<i-bs width="1.2em" height="1.2em" name="box-arrow-up"></i-bs><span class="visually-hidden" i18n>Share</span>
</button>
}
<button type="button" class="btn btn-sm btn-outline-danger" (click)="delete(link)">
<i-bs width="1.2em" height="1.2em" name="trash"></i-bs><span class="visually-hidden" i18n>Delete</span>
</button>
</div>
<span class="badge copied-badge bg-primary small fade ms-4 position-absolute top-50 translate-middle-y pe-none z-3" [class.show]="copied === link.id" i18n>Copied!</span>
</li>
}
<li class="list-group-item pt-3 pb-2">
<div class="input-group input-group-sm w-100">
<div class="form-check form-switch ms-auto small">
<input class="form-check-input" type="checkbox" role="switch" id="versionSwitch" [disabled]="!hasArchiveVersion" [(ngModel)]="useArchiveVersion">
<label class="form-check-label" for="versionSwitch" i18n>Share archive version</label>
</div>
</div>
<div class="input-group input-group-sm w-100 mt-2">
<label class="input-group-text" for="addLink"><ng-container i18n>Expires</ng-container>:</label>
<select class="form-select form-select-sm" [(ngModel)]="expirationDays">
@for (option of EXPIRATION_OPTIONS; track option) {
<option [ngValue]="option.value">{{ option.label }}</option>
}
</select>
<button class="btn btn-sm btn-outline-primary ms-auto" type="button" (click)="createLink()" [disabled]="loading">
@if (loading) {
<div class="spinner-border spinner-border-sm me-2" role="status"></div>
}
@if (!loading) {
<i-bs name="plus"></i-bs>
}
<ng-container i18n>Create</ng-container>
</button>
</div>
</li>
</ul>
</div>
</div>

View File

@ -1,14 +0,0 @@
.share-links-dropdown {
min-width: 350px;
// correct position on mobile
@media (max-width: 575.98px) {
&.show {
margin-left: -175px !important;
}
}
}
.copied-badge {
right: 7.5em;
}

View File

@ -81,7 +81,24 @@
(added)="addField($event)">
</pngx-custom-fields-dropdown>
<pngx-share-links-dropdown [documentId]="documentId" [hasArchiveVersion]="!!document?.archived_file_name" [disabled]="!userCanEdit && !userIsOwner" *pngxIfPermissions="{ action: PermissionAction.Add, type: PermissionType.ShareLink }"></pngx-share-links-dropdown>
<div class="ms-auto" ngbDropdown>
<button class="btn btn-sm btn-outline-primary" id="sendDropdown" ngbDropdownToggle>
<i-bs name="send"></i-bs>
<div class="d-none d-sm-inline">&nbsp;<ng-container i18n>Send</ng-container></div>
</button>
<div ngbDropdownMenu aria-labelledby="actionsDropdown" class="shadow">
<button ngbDropdownItem (click)="openShareLinks()" *pngxIfPermissions="{ action: PermissionAction.Add, type: PermissionType.ShareLink }">
<i-bs name="link"></i-bs>&nbsp;<span i18n>Share Links</span>
</button>
@if (emailEnabled) {
<button ngbDropdownItem (click)="openEmailDocument()">
<i-bs name="envelope"></i-bs>&nbsp;<span i18n>Email</span>
</button>
}
</div>
</div>
</pngx-page-header>
<div class="row">

View File

@ -1330,4 +1330,18 @@ describe('DocumentDetailComponent', () => {
expect(createSpy).toHaveBeenCalledWith('a')
expect(urlRevokeSpy).toHaveBeenCalled()
})
it('should get email enabled status from settings', () => {
jest.spyOn(settingsService, 'get').mockReturnValue(true)
expect(component.emailEnabled).toBeTruthy()
})
it('should support open share links and email modals', () => {
const modalSpy = jest.spyOn(modalService, 'open')
initNormally()
component.openShareLinks()
expect(modalSpy).toHaveBeenCalled()
component.openEmailDocument()
expect(modalSpy).toHaveBeenCalled()
})
})

View File

@ -88,6 +88,7 @@ import { CorrespondentEditDialogComponent } from '../common/edit-dialog/correspo
import { DocumentTypeEditDialogComponent } from '../common/edit-dialog/document-type-edit-dialog/document-type-edit-dialog.component'
import { EditDialogMode } from '../common/edit-dialog/edit-dialog.component'
import { StoragePathEditDialogComponent } from '../common/edit-dialog/storage-path-edit-dialog/storage-path-edit-dialog.component'
import { EmailDocumentDialogComponent } from '../common/email-document-dialog/email-document-dialog.component'
import { CheckComponent } from '../common/input/check/check.component'
import { DateComponent } from '../common/input/date/date.component'
import { DocumentLinkComponent } from '../common/input/document-link/document-link.component'
@ -99,7 +100,7 @@ import { TagsComponent } from '../common/input/tags/tags.component'
import { TextComponent } from '../common/input/text/text.component'
import { UrlComponent } from '../common/input/url/url.component'
import { PageHeaderComponent } from '../common/page-header/page-header.component'
import { ShareLinksDropdownComponent } from '../common/share-links-dropdown/share-links-dropdown.component'
import { ShareLinksDialogComponent } from '../common/share-links-dialog/share-links-dialog.component'
import { DocumentHistoryComponent } from '../document-history/document-history.component'
import { DocumentNotesComponent } from '../document-notes/document-notes.component'
import { ComponentWithPermissions } from '../with-permissions/with-permissions.component'
@ -145,7 +146,6 @@ export enum ZoomSetting {
CustomFieldsDropdownComponent,
DocumentNotesComponent,
DocumentHistoryComponent,
ShareLinksDropdownComponent,
CheckComponent,
DateComponent,
DocumentLinkComponent,
@ -1426,6 +1426,26 @@ export class DocumentDetailComponent
})
}
public openShareLinks() {
const modal = this.modalService.open(ShareLinksDialogComponent)
modal.componentInstance.documentId = this.document.id
modal.componentInstance.hasArchiveVersion =
!!this.document?.archived_file_name
}
get emailEnabled(): boolean {
return this.settings.get(SETTINGS_KEYS.EMAIL_ENABLED)
}
public openEmailDocument() {
const modal = this.modalService.open(EmailDocumentDialogComponent, {
backdrop: 'static',
})
modal.componentInstance.documentId = this.document.id
modal.componentInstance.hasArchiveVersion =
!!this.document?.archived_file_name
}
private tryRenderTiff() {
this.http.get(this.previewUrl, { responseType: 'arraybuffer' }).subscribe({
next: (res) => {

View File

@ -355,6 +355,21 @@ it('should include custom fields in sort fields if user has permission', () => {
])
})
it('should call appropriate api endpoint for email document', () => {
subscription = service
.emailDocument(
documents[0].id,
'hello@paperless-ngx.com',
'hello',
'world',
true
)
.subscribe()
httpTestingController.expectOne(
`${environment.apiBaseUrl}${endpoint}/${documents[0].id}/email/`
)
})
afterEach(() => {
subscription?.unsubscribe()
httpTestingController.verify()

View File

@ -258,4 +258,19 @@ export class DocumentService extends AbstractPaperlessService<Document> {
public get searchQuery(): string {
return this._searchQuery
}
emailDocument(
documentId: number,
addresses: string,
subject: string,
message: string,
useArchiveVersion: boolean
): Observable<any> {
return this.http.post(this.getResourceUrl(documentId, 'email'), {
addresses: addresses,
subject: subject,
message: message,
use_archive_version: useArchiveVersion,
})
}
}

View File

@ -113,6 +113,7 @@ import {
questionCircle,
scissors,
search,
send,
slashCircle,
sliders2Vertical,
sortAlphaDown,
@ -318,6 +319,7 @@ const icons = {
questionCircle,
scissors,
search,
send,
slashCircle,
sliders2Vertical,
sortAlphaDown,

View File

@ -767,6 +767,8 @@ canvas.hiddenCanvasElement {
}
.document-card {
overflow: hidden;
.card-footer i-bs svg {
vertical-align: middle;
}

View File

@ -194,6 +194,7 @@ $form-check-radio-checked-bg-image-dark: url("data:image/svg+xml,<svg xmlns='htt
border-radius: 0;
border-color: var(--bs-border-color);
filter: invert(10%);
transform: translateZ(0); // fix for safari to force hw acceleration
&.border-end {
border-right: none !important;
@ -208,28 +209,6 @@ $form-check-radio-checked-bg-image-dark: url("data:image/svg+xml,<svg xmlns='htt
mix-blend-mode: luminosity;
}
@supports (hanging-punctuation: first) and (font: -apple-system-body) and (-webkit-appearance: none) {
// Safari does not like the filters on the image, see https://github.com/paperless-ngx/paperless-ngx/pull/8121
.document-card:not(.placeholder-glow),
.document-card-large:not(.placeholder-glow) {
.doc-img-container {
transition: none;
background-color: #ffffff !important;
}
.doc-img {
filter: none !important;
box-shadow: inset 0px 0px 0px 10px rgba(0,0,0,1);
}
.doc-img.inverted {
filter: none !important;
mix-blend-mode: difference;
opacity: 0.95;
}
}
}
.paperless-input-select .ng-select .ng-dropdown-panel .ng-dropdown-panel-items .ng-option:not(.ng-option-selected):hover,
.paperless-input-select .ng-dropdown-panel .ng-dropdown-panel-items .ng-option.ng-option-marked {
background-color: var(--bs-light) !important;

View File

@ -1,3 +1,5 @@
from __future__ import annotations
import logging
import re
import tempfile
@ -10,7 +12,6 @@ from pdf2image import convert_from_path
from pikepdf import Page
from pikepdf import PasswordError
from pikepdf import Pdf
from PIL import Image
from documents.converters import convert_from_tiff_to_pdf
from documents.data_models import ConsumableDocument
@ -25,6 +26,8 @@ from documents.utils import maybe_override_pixel_limit
if TYPE_CHECKING:
from collections.abc import Callable
from PIL import Image
logger = logging.getLogger("paperless.barcodes")

View File

@ -1,12 +1,14 @@
from __future__ import annotations
from pathlib import Path
from typing import TYPE_CHECKING
from typing import NoReturn
from zipfile import ZipFile
from documents.models import Document
if TYPE_CHECKING:
from collections.abc import Callable
from zipfile import ZipFile
from documents.models import Document
class BulkArchiveStrategy:

View File

@ -1,8 +1,11 @@
from __future__ import annotations
import hashlib
import itertools
import logging
import tempfile
from pathlib import Path
from typing import TYPE_CHECKING
from typing import Literal
from celery import chain
@ -10,7 +13,6 @@ from celery import chord
from celery import group
from celery import shared_task
from django.conf import settings
from django.contrib.auth.models import User
from django.db.models import Q
from django.utils import timezone
@ -29,6 +31,9 @@ from documents.tasks import bulk_update_documents
from documents.tasks import consume_file
from documents.tasks import update_document_content_maybe_archive_file
if TYPE_CHECKING:
from django.contrib.auth.models import User
logger: logging.Logger = logging.getLogger("paperless.bulk_edit")

View File

@ -1,9 +1,10 @@
from __future__ import annotations
import logging
from binascii import hexlify
from dataclasses import dataclass
from typing import TYPE_CHECKING
from typing import Final
from typing import Optional
from django.core.cache import cache
@ -80,7 +81,7 @@ def get_suggestion_cache(document_id: int) -> SuggestionCacheData | None:
def set_suggestions_cache(
document_id: int,
suggestions: dict,
classifier: Optional["DocumentClassifier"],
classifier: DocumentClassifier | None,
*,
timeout=CACHE_50_MINUTES,
) -> None:

View File

@ -1,21 +1,21 @@
from __future__ import annotations
import logging
import pickle
import re
import warnings
from collections.abc import Iterator
from hashlib import sha256
from pathlib import Path
from typing import TYPE_CHECKING
from typing import Optional
if TYPE_CHECKING:
from collections.abc import Iterator
from datetime import datetime
from numpy import ndarray
from django.conf import settings
from django.core.cache import cache
from sklearn.exceptions import InconsistentVersionWarning
from documents.caching import CACHE_50_MINUTES
from documents.caching import CLASSIFIER_HASH_KEY
@ -37,7 +37,7 @@ class ClassifierModelCorruptError(Exception):
pass
def load_classifier(*, raise_exception: bool = False) -> Optional["DocumentClassifier"]:
def load_classifier(*, raise_exception: bool = False) -> DocumentClassifier | None:
if not settings.MODEL_FILE.is_file():
logger.debug(
"Document classification model does not exist (yet), not "
@ -102,6 +102,8 @@ class DocumentClassifier:
self._stop_words = None
def load(self) -> None:
from sklearn.exceptions import InconsistentVersionWarning
# Catch warnings for processing
with warnings.catch_warnings(record=True) as w:
with Path(settings.MODEL_FILE).open("rb") as f:

View File

@ -1,9 +1,11 @@
from __future__ import annotations
import functools
import inspect
import json
import operator
from collections.abc import Callable
from contextlib import contextmanager
from typing import TYPE_CHECKING
from django.contrib.contenttypes.models import ContentType
from django.db.models import Case
@ -40,6 +42,9 @@ from documents.models import ShareLink
from documents.models import StoragePath
from documents.models import Tag
if TYPE_CHECKING:
from collections.abc import Callable
CHAR_KWARGS = ["istartswith", "iendswith", "icontains", "iexact"]
ID_KWARGS = ["in", "exact"]
INT_KWARGS = ["exact", "gt", "gte", "lt", "lte", "isnull"]

View File

@ -1,3 +1,5 @@
from __future__ import annotations
import logging
import math
from collections import Counter
@ -5,10 +7,10 @@ from contextlib import contextmanager
from datetime import datetime
from datetime import timezone
from shutil import rmtree
from typing import TYPE_CHECKING
from typing import Literal
from django.conf import settings
from django.db.models import QuerySet
from django.utils import timezone as django_timezone
from guardian.shortcuts import get_users_with_perms
from whoosh import classify
@ -32,10 +34,7 @@ from whoosh.qparser import QueryParser
from whoosh.qparser.dateparse import DateParserPlugin
from whoosh.qparser.dateparse import English
from whoosh.qparser.plugins import FieldsPlugin
from whoosh.reading import IndexReader
from whoosh.scoring import TF_IDF
from whoosh.searching import ResultsPage
from whoosh.searching import Searcher
from whoosh.util.times import timespan
from whoosh.writing import AsyncWriter
@ -44,6 +43,12 @@ from documents.models import Document
from documents.models import Note
from documents.models import User
if TYPE_CHECKING:
from django.db.models import QuerySet
from whoosh.reading import IndexReader
from whoosh.searching import ResultsPage
from whoosh.searching import Searcher
logger = logging.getLogger("paperless.index")

View File

@ -1,8 +1,10 @@
from __future__ import annotations
import logging
import re
from fnmatch import fnmatch
from typing import TYPE_CHECKING
from documents.classifier import DocumentClassifier
from documents.data_models import ConsumableDocument
from documents.data_models import DocumentSource
from documents.models import Correspondent
@ -15,6 +17,9 @@ from documents.models import Workflow
from documents.models import WorkflowTrigger
from documents.permissions import get_objects_for_user_owner_aware
if TYPE_CHECKING:
from documents.classifier import DocumentClassifier
logger = logging.getLogger("paperless.matching")

View File

@ -1,4 +1,5 @@
import datetime
from __future__ import annotations
import logging
import mimetypes
import os
@ -6,10 +7,10 @@ import re
import shutil
import subprocess
import tempfile
from collections.abc import Iterator
from functools import lru_cache
from pathlib import Path
from re import Match
from typing import TYPE_CHECKING
from django.conf import settings
from django.utils import timezone
@ -19,6 +20,10 @@ from documents.signals import document_consumer_declaration
from documents.utils import copy_file_with_basic_stats
from documents.utils import run_subprocess
if TYPE_CHECKING:
import datetime
from collections.abc import Iterator
# This regular expression will try to find dates in the document at
# hand and will match the following formats:
# - XX.YY.ZZZZ with XX + YY being 1 or 2 and ZZZZ being 2 or 4 digits
@ -106,7 +111,7 @@ def get_supported_file_extensions() -> set[str]:
return extensions
def get_parser_class_for_mime_type(mime_type: str) -> type["DocumentParser"] | None:
def get_parser_class_for_mime_type(mime_type: str) -> type[DocumentParser] | None:
"""
Returns the best parser (by weight) for the given mimetype or
None if no parser exists

View File

@ -1,10 +1,12 @@
from __future__ import annotations
import datetime
import logging
import math
import re
import zoneinfo
from collections.abc import Iterable
from decimal import Decimal
from typing import TYPE_CHECKING
import magic
from celery import states
@ -32,6 +34,7 @@ from rest_framework.fields import SerializerMethodField
if settings.AUDIT_LOG_ENABLED:
from auditlog.context import set_actor
from documents import bulk_edit
from documents.data_models import DocumentSource
from documents.models import Correspondent
@ -60,6 +63,9 @@ from documents.templating.utils import convert_format_str_to_template_format
from documents.validators import uri_validator
from documents.validators import url_validator
if TYPE_CHECKING:
from collections.abc import Iterable
logger = logging.getLogger("paperless.serializers")
@ -1130,9 +1136,8 @@ class SavedViewSerializer(OwnedObjectSerializer):
): # i.e. check for 'custom_field_' prefix
field_id = int(re.search(r"\d+", field)[0])
if not CustomField.objects.filter(id=field_id).exists():
raise serializers.ValidationError(
f"Invalid field: {field}",
)
# In case the field was deleted, just remove from the list
attrs["display_fields"].remove(field)
elif field not in SavedView.DisplayFields.values:
raise serializers.ValidationError(
f"Invalid field: {field}",

View File

@ -1,7 +1,9 @@
from __future__ import annotations
import logging
import os
import shutil
from pathlib import Path
from typing import TYPE_CHECKING
import httpx
from celery import shared_task
@ -23,9 +25,6 @@ from guardian.shortcuts import remove_perm
from documents import matching
from documents.caching import clear_document_caches
from documents.classifier import DocumentClassifier
from documents.data_models import ConsumableDocument
from documents.data_models import DocumentMetadataOverrides
from documents.file_handling import create_source_path_directory
from documents.file_handling import delete_empty_directories
from documents.file_handling import generate_unique_filename
@ -46,6 +45,13 @@ from documents.permissions import get_objects_for_user_owner_aware
from documents.permissions import set_permissions_for_object
from documents.templating.workflows import parse_w_workflow_placeholders
if TYPE_CHECKING:
from pathlib import Path
from documents.classifier import DocumentClassifier
from documents.data_models import ConsumableDocument
from documents.data_models import DocumentMetadataOverrides
logger = logging.getLogger("paperless.handlers")

View File

@ -15,6 +15,7 @@ from dateutil import parser
from django.conf import settings
from django.contrib.auth.models import Permission
from django.contrib.auth.models import User
from django.core import mail
from django.core.cache import cache
from django.db import DataError
from django.test import override_settings
@ -1910,7 +1911,7 @@ class TestDocumentApi(DirectoriesMixin, DocumentConsumeDelayMixin, APITestCase):
],
)
# Custom field not found
# Custom field not found, removed from list
response = self.client.patch(
f"/api/saved_views/{v1.id}/",
{
@ -1922,7 +1923,9 @@ class TestDocumentApi(DirectoriesMixin, DocumentConsumeDelayMixin, APITestCase):
},
format="json",
)
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
self.assertEqual(response.status_code, status.HTTP_200_OK)
v1.refresh_from_db()
self.assertNotIn(SavedView.DisplayFields.CUSTOM_FIELD % 99, v1.display_fields)
def test_get_logs(self):
log_data = "test\ntest2\n"
@ -2651,6 +2654,153 @@ class TestDocumentApi(DirectoriesMixin, DocumentConsumeDelayMixin, APITestCase):
self.assertEqual(resp.status_code, status.HTTP_200_OK)
self.assertEqual(doc1.tags.count(), 2)
@override_settings(
EMAIL_ENABLED=True,
EMAIL_BACKEND="django.core.mail.backends.locmem.EmailBackend",
)
def test_email_document(self):
"""
GIVEN:
- Existing document
WHEN:
- API request is made to email document action
THEN:
- Email is sent, with document (original or archive) attached
"""
doc = Document.objects.create(
title="test",
mime_type="application/pdf",
content="this is a document 1",
checksum="1",
filename="test.pdf",
archive_checksum="A",
archive_filename="archive.pdf",
)
doc2 = Document.objects.create(
title="test2",
mime_type="application/pdf",
content="this is a document 2",
checksum="2",
filename="test2.pdf",
)
archive_file = Path(__file__).parent / "samples" / "simple.pdf"
source_file = Path(__file__).parent / "samples" / "simple.pdf"
shutil.copy(archive_file, doc.archive_path)
shutil.copy(source_file, doc2.source_path)
self.client.post(
f"/api/documents/{doc.pk}/email/",
{
"addresses": "hello@paperless-ngx.com",
"subject": "test",
"message": "hello",
},
)
self.assertEqual(len(mail.outbox), 1)
self.assertEqual(mail.outbox[0].attachments[0][0], "archive.pdf")
self.client.post(
f"/api/documents/{doc2.pk}/email/",
{
"addresses": "hello@paperless-ngx.com",
"subject": "test",
"message": "hello",
"use_archive_version": False,
},
)
self.assertEqual(len(mail.outbox), 2)
self.assertEqual(mail.outbox[1].attachments[0][0], "test2.pdf")
@mock.patch("django.core.mail.message.EmailMessage.send", side_effect=Exception)
def test_email_document_errors(self, mocked_send):
"""
GIVEN:
- Existing document
WHEN:
- API request is made to email document action with insufficient permissions
- API request is made to email document action with invalid document id
- API request is made to email document action with missing data
- API request is made to email document action with invalid email address
- API request is made to email document action and error occurs during email send
THEN:
- Error response is returned
"""
user1 = User.objects.create_user(username="test1")
user1.user_permissions.add(*Permission.objects.all())
user1.save()
doc = Document.objects.create(
title="test",
mime_type="application/pdf",
content="this is a document 1",
checksum="1",
filename="test.pdf",
archive_checksum="A",
archive_filename="archive.pdf",
)
doc2 = Document.objects.create(
title="test2",
mime_type="application/pdf",
content="this is a document 2",
checksum="2",
owner=self.user,
)
self.client.force_authenticate(user1)
resp = self.client.post(
f"/api/documents/{doc2.pk}/email/",
{
"addresses": "hello@paperless-ngx.com",
"subject": "test",
"message": "hello",
},
)
self.assertEqual(resp.status_code, status.HTTP_403_FORBIDDEN)
resp = self.client.post(
"/api/documents/999/email/",
{
"addresses": "hello@paperless-ngx.com",
"subject": "test",
"message": "hello",
},
)
self.assertEqual(resp.status_code, status.HTTP_404_NOT_FOUND)
resp = self.client.post(
f"/api/documents/{doc.pk}/email/",
{
"addresses": "hello@paperless-ngx.com",
},
)
self.assertEqual(resp.status_code, status.HTTP_400_BAD_REQUEST)
resp = self.client.post(
f"/api/documents/{doc.pk}/email/",
{
"addresses": "hello@paperless-ngx.com,hello",
"subject": "test",
"message": "hello",
},
)
self.assertEqual(resp.status_code, status.HTTP_400_BAD_REQUEST)
resp = self.client.post(
f"/api/documents/{doc.pk}/email/",
{
"addresses": "hello@paperless-ngx.com",
"subject": "test",
"message": "hello",
},
)
self.assertEqual(resp.status_code, status.HTTP_500_INTERNAL_SERVER_ERROR)
@mock.patch("django_softdelete.models.SoftDeleteModel.delete")
def test_warn_on_delete_with_old_uuid_field(self, mocked_delete):
"""

View File

@ -109,6 +109,7 @@ from documents.filters import PaperlessTaskFilterSet
from documents.filters import ShareLinkFilterSet
from documents.filters import StoragePathFilterSet
from documents.filters import TagFilterSet
from documents.mail import send_email
from documents.matching import match_correspondents
from documents.matching import match_document_types
from documents.matching import match_storage_paths
@ -1030,6 +1031,57 @@ class DocumentViewSet(
return Response(sorted(entries, key=lambda x: x["timestamp"], reverse=True))
@action(methods=["post"], detail=True)
def email(self, request, pk=None):
try:
doc = Document.objects.select_related("owner").get(pk=pk)
if request.user is not None and not has_perms_owner_aware(
request.user,
"view_document",
doc,
):
return HttpResponseForbidden("Insufficient permissions")
except Document.DoesNotExist:
raise Http404
try:
if (
"addresses" not in request.data
or "subject" not in request.data
or "message" not in request.data
):
return HttpResponseBadRequest("Missing required fields")
use_archive_version = request.data.get("use_archive_version", True)
addresses = request.data.get("addresses").split(",")
if not all(
re.match(r"[^@]+@[^@]+\.[^@]+", address.strip())
for address in addresses
):
return HttpResponseBadRequest("Invalid email address found")
send_email(
subject=request.data.get("subject"),
body=request.data.get("message"),
to=addresses,
attachment=(
doc.archive_path
if use_archive_version and doc.has_archive_version
else doc.source_path
),
attachment_mime_type=doc.mime_type,
)
logger.debug(
f"Sent document {doc.id} via email to {addresses}",
)
return Response({"message": "Email sent"})
except Exception as e:
logger.warning(f"An error occurred emailing document: {e!s}")
return HttpResponseServerError(
"Error emailing document, check logs for more detail.",
)
@extend_schema_view(
list=extend_schema(
@ -1148,7 +1200,7 @@ class UnifiedSearchViewSet(DocumentViewSet):
class LogViewSet(ViewSet):
permission_classes = (IsAuthenticated, PaperlessAdminPermissions)
log_files = ["paperless", "mail"]
log_files = ["paperless", "mail", "celery"]
def get_log_filename(self, log):
return os.path.join(settings.LOGGING_DIR, f"{log}.log")

View File

@ -1,12 +1,17 @@
import logging
from urllib.parse import quote
from allauth.account.adapter import DefaultAccountAdapter
from allauth.core import context
from allauth.socialaccount.adapter import DefaultSocialAccountAdapter
from django.conf import settings
from django.contrib.auth.models import Group
from django.contrib.auth.models import User
from django.forms import ValidationError
from django.urls import reverse
logger = logging.getLogger("paperless.auth")
class CustomAccountAdapter(DefaultAccountAdapter):
def is_open_for_signup(self, request):
@ -61,6 +66,20 @@ class CustomAccountAdapter(DefaultAccountAdapter):
path = path.replace("UID-KEY", quote(key))
return settings.PAPERLESS_URL + path
def save_user(self, request, user, form, commit=True): # noqa: FBT002
"""
Save the user instance. Default groups are assigned to the user, if
specified in the settings.
"""
user: User = super().save_user(request, user, form, commit)
group_names: list[str] = settings.ACCOUNT_DEFAULT_GROUPS
if len(group_names) > 0:
groups = Group.objects.filter(name__in=group_names)
logger.debug(f"Adding default groups to user `{user}`: {group_names}")
user.groups.add(*groups)
user.save()
return user
class CustomSocialAccountAdapter(DefaultSocialAccountAdapter):
def is_open_for_signup(self, request, sociallogin):
@ -80,10 +99,19 @@ class CustomSocialAccountAdapter(DefaultSocialAccountAdapter):
url = reverse("base")
return url
def populate_user(self, request, sociallogin, data):
def save_user(self, request, sociallogin, form=None):
"""
Populate the user with data from the social account. Stub is kept in case
global default permissions are implemented in the future.
Save the user instance. Default groups are assigned to the user, if
specified in the settings.
"""
# TODO: If default global permissions are implemented, should also be here
return super().populate_user(request, sociallogin, data) # pragma: no cover
# save_user also calls account_adapter save_user which would set ACCOUNT_DEFAULT_GROUPS
user: User = super().save_user(request, sociallogin, form)
group_names: list[str] = settings.SOCIAL_ACCOUNT_DEFAULT_GROUPS
if len(group_names) > 0:
groups = Group.objects.filter(name__in=group_names)
logger.debug(
f"Adding default social groups to user `{user}`: {group_names}",
)
user.groups.add(*groups)
user.save()
return user

View File

@ -2,6 +2,7 @@ from django.apps import AppConfig
from django.utils.translation import gettext_lazy as _
from paperless.signals import handle_failed_login
from paperless.signals import handle_social_account_updated
class PaperlessConfig(AppConfig):
@ -13,4 +14,9 @@ class PaperlessConfig(AppConfig):
from django.contrib.auth.signals import user_login_failed
user_login_failed.connect(handle_failed_login)
from allauth.socialaccount.signals import social_account_updated
social_account_updated.connect(handle_social_account_updated)
AppConfig.ready(self)

View File

@ -480,6 +480,7 @@ ACCOUNT_DEFAULT_HTTP_PROTOCOL = os.getenv(
ACCOUNT_ADAPTER = "paperless.adapter.CustomAccountAdapter"
ACCOUNT_ALLOW_SIGNUPS = __get_boolean("PAPERLESS_ACCOUNT_ALLOW_SIGNUPS")
ACCOUNT_DEFAULT_GROUPS = __get_list("PAPERLESS_ACCOUNT_DEFAULT_GROUPS")
SOCIALACCOUNT_ADAPTER = "paperless.adapter.CustomSocialAccountAdapter"
SOCIALACCOUNT_ALLOW_SIGNUPS = __get_boolean(
@ -490,6 +491,8 @@ SOCIALACCOUNT_AUTO_SIGNUP = __get_boolean("PAPERLESS_SOCIAL_AUTO_SIGNUP")
SOCIALACCOUNT_PROVIDERS = json.loads(
os.getenv("PAPERLESS_SOCIALACCOUNT_PROVIDERS", "{}"),
)
SOCIAL_ACCOUNT_DEFAULT_GROUPS = __get_list("PAPERLESS_SOCIAL_ACCOUNT_DEFAULT_GROUPS")
SOCIAL_ACCOUNT_SYNC_GROUPS = __get_boolean("PAPERLESS_SOCIAL_ACCOUNT_SYNC_GROUPS")
MFA_TOTP_ISSUER = "Paperless-ngx"

View File

@ -30,3 +30,21 @@ def handle_failed_login(sender, credentials, request, **kwargs):
log_output += f" from private IP `{client_ip}`."
logger.info(log_output)
def handle_social_account_updated(sender, request, sociallogin, **kwargs):
"""
Handle the social account update signal.
"""
from django.contrib.auth.models import Group
social_account_groups = sociallogin.account.extra_data.get(
"groups",
[],
) # None if not found
if settings.SOCIAL_ACCOUNT_SYNC_GROUPS and social_account_groups is not None:
groups = Group.objects.filter(name__in=social_account_groups)
logger.debug(
f"Syncing groups for user `{sociallogin.user}`: {social_account_groups}",
)
sociallogin.user.groups.set(groups, clear=True)

View File

@ -4,6 +4,8 @@ from allauth.account.adapter import get_adapter
from allauth.core import context
from allauth.socialaccount.adapter import get_adapter as get_social_adapter
from django.conf import settings
from django.contrib.auth.models import Group
from django.contrib.auth.models import User
from django.forms import ValidationError
from django.http import HttpRequest
from django.test import TestCase
@ -81,6 +83,24 @@ class TestCustomAccountAdapter(TestCase):
expected_url,
)
@override_settings(ACCOUNT_DEFAULT_GROUPS=["group1", "group2"])
def test_save_user_adds_groups(self):
Group.objects.create(name="group1")
user = User.objects.create_user("testuser")
adapter = get_adapter()
form = mock.Mock(
cleaned_data={
"username": "testuser",
"email": "user@example.com",
},
)
user = adapter.save_user(HttpRequest(), user, form, commit=True)
self.assertEqual(user.groups.count(), 1)
self.assertTrue(user.groups.filter(name="group1").exists())
self.assertFalse(user.groups.filter(name="group2").exists())
class TestCustomSocialAccountAdapter(TestCase):
def test_is_open_for_signup(self):
@ -105,3 +125,19 @@ class TestCustomSocialAccountAdapter(TestCase):
adapter.get_connect_redirect_url(request, socialaccount),
expected_url,
)
@override_settings(SOCIAL_ACCOUNT_DEFAULT_GROUPS=["group1", "group2"])
def test_save_user_adds_groups(self):
Group.objects.create(name="group1")
adapter = get_social_adapter()
request = HttpRequest()
user = User.objects.create_user("testuser")
sociallogin = mock.Mock(
user=user,
)
user = adapter.save_user(request, sociallogin, None)
self.assertEqual(user.groups.count(), 1)
self.assertTrue(user.groups.filter(name="group1").exists())
self.assertFalse(user.groups.filter(name="group2").exists())

View File

@ -1,7 +1,13 @@
from unittest.mock import Mock
from django.contrib.auth.models import Group
from django.contrib.auth.models import User
from django.http import HttpRequest
from django.test import TestCase
from django.test import override_settings
from paperless.signals import handle_failed_login
from paperless.signals import handle_social_account_updated
class TestFailedLoginLogging(TestCase):
@ -99,3 +105,88 @@ class TestFailedLoginLogging(TestCase):
"INFO:paperless.auth:Login failed for user `john lennon` from private IP `10.0.0.1`.",
],
)
class TestSyncSocialLoginGroups(TestCase):
@override_settings(SOCIAL_ACCOUNT_SYNC_GROUPS=True)
def test_sync_enabled(self):
"""
GIVEN:
- Enabled group syncing, a user, and a social login
WHEN:
- The social login is updated via signal after login
THEN:
- The user's groups are updated to match the social login's groups
"""
group = Group.objects.create(name="group1")
user = User.objects.create_user(username="testuser")
sociallogin = Mock(
user=user,
account=Mock(
extra_data={
"groups": ["group1"],
},
),
)
handle_social_account_updated(
sender=None,
request=HttpRequest(),
sociallogin=sociallogin,
)
self.assertEqual(list(user.groups.all()), [group])
@override_settings(SOCIAL_ACCOUNT_SYNC_GROUPS=False)
def test_sync_disabled(self):
"""
GIVEN:
- Disabled group syncing, a user, and a social login
WHEN:
- The social login is updated via signal after login
THEN:
- The user's groups are not updated
"""
Group.objects.create(name="group1")
user = User.objects.create_user(username="testuser")
sociallogin = Mock(
user=user,
account=Mock(
extra_data={
"groups": ["group1"],
},
),
)
handle_social_account_updated(
sender=None,
request=HttpRequest(),
sociallogin=sociallogin,
)
self.assertEqual(list(user.groups.all()), [])
@override_settings(SOCIAL_ACCOUNT_SYNC_GROUPS=True)
def test_no_groups(self):
"""
GIVEN:
- Enabled group syncing, a user, and a social login with no groups
WHEN:
- The social login is updated via signal after login
THEN:
- The user's groups are cleared to match the social login's groups
"""
group = Group.objects.create(name="group1")
user = User.objects.create_user(username="testuser")
user.groups.add(group)
user.save()
sociallogin = Mock(
user=user,
account=Mock(
extra_data={
"groups": [],
},
),
)
handle_social_account_updated(
sender=None,
request=HttpRequest(),
sociallogin=sociallogin,
)
self.assertEqual(list(user.groups.all()), [])