Merge branch 'dev' into feature-confirm-buttons

This commit is contained in:
shamoon 2024-02-08 08:20:03 -08:00
commit 728e16199c
35 changed files with 1239 additions and 488 deletions

View File

@ -1,7 +1,29 @@
# https://beta.ruff.rs/docs/settings/
# https://beta.ruff.rs/docs/rules/
extend-select = ["I", "W", "UP", "COM", "DJ", "EXE", "ISC", "ICN", "G201", "INP", "PIE", "RSE", "SIM", "TID", "PLC", "PLE", "RUF"]
# TODO PTH
# https://docs.astral.sh/ruff/settings/
# https://docs.astral.sh/ruff/rules/
extend-select = [
"W", # https://docs.astral.sh/ruff/rules/#pycodestyle-e-w
"I", # https://docs.astral.sh/ruff/rules/#isort-i
"UP", # https://docs.astral.sh/ruff/rules/#pyupgrade-up
"COM", # https://docs.astral.sh/ruff/rules/#flake8-commas-com
"DJ", # https://docs.astral.sh/ruff/rules/#flake8-django-dj
"EXE", # https://docs.astral.sh/ruff/rules/#flake8-executable-exe
"ISC", # https://docs.astral.sh/ruff/rules/#flake8-implicit-str-concat-isc
"ICN", # https://docs.astral.sh/ruff/rules/#flake8-import-conventions-icn
"G201", # https://docs.astral.sh/ruff/rules/#flake8-logging-format-g
"INP", # https://docs.astral.sh/ruff/rules/#flake8-no-pep420-inp
"PIE", # https://docs.astral.sh/ruff/rules/#flake8-pie-pie
"Q", # https://docs.astral.sh/ruff/rules/#flake8-quotes-q
"RSE", # https://docs.astral.sh/ruff/rules/#flake8-raise-rse
"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
"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
"FLY", # https://docs.astral.sh/ruff/rules/#flynt-fly
]
# TODO PTH https://docs.astral.sh/ruff/rules/#flake8-use-pathlib-pth
ignore = ["DJ001", "SIM105", "RUF012"]
fix = true
line-length = 88
@ -13,9 +35,9 @@ show-fixes = true
[per-file-ignores]
".github/scripts/*.py" = ["E501", "INP001", "SIM117"]
"docker/wait-for-redis.py" = ["INP001"]
"docker/wait-for-redis.py" = ["INP001", "T201"]
"*/tests/*.py" = ["E501", "SIM117"]
"*/migrations/*.py" = ["E501", "SIM"]
"*/migrations/*.py" = ["E501", "SIM", "T201"]
"src/paperless_tesseract/tests/test_parser.py" = ["RUF001"]
"src/documents/models.py" = ["SIM115"]

View File

@ -39,8 +39,6 @@ RUN set -eux \
# - Don't leave anything extra in here
FROM docker.io/python:3.11-slim-bookworm as main-app
ENV PYTHONWARNINGS="ignore:::django.http.response:517"
LABEL org.opencontainers.image.authors="paperless-ngx team <hello@paperless-ngx.com>"
LABEL org.opencontainers.image.documentation="https://docs.paperless-ngx.com/"
LABEL org.opencontainers.image.source="https://github.com/paperless-ngx/paperless-ngx"
@ -57,6 +55,12 @@ ARG JBIG2ENC_VERSION=0.29
ARG QPDF_VERSION=11.6.4
ARG GS_VERSION=10.02.1
# Set Python environment variables
ENV PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1 \
# Ignore warning from Whitenoise
PYTHONWARNINGS="ignore:::django.http.response:517"
#
# Begin installation and configuration
# Order the steps below from least often changed to most

View File

@ -640,3 +640,42 @@ single-sided split marker page, the split document(s) will have an empty page at
whatever else was on the backside of the split marker page.) You can work around that by having
a split marker page that has the split barcode on _both_ sides. This way, the extra page will
get automatically removed.
## SSO and third party authentication with Paperless-ngx
Paperless-ngx has a built-in authentication system from Django but you can easily integrate an
external authentication solution using one of the following methods:
### Remote User authentication
This is a simple option that uses remote user authentication made available by certain SSO
applications. See the relevant configuration options for more information:
[PAPERLESS_ENABLE_HTTP_REMOTE_USER](configuration.md#PAPERLESS_ENABLE_HTTP_REMOTE_USER) and
[PAPERLESS_HTTP_REMOTE_USER_HEADER_NAME](configuration.md#PAPERLESS_HTTP_REMOTE_USER_HEADER_NAME)
### OpenID Connect and social authentication
Version 2.5.0 of Paperless-ngx added support for integrating other authentication systems via
the [django-allauth](https://github.com/pennersr/django-allauth) package. Once set up, users
can either log in or (optionally) sign up using any third party systems you integrate. See the
relevant [configuration settings](configuration.md#PAPERLESS_SOCIALACCOUNT_PROVIDERS) and
[django-allauth docs](https://docs.allauth.org/en/latest/socialaccount/configuration.html)
for more information.
As an example, to set up login via Github, the following environment variables would need to be
set:
```conf
PAPERLESS_APPS="allauth.socialaccount.providers.github"
PAPERLESS_SOCIALACCOUNT_PROVIDERS='{"github": {"APPS": [{"provider_id": "github","name": "Github","client_id": "<CLIENT_ID>","secret": "<CLIENT_SECRET>"}]}}'
```
Or, to use OpenID Connect ("OIDC"), via Keycloak in this example:
```conf
PAPERLESS_APPS="allauth.socialaccount.providers.openid_connect"
PAPERLESS_SOCIALACCOUNT_PROVIDERS='
{"openid_connect": {"APPS": [{"provider_id": "keycloak","name": "Keycloak","client_id": "paperless","secret": "<CLIENT_SECRET>","settings": { "server_url": "https://<KEYCLOAK_SERVER>/realms/<REALM>/.well-known/openid-configuration"}}]}}'
```
More details about configuration option for various providers can be found in the allauth documentation: https://docs.allauth.org/en/latest/socialaccount/providers/index.html#provider-specifics

View File

@ -535,6 +535,42 @@ 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.
#### [`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.
See the corresponding [django-allauth documentation](https://docs.allauth.org/en/0.60.0/socialaccount/providers/index.html)
for a list of provider configurations. You will also likely need to include the relevant Django 'application' inside the
[PAPERLESS_APPS](#PAPERLESS_APPS) setting.
Defaults to None, which does not enable any third party authentication systems.
#### [`PAPERLESS_SOCIAL_AUTO_SIGNUP=<bool>`](#PAPERLESS_SOCIAL_AUTO_SIGNUP) {#PAPERLESS_SOCIAL_AUTO_SIGNUP}
: Attempt to signup the user using retrieved email, username etc from the third party authentication
system. See the corresponding
[django-allauth documentation](https://docs.allauth.org/en/0.60.0/socialaccount/configuration.html)
Defaults to False
#### [`PAPERLESS_SOCIALACCOUNT_ALLOW_SIGNUPS=<bool>`](#PAPERLESS_SOCIALACCOUNT_ALLOW_SIGNUPS) {#PAPERLESS_SOCIALACCOUNT_ALLOW_SIGNUPS}
: Allow users to signup for a new Paperless-ngx account using any setup third party authentication systems.
Defaults to True
#### [`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_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
[django-allauth documentation](https://docs.allauth.org/en/latest/account/configuration.html)
Defaults to 'https'
## OCR settings {#ocr}
Paperless uses [OCRmyPDF](https://ocrmypdf.readthedocs.io/en/latest/)
@ -905,6 +941,14 @@ documents.
Default is none, which disables the temporary directory.
#### [`PAPERLESS_APPS=<string>`](#PAPERLESS_APPS) {#PAPERLESS_APPS}
: A comma-separated list of Django apps to be included in Django's
[`INSTALLED_APPS`](https://docs.djangoproject.com/en/5.0/ref/applications/). This setting should
be used with caution!
Defaults to None, which does not add any additional apps.
## Document Consumption {#consume_config}
#### [`PAPERLESS_CONSUMER_DELETE_DUPLICATES=<bool>`](#PAPERLESS_CONSUMER_DELETE_DUPLICATES) {#PAPERLESS_CONSUMER_DELETE_DUPLICATES}
@ -1173,6 +1217,55 @@ combination with PAPERLESS_CONSUMER_BARCODE_UPSCALE bigger than 1.0.
Defaults to "300"
#### [`PAPERLESS_CONSUMER_ENABLE_TAG_BARCODE=<bool>`](#PAPERLESS_CONSUMER_ENABLE_TAG_BARCODE) {#PAPERLESS_CONSUMER_ENABLE_TAG_BARCODE}
: Enables the detection of barcodes in the scanned document and
assigns or creates tags if a properly formatted barcode is detected.
The barcode must match one of the (configurable) regular expressions.
If the barcode text contains ',' (comma), it is split into multiple
barcodes which are individually processed for tagging.
Matching is case insensitive.
Defaults to false.
#### [`PAPERLESS_CONSUMER_TAG_BARCODE_MAPPING=<json dict>`](#PAPERLESS_CONSUMER_TAG_BARCODE_MAPPING) {#PAPERLESS_CONSUMER_TAG_BARCODE_MAPPING}
: Defines a dictionary of filter regex and substitute expressions.
Syntax: {"<regex>": "<substitute>" [,...]]}
A barcode is considered for tagging if the barcode text matches
at least one of the provided <regex> pattern.
If a match is found, the <substitute> rule is applied. This allows very
versatile reformatting and mapping of barcode pattern to tag values.
If a tag is not found it will be created.
Defaults to:
{"TAG:(.*)": "\\g<1>"} which defines
- a regex TAG:(.*) which includes barcodes beginning with TAG:
followed by any text that gets stored into match group #1 and
- a substitute \\g<1> that replaces the original barcode text
by the content in match group #1.
Consequently, the tag is the barcode text without its TAG: prefix.
More examples:
{"ASN12.*": "JOHN", "ASN13.*": "SMITH"} for example maps
- ASN12nnnn barcodes to the tag JOHN and
- ASN13nnnn barcodes to the tag SMITH.
{"T-J": "JOHN", "T-S": "SMITH", "T-D": "DOE"} directly maps
- T-J barcodes to the tag JOHN,
- T-S barcodes to the tag SMITH and
- T-D barcodes to the tag DOE.
Please refer to the Python regex documentation for more information.
## Audit Trail
#### [`PAPERLESS_AUDIT_LOG_ENABLED=<bool>`](#PAPERLESS_AUDIT_LOG_ENABLED) {#PAPERLESS_AUDIT_LOG_ENABLED}

View File

@ -68,6 +68,8 @@
#PAPERLESS_CONSUMER_BARCODE_STRING=PATCHT
#PAPERLESS_CONSUMER_BARCODE_UPSCALE=0.0
#PAPERLESS_CONSUMER_BARCODE_DPI=300
#PAPERLESS_CONSUMER_ENABLE_TAG_BARCODE=false
#PAPERLESS_CONSUMER_TAG_BARCODE_MAPPING={"TAG:(.*)": "\\g<1>"}
#PAPERLESS_CONSUMER_ENABLE_COLLATE_DOUBLE_SIDED=false
#PAPERLESS_CONSUMER_COLLATE_DOUBLE_SIDED_SUBDIR_NAME=double-sided
#PAPERLESS_CONSUMER_COLLATE_DOUBLE_SIDED_TIFF_SUPPORT=false

View File

@ -458,7 +458,7 @@
</context-group>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/admin/settings/settings.component.html</context>
<context context-type="linenumber">354</context>
<context context-type="linenumber">346</context>
</context-group>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/common/edit-dialog/correspondent-edit-dialog/correspondent-edit-dialog.component.html</context>
@ -498,11 +498,11 @@
</context-group>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.html</context>
<context context-type="linenumber">167</context>
<context context-type="linenumber">159</context>
</context-group>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/common/profile-edit-dialog/profile-edit-dialog.component.html</context>
<context context-type="linenumber">99</context>
<context context-type="linenumber">92</context>
</context-group>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.html</context>
@ -600,7 +600,7 @@
</context-group>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/admin/settings/settings.component.html</context>
<context context-type="linenumber">342</context>
<context context-type="linenumber">334</context>
</context-group>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/admin/tasks/tasks.component.html</context>
@ -1048,11 +1048,11 @@
</context-group>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.html</context>
<context context-type="linenumber">112</context>
<context context-type="linenumber">104</context>
</context-group>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.html</context>
<context context-type="linenumber">131</context>
<context context-type="linenumber">123</context>
</context-group>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/common/input/permissions/permissions-form/permissions-form.component.html</context>
@ -1075,11 +1075,11 @@
</context-group>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.html</context>
<context context-type="linenumber">120</context>
<context context-type="linenumber">112</context>
</context-group>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.html</context>
<context context-type="linenumber">139</context>
<context context-type="linenumber">131</context>
</context-group>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/common/input/permissions/permissions-form/permissions-form.component.html</context>
@ -1105,7 +1105,7 @@
</context-group>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.html</context>
<context context-type="linenumber">145</context>
<context context-type="linenumber">137</context>
</context-group>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/common/input/permissions/permissions-form/permissions-form.component.html</context>
@ -1346,7 +1346,7 @@
</context-group>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.html</context>
<context context-type="linenumber">64</context>
<context context-type="linenumber">60</context>
</context-group>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.html</context>
@ -1393,7 +1393,7 @@
<source>Delete</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/admin/settings/settings.component.html</context>
<context context-type="linenumber">324</context>
<context context-type="linenumber">322</context>
</context-group>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/admin/users-groups/users-groups.component.html</context>
@ -1413,7 +1413,7 @@
</context-group>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.html</context>
<context context-type="linenumber">84</context>
<context context-type="linenumber">80</context>
</context-group>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/common/permissions-select/permissions-select.component.html</context>
@ -1475,6 +1475,10 @@
<context context-type="sourcefile">src/app/components/manage/management-list/management-list.component.html</context>
<context context-type="linenumber">93</context>
</context-group>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/manage/management-list/management-list.component.ts</context>
<context context-type="linenumber">205</context>
</context-group>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/manage/workflows/workflows.component.html</context>
<context context-type="linenumber">38</context>
@ -1484,7 +1488,7 @@
<source>No saved views defined.</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/admin/settings/settings.component.html</context>
<context context-type="linenumber">336</context>
<context context-type="linenumber">328</context>
</context-group>
</trans-unit>
<trans-unit id="6839066544204061364" datatype="html">
@ -2433,13 +2437,6 @@
<context context-type="linenumber">55</context>
</context-group>
</trans-unit>
<trans-unit id="7515883357904500238" datatype="html">
<source>Are you sure?</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/common/confirm-button/confirm-button.component.ts</context>
<context context-type="linenumber">20</context>
</context-group>
</trans-unit>
<trans-unit id="1234709746630139322" datatype="html">
<source>Confirmation</source>
<context-group purpose="location">
@ -2518,7 +2515,7 @@
</context-group>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.html</context>
<context context-type="linenumber">166</context>
<context context-type="linenumber">158</context>
</context-group>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/common/permissions-dialog/permissions-dialog.component.html</context>
@ -2526,7 +2523,7 @@
</context-group>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/common/profile-edit-dialog/profile-edit-dialog.component.html</context>
<context context-type="linenumber">98</context>
<context context-type="linenumber">91</context>
</context-group>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/common/select-dialog/select-dialog.component.html</context>
@ -2702,7 +2699,7 @@
</context-group>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.html</context>
<context context-type="linenumber">190</context>
<context context-type="linenumber">182</context>
</context-group>
</trans-unit>
<trans-unit id="6457471243969293847" datatype="html">
@ -3068,7 +3065,7 @@
</context-group>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.html</context>
<context context-type="linenumber">100</context>
<context context-type="linenumber">92</context>
</context-group>
</trans-unit>
<trans-unit id="4754802869258527587" datatype="html">
@ -3086,7 +3083,7 @@
</context-group>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.html</context>
<context context-type="linenumber">101</context>
<context context-type="linenumber">93</context>
</context-group>
</trans-unit>
<trans-unit id="5232720756589450549" datatype="html">
@ -3104,7 +3101,7 @@
</context-group>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.html</context>
<context context-type="linenumber">164</context>
<context context-type="linenumber">156</context>
</context-group>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/common/toasts/toasts.component.html</context>
@ -3438,175 +3435,175 @@
<source>Apply Actions:</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.html</context>
<context context-type="linenumber">70</context>
<context context-type="linenumber">66</context>
</context-group>
</trans-unit>
<trans-unit id="51883444329775670" datatype="html">
<source>Add Action</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.html</context>
<context context-type="linenumber">72</context>
<context context-type="linenumber">68</context>
</context-group>
</trans-unit>
<trans-unit id="6417103744331194518" datatype="html">
<source>Action type</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.html</context>
<context context-type="linenumber">94</context>
<context context-type="linenumber">86</context>
</context-group>
</trans-unit>
<trans-unit id="6019822389883736115" datatype="html">
<source>Assign title</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.html</context>
<context context-type="linenumber">98</context>
<context context-type="linenumber">90</context>
</context-group>
</trans-unit>
<trans-unit id="1098196422099517191" datatype="html">
<source>Can include some placeholders, see &lt;a target=&apos;_blank&apos; href=&apos;https://docs.paperless-ngx.com/usage/#workflows&apos;&gt;documentation&lt;/a&gt;.</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.html</context>
<context context-type="linenumber">98</context>
<context context-type="linenumber">90</context>
</context-group>
</trans-unit>
<trans-unit id="6528897010417701530" datatype="html">
<source>Assign tags</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.html</context>
<context context-type="linenumber">99</context>
<context context-type="linenumber">91</context>
</context-group>
</trans-unit>
<trans-unit id="7198346314713788799" datatype="html">
<source>Assign storage path</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.html</context>
<context context-type="linenumber">102</context>
<context context-type="linenumber">94</context>
</context-group>
</trans-unit>
<trans-unit id="475685412372379925" datatype="html">
<source>Assign custom fields</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.html</context>
<context context-type="linenumber">103</context>
<context context-type="linenumber">95</context>
</context-group>
</trans-unit>
<trans-unit id="5057200219587080996" datatype="html">
<source>Assign owner</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.html</context>
<context context-type="linenumber">106</context>
<context context-type="linenumber">98</context>
</context-group>
</trans-unit>
<trans-unit id="1749184201773078639" datatype="html">
<source>Assign view permissions</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.html</context>
<context context-type="linenumber">108</context>
<context context-type="linenumber">100</context>
</context-group>
</trans-unit>
<trans-unit id="1744964187586405039" datatype="html">
<source>Assign edit permissions</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.html</context>
<context context-type="linenumber">127</context>
<context context-type="linenumber">119</context>
</context-group>
</trans-unit>
<trans-unit id="3288318211116868972" datatype="html">
<source>Trigger type</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.html</context>
<context context-type="linenumber">174</context>
<context context-type="linenumber">166</context>
</context-group>
</trans-unit>
<trans-unit id="8727727835543352574" datatype="html">
<source>Trigger for documents that match <x id="START_EMPHASISED_TEXT" ctype="x-em" equiv-text="&lt;em&gt;"/>all<x id="CLOSE_EMPHASISED_TEXT" ctype="x-em" equiv-text="&lt;/em&gt;"/> filters specified below.</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.html</context>
<context context-type="linenumber">175</context>
<context context-type="linenumber">167</context>
</context-group>
</trans-unit>
<trans-unit id="7467799586957602479" datatype="html">
<source>Filter filename</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.html</context>
<context context-type="linenumber">178</context>
<context context-type="linenumber">170</context>
</context-group>
</trans-unit>
<trans-unit id="3694878959415278689" datatype="html">
<source>Apply to documents that match this filename. Wildcards such as *.pdf or *invoice* are allowed. Case insensitive.</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.html</context>
<context context-type="linenumber">178</context>
<context context-type="linenumber">170</context>
</context-group>
</trans-unit>
<trans-unit id="1473412958770421458" datatype="html">
<source>Filter sources</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.html</context>
<context context-type="linenumber">180</context>
<context context-type="linenumber">172</context>
</context-group>
</trans-unit>
<trans-unit id="6540860478788535250" datatype="html">
<source>Filter path</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.html</context>
<context context-type="linenumber">181</context>
<context context-type="linenumber">173</context>
</context-group>
</trans-unit>
<trans-unit id="5491897741674893121" datatype="html">
<source>Apply to documents that match this path. Wildcards specified as * are allowed. Case-normalized.&lt;/a&gt;</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.html</context>
<context context-type="linenumber">181</context>
<context context-type="linenumber">173</context>
</context-group>
</trans-unit>
<trans-unit id="7468453896129193641" datatype="html">
<source>Filter mail rule</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.html</context>
<context context-type="linenumber">182</context>
<context context-type="linenumber">174</context>
</context-group>
</trans-unit>
<trans-unit id="8663702115863339485" datatype="html">
<source>Apply to documents consumed via this mail rule.</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.html</context>
<context context-type="linenumber">182</context>
<context context-type="linenumber">174</context>
</context-group>
</trans-unit>
<trans-unit id="6840369584127435743" datatype="html">
<source>Content matching algorithm</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.html</context>
<context context-type="linenumber">185</context>
<context context-type="linenumber">177</context>
</context-group>
</trans-unit>
<trans-unit id="510635115034690805" datatype="html">
<source>Content matching pattern</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.html</context>
<context context-type="linenumber">187</context>
<context context-type="linenumber">179</context>
</context-group>
</trans-unit>
<trans-unit id="1333789258712064056" datatype="html">
<source>Has tags</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.html</context>
<context context-type="linenumber">196</context>
<context context-type="linenumber">188</context>
</context-group>
</trans-unit>
<trans-unit id="5281365940563983618" datatype="html">
<source>Has correspondent</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.html</context>
<context context-type="linenumber">197</context>
<context context-type="linenumber">189</context>
</context-group>
</trans-unit>
<trans-unit id="4806713133917046341" datatype="html">
<source>Has document type</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.html</context>
<context context-type="linenumber">198</context>
<context context-type="linenumber">190</context>
</context-group>
</trans-unit>
<trans-unit id="4626030417479279989" datatype="html">
@ -4085,14 +4082,14 @@
<source>Regenerate auth token</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/common/profile-edit-dialog/profile-edit-dialog.component.html</context>
<context context-type="linenumber">45</context>
<context context-type="linenumber">44</context>
</context-group>
</trans-unit>
<trans-unit id="5392341774767336507" datatype="html">
<source>Copied!</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/common/profile-edit-dialog/profile-edit-dialog.component.html</context>
<context context-type="linenumber">53</context>
<context context-type="linenumber">48</context>
</context-group>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/common/share-links-dropdown/share-links-dropdown.component.html</context>
@ -4103,21 +4100,28 @@
<source>Warning: changing the token cannot be undone</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/common/profile-edit-dialog/profile-edit-dialog.component.html</context>
<context context-type="linenumber">55</context>
<context context-type="linenumber">50</context>
</context-group>
</trans-unit>
<trans-unit id="8935717557476105185" datatype="html">
<source>Connected social accounts</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/common/profile-edit-dialog/profile-edit-dialog.component.html</context>
<context context-type="linenumber">59</context>
<context context-type="linenumber">54</context>
</context-group>
</trans-unit>
<trans-unit id="8383227756109993898" datatype="html">
<source>Set a password before disconnecting social account.</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/common/profile-edit-dialog/profile-edit-dialog.component.html</context>
<context context-type="linenumber">63</context>
<context context-type="linenumber">58</context>
</context-group>
</trans-unit>
<trans-unit id="5322995394400578831" datatype="html">
<source>Disconnect <x id="INTERPOLATION" equiv-text="{{ account.name }}"/> social account</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/common/profile-edit-dialog/profile-edit-dialog.component.html</context>
<context context-type="linenumber">68</context>
</context-group>
</trans-unit>
<trans-unit id="2907016025519254862" datatype="html">
@ -4127,25 +4131,18 @@
<context context-type="linenumber">69</context>
</context-group>
</trans-unit>
<trans-unit id="5322995394400578831" datatype="html">
<source>Disconnect <x id="INTERPOLATION" equiv-text="{{ account.name }}"/> social account</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/common/profile-edit-dialog/profile-edit-dialog.component.html</context>
<context context-type="linenumber">71</context>
</context-group>
</trans-unit>
<trans-unit id="649824314893051979" datatype="html">
<source>Warning: disconnecting social accounts cannot be undone</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/common/profile-edit-dialog/profile-edit-dialog.component.html</context>
<context context-type="linenumber">81</context>
<context context-type="linenumber">74</context>
</context-group>
</trans-unit>
<trans-unit id="1375396510511350122" datatype="html">
<source>Connect new social account</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/common/profile-edit-dialog/profile-edit-dialog.component.html</context>
<context context-type="linenumber">86</context>
<context context-type="linenumber">79</context>
</context-group>
</trans-unit>
<trans-unit id="6141884091799403188" datatype="html">
@ -4995,6 +4992,10 @@
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
<context context-type="linenumber">717</context>
</context-group>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/manage/management-list/management-list.component.ts</context>
<context context-type="linenumber">201</context>
</context-group>
</trans-unit>
<trans-unit id="5382975254277698192" datatype="html">
<source>Do you really want to delete document &quot;<x id="PH" equiv-text="this.document.title"/>&quot;?</source>
@ -6187,7 +6188,7 @@
</context-group>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/manage/management-list/management-list.component.ts</context>
<context context-type="linenumber">285</context>
<context context-type="linenumber">301</context>
</context-group>
</trans-unit>
<trans-unit id="4010735610815226758" datatype="html">
@ -6270,26 +6271,26 @@
<source>{VAR_PLURAL, plural, =1 {One <x id="INTERPOLATION"/>} other {<x id="INTERPOLATION_1"/> total <x id="INTERPOLATION_2"/>}}</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/manage/management-list/management-list.component.html</context>
<context context-type="linenumber">113</context>
<context context-type="linenumber">107</context>
</context-group>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/manage/management-list/management-list.component.html</context>
<context context-type="linenumber">113</context>
<context context-type="linenumber">107</context>
</context-group>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/manage/management-list/management-list.component.html</context>
<context context-type="linenumber">113</context>
<context context-type="linenumber">107</context>
</context-group>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/manage/management-list/management-list.component.html</context>
<context context-type="linenumber">113</context>
<context context-type="linenumber">107</context>
</context-group>
</trans-unit>
<trans-unit id="810888510148304696" datatype="html">
<source>Automatic</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/manage/management-list/management-list.component.ts</context>
<context context-type="linenumber">110</context>
<context context-type="linenumber">113</context>
</context-group>
<context-group purpose="location">
<context context-type="sourcefile">src/app/data/matching-model.ts</context>
@ -6300,7 +6301,7 @@
<source>None</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/manage/management-list/management-list.component.ts</context>
<context context-type="linenumber">112</context>
<context context-type="linenumber">115</context>
</context-group>
<context-group purpose="location">
<context context-type="sourcefile">src/app/data/matching-model.ts</context>
@ -6311,42 +6312,49 @@
<source>Successfully created <x id="PH" equiv-text="this.typeName"/>.</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/manage/management-list/management-list.component.ts</context>
<context context-type="linenumber">155</context>
<context context-type="linenumber">158</context>
</context-group>
</trans-unit>
<trans-unit id="3928835053823658072" datatype="html">
<source>Error occurred while creating <x id="PH" equiv-text="this.typeName"/>.</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/manage/management-list/management-list.component.ts</context>
<context context-type="linenumber">160</context>
<context context-type="linenumber">163</context>
</context-group>
</trans-unit>
<trans-unit id="2541368547549828690" datatype="html">
<source>Successfully updated <x id="PH" equiv-text="this.typeName"/>.</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/manage/management-list/management-list.component.ts</context>
<context context-type="linenumber">175</context>
<context context-type="linenumber">178</context>
</context-group>
</trans-unit>
<trans-unit id="6442673774206210733" datatype="html">
<source>Error occurred while saving <x id="PH" equiv-text="this.typeName"/>.</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/manage/management-list/management-list.component.ts</context>
<context context-type="linenumber">180</context>
<context context-type="linenumber">183</context>
</context-group>
</trans-unit>
<trans-unit id="8371896857609524947" datatype="html">
<source>Associated documents will not be deleted.</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/manage/management-list/management-list.component.ts</context>
<context context-type="linenumber">203</context>
</context-group>
</trans-unit>
<trans-unit id="6639207128255974941" datatype="html">
<source>Error while deleting element</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/manage/management-list/management-list.component.ts</context>
<context context-type="linenumber">204</context>
<context context-type="linenumber">219</context>
</context-group>
</trans-unit>
<trans-unit id="4863024195229581844" datatype="html">
<source>Permissions updated successfully</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/manage/management-list/management-list.component.ts</context>
<context context-type="linenumber">278</context>
<context context-type="linenumber">294</context>
</context-group>
</trans-unit>
<trans-unit id="5101757640976222639" datatype="html">

View File

@ -43,9 +43,9 @@
</li>
}
</ul>
}
}
@if (groups) {
@if (groups) {
<h4 class="mt-4 d-flex">
<ng-container i18n>Groups</ng-container>
<button type="button" class="btn btn-sm btn-outline-primary ms-4" (click)="editGroup()" *pngxIfPermissions="{ action: PermissionAction.Add, type: PermissionType.Group }">
@ -86,11 +86,11 @@
}
</ul>
}
}
}
@if (!users || !groups) {
@if (!users || !groups) {
<div>
<div class="spinner-border spinner-border-sm fw-normal ms-2 me-auto" role="status"></div>
<div class="visually-hidden" i18n>Loading...</div>
</div>
}
}

View File

@ -45,11 +45,19 @@
</div>
}
@if (editing) {
@if ((selectionModel.itemsSorted | filter: filterText).length === 0 && createRef !== undefined) {
<button class="list-group-item list-group-item-action bg-light" (click)="createClicked()" [disabled]="disabled">
<small class="ms-2"><ng-container i18n>Create</ng-container> "{{filterText}}"</small>
<i-bs width="1.5em" height="1em" name="plus"></i-bs>
</button>
}
@if ((selectionModel.itemsSorted | filter: filterText).length > 0) {
<button class="list-group-item list-group-item-action bg-light" (click)="applyClicked()" [disabled]="!modelIsDirty || disabled">
<small class="ms-2" [ngClass]="{'fw-bold': modelIsDirty}" i18n>Apply</small>
<i-bs width="1.5em" height="1em" name="arrow-right"></i-bs>
</button>
}
}
@if (!editing && manyToOne) {
<div class="list-group-item list-group-item-note pt-1 pb-2">
<small i18n>Click again to exclude items.</small>

View File

@ -500,4 +500,46 @@ describe('FilterableDropdownComponent & FilterableDropdownSelectionModel', () =>
selectionModel.apply()
expect(selectionModel.itemsSorted).toEqual([nullItem, items[1], items[0]])
})
it('should set support create, keep open model and call createRef method', fakeAsync(() => {
component.items = items
component.icon = 'tag-fill'
component.selectionModel = selectionModel
fixture.nativeElement
.querySelector('button')
.dispatchEvent(new MouseEvent('click')) // open
fixture.detectChanges()
tick(100)
component.filterText = 'Test Filter Text'
component.createRef = jest.fn()
component.createClicked()
expect(component.creating).toBeTruthy()
expect(component.createRef).toHaveBeenCalledWith('Test Filter Text')
const openSpy = jest.spyOn(component.dropdown, 'open')
component.dropdownOpenChange(false)
expect(openSpy).toHaveBeenCalled() // should keep open
}))
it('should call create on enter inside filter field if 0 items remain while editing', fakeAsync(() => {
component.items = items
component.icon = 'tag-fill'
component.editing = true
component.createRef = jest.fn()
const createSpy = jest.spyOn(component, 'createClicked')
expect(component.selectionModel.getSelectedItems()).toEqual([])
fixture.nativeElement
.querySelector('button')
.dispatchEvent(new MouseEvent('click')) // open
fixture.detectChanges()
tick(100)
component.filterText = 'FooBar'
fixture.detectChanges()
component.listFilterTextInput.nativeElement.dispatchEvent(
new KeyboardEvent('keyup', { key: 'Enter' })
)
expect(component.selectionModel.getSelectedItems()).toEqual([])
tick(300)
expect(createSpy).toHaveBeenCalled()
}))
})

View File

@ -398,6 +398,11 @@ export class FilterableDropdownComponent {
@Input()
disabled = false
@Input()
createRef: (name) => void
creating: boolean = false
@Output()
apply = new EventEmitter<ChangedItems>()
@ -437,6 +442,11 @@ export class FilterableDropdownComponent {
}
}
createClicked() {
this.creating = true
this.createRef(this.filterText)
}
dropdownOpenChange(open: boolean): void {
if (open) {
setTimeout(() => {
@ -447,6 +457,10 @@ export class FilterableDropdownComponent {
this.modelIsDirty = false
}
this.opened.next(this)
} else {
if (this.creating) {
this.dropdown.open()
this.creating = false
} else {
this.filterText = ''
if (this.applyOnClose && this.selectionModel.isDirty()) {
@ -454,6 +468,7 @@ export class FilterableDropdownComponent {
}
}
}
}
listFilterEnter(): void {
let filtered = this.filterPipe.transform(this.items, this.filterText)
@ -466,6 +481,8 @@ export class FilterableDropdownComponent {
this.dropdown.close()
}
}, 200)
} else if (filtered.length == 0 && this.createRef) {
this.createClicked()
}
}

View File

@ -25,6 +25,7 @@
[editing]="true"
[manyToOne]="true"
[applyOnClose]="applyOnClose"
[createRef]="createTag.bind(this)"
(opened)="openTagsDropdown()"
[(selectionModel)]="tagSelectionModel"
[documentCounts]="tagDocumentCounts"
@ -38,6 +39,7 @@
[disabled]="!userCanEditAll"
[editing]="true"
[applyOnClose]="applyOnClose"
[createRef]="createCorrespondent.bind(this)"
(opened)="openCorrespondentDropdown()"
[(selectionModel)]="correspondentSelectionModel"
[documentCounts]="correspondentDocumentCounts"
@ -51,6 +53,7 @@
[disabled]="!userCanEditAll"
[editing]="true"
[applyOnClose]="applyOnClose"
[createRef]="createDocumentType.bind(this)"
(opened)="openDocumentTypeDropdown()"
[(selectionModel)]="documentTypeSelectionModel"
[documentCounts]="documentTypeDocumentCounts"
@ -64,6 +67,7 @@
[disabled]="!userCanEditAll"
[editing]="true"
[applyOnClose]="applyOnClose"
[createRef]="createStoragePath.bind(this)"
(opened)="openStoragePathDropdown()"
[(selectionModel)]="storagePathsSelectionModel"
[documentCounts]="storagePathDocumentCounts"

View File

@ -42,6 +42,16 @@ import { NgSelectModule } from '@ng-select/ng-select'
import { GroupService } from 'src/app/services/rest/group.service'
import { NgxBootstrapIconsModule, allIcons } from 'ngx-bootstrap-icons'
import { SwitchComponent } from '../../common/input/switch/switch.component'
import { EditDialogMode } from '../../common/edit-dialog/edit-dialog.component'
import { TagEditDialogComponent } from '../../common/edit-dialog/tag-edit-dialog/tag-edit-dialog.component'
import { Results } from 'src/app/data/results'
import { Tag } from 'src/app/data/tag'
import { Correspondent } from 'src/app/data/correspondent'
import { DocumentType } from 'src/app/data/document-type'
import { StoragePath } from 'src/app/data/storage-path'
import { CorrespondentEditDialogComponent } from '../../common/edit-dialog/correspondent-edit-dialog/correspondent-edit-dialog.component'
import { DocumentTypeEditDialogComponent } from '../../common/edit-dialog/document-type-edit-dialog/document-type-edit-dialog.component'
import { StoragePathEditDialogComponent } from '../../common/edit-dialog/storage-path-edit-dialog/storage-path-edit-dialog.component'
const selectionData: SelectionData = {
selected_tags: [
@ -65,6 +75,10 @@ describe('BulkEditorComponent', () => {
let documentService: DocumentService
let toastService: ToastService
let modalService: NgbModal
let tagService: TagService
let correspondentsService: CorrespondentService
let documentTypeService: DocumentTypeService
let storagePathService: StoragePathService
let httpTestingController: HttpTestingController
beforeEach(async () => {
@ -165,6 +179,10 @@ describe('BulkEditorComponent', () => {
documentService = TestBed.inject(DocumentService)
toastService = TestBed.inject(ToastService)
modalService = TestBed.inject(NgbModal)
tagService = TestBed.inject(TagService)
correspondentsService = TestBed.inject(CorrespondentService)
documentTypeService = TestBed.inject(DocumentTypeService)
storagePathService = TestBed.inject(StoragePathService)
httpTestingController = TestBed.inject(HttpTestingController)
fixture = TestBed.createComponent(BulkEditorComponent)
@ -902,4 +920,180 @@ describe('BulkEditorComponent', () => {
`${environment.apiBaseUrl}documents/storage_paths/`
)
})
it('should support create new tag', () => {
const name = 'New Tag'
const newTag = { id: 101, name: 'New Tag' }
const tags: Results<Tag> = {
results: [
{ id: 1, name: 'Tag 1' },
{ id: 2, name: 'Tag 2' },
],
count: 2,
all: [1, 2],
}
const modalInstance = {
componentInstance: {
dialogMode: EditDialogMode.CREATE,
object: { name },
succeeded: of(newTag),
},
}
const tagListAllSpy = jest.spyOn(tagService, 'listAll')
tagListAllSpy.mockReturnValue(of(tags))
const tagSelectionModelToggleSpy = jest.spyOn(
component.tagSelectionModel,
'toggle'
)
const modalServiceOpenSpy = jest.spyOn(modalService, 'open')
modalServiceOpenSpy.mockReturnValue(modalInstance as any)
component.createTag(name)
expect(modalServiceOpenSpy).toHaveBeenCalledWith(TagEditDialogComponent, {
backdrop: 'static',
})
expect(tagListAllSpy).toHaveBeenCalled()
expect(tagSelectionModelToggleSpy).toHaveBeenCalledWith(newTag.id)
expect(component.tags).toEqual(tags.results)
})
it('should support create new correspondent', () => {
const name = 'New Correspondent'
const newCorrespondent = { id: 101, name: 'New Correspondent' }
const correspondents: Results<Correspondent> = {
results: [
{ id: 1, name: 'Correspondent 1' },
{ id: 2, name: 'Correspondent 2' },
],
count: 2,
all: [1, 2],
}
const modalInstance = {
componentInstance: {
dialogMode: EditDialogMode.CREATE,
object: { name },
succeeded: of(newCorrespondent),
},
}
const correspondentsListAllSpy = jest.spyOn(
correspondentsService,
'listAll'
)
correspondentsListAllSpy.mockReturnValue(of(correspondents))
const correspondentSelectionModelToggleSpy = jest.spyOn(
component.correspondentSelectionModel,
'toggle'
)
const modalServiceOpenSpy = jest.spyOn(modalService, 'open')
modalServiceOpenSpy.mockReturnValue(modalInstance as any)
component.createCorrespondent(name)
expect(modalServiceOpenSpy).toHaveBeenCalledWith(
CorrespondentEditDialogComponent,
{ backdrop: 'static' }
)
expect(correspondentsListAllSpy).toHaveBeenCalled()
expect(correspondentSelectionModelToggleSpy).toHaveBeenCalledWith(
newCorrespondent.id
)
expect(component.correspondents).toEqual(correspondents.results)
})
it('should support create new document type', () => {
const name = 'New Document Type'
const newDocumentType = { id: 101, name: 'New Document Type' }
const documentTypes: Results<DocumentType> = {
results: [
{ id: 1, name: 'Document Type 1' },
{ id: 2, name: 'Document Type 2' },
],
count: 2,
all: [1, 2],
}
const modalInstance = {
componentInstance: {
dialogMode: EditDialogMode.CREATE,
object: { name },
succeeded: of(newDocumentType),
},
}
const documentTypesListAllSpy = jest.spyOn(documentTypeService, 'listAll')
documentTypesListAllSpy.mockReturnValue(of(documentTypes))
const documentTypeSelectionModelToggleSpy = jest.spyOn(
component.documentTypeSelectionModel,
'toggle'
)
const modalServiceOpenSpy = jest.spyOn(modalService, 'open')
modalServiceOpenSpy.mockReturnValue(modalInstance as any)
component.createDocumentType(name)
expect(modalServiceOpenSpy).toHaveBeenCalledWith(
DocumentTypeEditDialogComponent,
{ backdrop: 'static' }
)
expect(documentTypesListAllSpy).toHaveBeenCalled()
expect(documentTypeSelectionModelToggleSpy).toHaveBeenCalledWith(
newDocumentType.id
)
expect(component.documentTypes).toEqual(documentTypes.results)
})
it('should support create new storage path', () => {
const name = 'New Storage Path'
const newStoragePath = { id: 101, name: 'New Storage Path' }
const storagePaths: Results<StoragePath> = {
results: [
{ id: 1, name: 'Storage Path 1' },
{ id: 2, name: 'Storage Path 2' },
],
count: 2,
all: [1, 2],
}
const modalInstance = {
componentInstance: {
dialogMode: EditDialogMode.CREATE,
object: { name },
succeeded: of(newStoragePath),
},
}
const storagePathsListAllSpy = jest.spyOn(storagePathService, 'listAll')
storagePathsListAllSpy.mockReturnValue(of(storagePaths))
const storagePathsSelectionModelToggleSpy = jest.spyOn(
component.storagePathsSelectionModel,
'toggle'
)
const modalServiceOpenSpy = jest.spyOn(modalService, 'open')
modalServiceOpenSpy.mockReturnValue(modalInstance as any)
component.createStoragePath(name)
expect(modalServiceOpenSpy).toHaveBeenCalledWith(
StoragePathEditDialogComponent,
{ backdrop: 'static' }
)
expect(storagePathsListAllSpy).toHaveBeenCalled()
expect(storagePathsSelectionModelToggleSpy).toHaveBeenCalledWith(
newStoragePath.id
)
expect(component.storagePaths).toEqual(storagePaths.results)
})
})

View File

@ -33,7 +33,12 @@ import {
PermissionType,
} from 'src/app/services/permissions.service'
import { FormControl, FormGroup } from '@angular/forms'
import { first, Subject, takeUntil } from 'rxjs'
import { first, map, Subject, switchMap, takeUntil } from 'rxjs'
import { CorrespondentEditDialogComponent } from '../../common/edit-dialog/correspondent-edit-dialog/correspondent-edit-dialog.component'
import { EditDialogMode } from '../../common/edit-dialog/edit-dialog.component'
import { TagEditDialogComponent } from '../../common/edit-dialog/tag-edit-dialog/tag-edit-dialog.component'
import { DocumentTypeEditDialogComponent } from '../../common/edit-dialog/document-type-edit-dialog/document-type-edit-dialog.component'
import { StoragePathEditDialogComponent } from '../../common/edit-dialog/storage-path-edit-dialog/storage-path-edit-dialog.component'
@Component({
selector: 'pngx-bulk-editor',
@ -479,6 +484,92 @@ export class BulkEditorComponent
}
}
createTag(name: string) {
let modal = this.modalService.open(TagEditDialogComponent, {
backdrop: 'static',
})
modal.componentInstance.dialogMode = EditDialogMode.CREATE
modal.componentInstance.object = { name }
modal.componentInstance.succeeded
.pipe(
switchMap((newTag) => {
return this.tagService
.listAll()
.pipe(map((tags) => ({ newTag, tags })))
})
)
.pipe(takeUntil(this.unsubscribeNotifier))
.subscribe(({ newTag, tags }) => {
this.tags = tags.results
this.tagSelectionModel.toggle(newTag.id)
})
}
createCorrespondent(name: string) {
let modal = this.modalService.open(CorrespondentEditDialogComponent, {
backdrop: 'static',
})
modal.componentInstance.dialogMode = EditDialogMode.CREATE
modal.componentInstance.object = { name }
modal.componentInstance.succeeded
.pipe(
switchMap((newCorrespondent) => {
return this.correspondentService
.listAll()
.pipe(
map((correspondents) => ({ newCorrespondent, correspondents }))
)
})
)
.pipe(takeUntil(this.unsubscribeNotifier))
.subscribe(({ newCorrespondent, correspondents }) => {
this.correspondents = correspondents.results
this.correspondentSelectionModel.toggle(newCorrespondent.id)
})
}
createDocumentType(name: string) {
let modal = this.modalService.open(DocumentTypeEditDialogComponent, {
backdrop: 'static',
})
modal.componentInstance.dialogMode = EditDialogMode.CREATE
modal.componentInstance.object = { name }
modal.componentInstance.succeeded
.pipe(
switchMap((newDocumentType) => {
return this.documentTypeService
.listAll()
.pipe(map((documentTypes) => ({ newDocumentType, documentTypes })))
})
)
.pipe(takeUntil(this.unsubscribeNotifier))
.subscribe(({ newDocumentType, documentTypes }) => {
this.documentTypes = documentTypes.results
this.documentTypeSelectionModel.toggle(newDocumentType.id)
})
}
createStoragePath(name: string) {
let modal = this.modalService.open(StoragePathEditDialogComponent, {
backdrop: 'static',
})
modal.componentInstance.dialogMode = EditDialogMode.CREATE
modal.componentInstance.object = { name }
modal.componentInstance.succeeded
.pipe(
switchMap((newStoragePath) => {
return this.storagePathService
.listAll()
.pipe(map((storagePaths) => ({ newStoragePath, storagePaths })))
})
)
.pipe(takeUntil(this.unsubscribeNotifier))
.subscribe(({ newStoragePath, storagePaths }) => {
this.storagePaths = storagePaths.results
this.storagePathsSelectionModel.toggle(newStoragePath.id)
})
}
applyDelete() {
let modal = this.modalService.open(ConfirmDialogComponent, {
backdrop: 'static',

View File

@ -25,7 +25,7 @@
@if (notesEnabled && document.notes.length) {
<a routerLink="/documents/{{document.id}}/notes" class="document-card-notes py-2 px-1">
<span class="badge rounded-pill bg-light border text-primary">
<i-bs width="0.9rem" height="0.9rem" class="ms-1 me-1" name="chat-left-text"></i-bs>
<i-bs width="1.2em" height="1.2em" class="ms-1 me-1" name="chat-left-text"></i-bs>
{{document.notes.length}}</span>
</a>
}
@ -43,14 +43,14 @@
@if (document.document_type) {
<button type="button" class="list-group-item list-group-item-action bg-transparent ps-0 p-1 border-0" title="Toggle document type filter" i18n-title
(click)="clickDocumentType.emit(document.document_type);$event.stopPropagation()">
<i-bs width="0.9rem" height="0.9rem" class="me-2 text-muted" name="file-earmark"></i-bs>
<i-bs width="1em" height="1em" class="me-2 text-muted" name="file-earmark"></i-bs>
<small>{{(document.document_type$ | async)?.name ?? privateName}}</small>
</button>
}
@if (document.storage_path) {
<button type="button" class="list-group-item list-group-item-action bg-transparent ps-0 p-1 border-0" title="Toggle storage path filter" i18n-title
(click)="clickStoragePath.emit(document.storage_path);$event.stopPropagation()">
<i-bs width="0.9rem" height="0.9rem" class="me-2 text-muted" name="folder"></i-bs>
<i-bs width="1em" height="1em" class="me-2 text-muted" name="folder"></i-bs>
<small>{{(document.storage_path$ | async)?.name ?? privateName}}</small>
</button>
}
@ -63,25 +63,25 @@
</div>
</ng-template>
<div class="ps-0 p-1" placement="top" [ngbTooltip]="dateTooltip">
<i-bs width="0.9rem" height="0.9rem" class="me-2 text-muted" name="calendar-event"></i-bs>
<i-bs width="1em" height="1em" class="me-2 text-muted" name="calendar-event"></i-bs>
<small>{{document.created_date | customDate:'mediumDate'}}</small>
</div>
</div>
@if (document.archive_serial_number | isNumber) {
<div class="ps-0 p-1">
<i-bs width="0.9rem" height="0.9rem" class="me-2 text-muted" name="upc-scan"></i-bs>
<i-bs width="1em" height="1em" class="me-2 text-muted" name="upc-scan"></i-bs>
<small>#{{document.archive_serial_number}}</small>
</div>
}
@if (document.owner && document.owner !== settingsService.currentUser.id) {
<div class="ps-0 p-1">
<i-bs width="0.9rem" height="0.9rem" class="me-2 text-muted" name="person-fill-lock"></i-bs>
<i-bs width="1em" height="1em" class="me-2 text-muted" name="person-fill-lock"></i-bs>
<small>{{document.owner | username}}</small>
</div>
}
@if (document.is_shared_by_requester) {
<div class="ps-0 p-1">
<i-bs width="0.9rem" height="0.9rem" class="me-2 text-muted" name="people-fill"></i-bs>
<i-bs width="1em" height="1em" class="me-2 text-muted" name="people-fill"></i-bs>
<small i18n>Shared</small>
</div>
}

View File

@ -232,7 +232,7 @@
@if (d.notes.length) {
<a routerLink="/documents/{{d.id}}/notes" class="btn btn-sm p-0">
<span class="badge rounded-pill bg-light border text-primary">
<i-bs width="0.9rem" height="0.9rem" class="ms-1 me-1" name="chat-left-text"></i-bs>
<i-bs width="1.2em" height="1.2em" class="ms-1 me-1" name="chat-left-text"></i-bs>
{{d.notes.length}}</span>
</a>
}

View File

@ -41,4 +41,4 @@
@if (fields.length === 0) {
<li class="list-group-item" i18n>No fields defined.</li>
}
</ul>
</ul>

View File

@ -49,9 +49,9 @@
}
</ul>
</ng-container>
</ng-container>
<ng-container *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.MailRule }">
<ng-container *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.MailRule }">
<h4 class="mt-4">
<ng-container i18n>Mail rules</ng-container>
<button type="button" class="btn btn-sm btn-outline-primary ms-4" (click)="editMailRule()" *pngxIfPermissions="{ action: PermissionAction.Add, type: PermissionType.MailRule }">
@ -93,11 +93,11 @@
}
</ul>
</ng-container>
</ng-container>
@if (!mailAccounts || !mailRules) {
@if (!mailAccounts || !mailRules) {
<div>
<div class="spinner-border spinner-border-sm fw-normal ms-2 me-auto" role="status"></div>
<div class="visually-hidden" i18n>Loading...</div>
</div>
}
}

View File

@ -45,4 +45,4 @@
@if (workflows.length === 0) {
<li class="list-group-item" i18n>No workflows defined.</li>
}
</ul>
</ul>

View File

@ -14,6 +14,7 @@ from PIL import Image
from documents.converters import convert_from_tiff_to_pdf
from documents.data_models import ConsumableDocument
from documents.models import Tag
from documents.plugins.base import ConsumeTaskPlugin
from documents.plugins.base import StopConsumeTaskError
from documents.plugins.helpers import ProgressStatusOptions
@ -65,7 +66,9 @@ class BarcodePlugin(ConsumeTaskPlugin):
supported_mimes = {"application/pdf"}
return (
settings.CONSUMER_ENABLE_ASN_BARCODE or settings.CONSUMER_ENABLE_BARCODES
settings.CONSUMER_ENABLE_ASN_BARCODE
or settings.CONSUMER_ENABLE_BARCODES
or settings.CONSUMER_ENABLE_TAG_BARCODE
) and self.input_doc.mime_type in supported_mimes
def setup(self):
@ -90,6 +93,16 @@ class BarcodePlugin(ConsumeTaskPlugin):
logger.info(f"Found ASN in barcode: {located_asn}")
self.metadata.asn = located_asn
# try reading tags from barcodes
if settings.CONSUMER_ENABLE_TAG_BARCODE:
tags = self.tags
if tags is not None and len(tags) > 0:
if self.metadata.tag_ids:
self.metadata.tag_ids += tags
else:
self.metadata.tag_ids = tags
logger.info(f"Found tags in barcode: {tags}")
separator_pages = self.get_separation_pages()
if not separator_pages:
return "No pages to split on!"
@ -279,6 +292,53 @@ class BarcodePlugin(ConsumeTaskPlugin):
return asn
@property
def tags(self) -> Optional[list[int]]:
"""
Search the parsed barcodes for any tags.
Returns the detected tag ids (or empty list)
"""
tags = []
# Ensure the barcodes have been read
self.detect()
for x in self.barcodes:
tag_texts = x.value
for raw in tag_texts.split(","):
try:
tag = None
for regex in settings.CONSUMER_TAG_BARCODE_MAPPING:
if re.match(regex, raw, flags=re.IGNORECASE):
sub = settings.CONSUMER_TAG_BARCODE_MAPPING[regex]
tag = (
re.sub(regex, sub, raw, flags=re.IGNORECASE)
if sub
else raw
)
break
if tag:
tag = Tag.objects.get_or_create(
name__iexact=tag,
defaults={"name": tag},
)[0]
logger.debug(
f"Found Tag Barcode '{raw}', substituted "
f"to '{tag}' and mapped to "
f"tag #{tag.pk}.",
)
tags.append(tag.pk)
except Exception as e:
logger.error(
f"Failed to find or create TAG '{raw}' because: {e}",
)
return tags
def get_separation_pages(self) -> dict[int, bool]:
"""
Search the parsed barcodes for separators and returns a dict of page

View File

@ -90,7 +90,6 @@ def set_suggestions_cache(
"""
if classifier is not None:
doc_key = get_suggestion_cache_key(document_id)
print(classifier.last_auto_type_hash)
cache.set(
doc_key,
SuggestionCacheData(

View File

@ -4,11 +4,14 @@ import pickle
import re
import warnings
from collections.abc import Iterator
from datetime import datetime
from hashlib import sha256
from pathlib import Path
from typing import TYPE_CHECKING
from typing import Optional
if TYPE_CHECKING:
from datetime import datetime
from pathlib import Path
from django.conf import settings
from django.core.cache import cache
from sklearn.exceptions import InconsistentVersionWarning

View File

@ -69,8 +69,6 @@ class Command(ProgressBarMixin, BaseCommand):
def handle(self, *args, **options):
self.handle_progress_bar_mixin(**options)
# Detect if we support color
color = self.style.ERROR("test") != "test"
if options["inbox_only"]:
queryset = Document.objects.filter(tags__is_inbox_tag=True)
@ -96,7 +94,8 @@ class Command(ProgressBarMixin, BaseCommand):
use_first=options["use_first"],
suggest=options["suggest"],
base_url=options["base_url"],
color=color,
stdout=self.stdout,
style_func=self.style,
)
if options["document_type"]:
@ -108,7 +107,8 @@ class Command(ProgressBarMixin, BaseCommand):
use_first=options["use_first"],
suggest=options["suggest"],
base_url=options["base_url"],
color=color,
stdout=self.stdout,
style_func=self.style,
)
if options["tags"]:
@ -119,7 +119,8 @@ class Command(ProgressBarMixin, BaseCommand):
replace=options["overwrite"],
suggest=options["suggest"],
base_url=options["base_url"],
color=color,
stdout=self.stdout,
style_func=self.style,
)
if options["storage_path"]:
set_storage_path(
@ -130,5 +131,6 @@ class Command(ProgressBarMixin, BaseCommand):
use_first=options["use_first"],
suggest=options["suggest"],
base_url=options["base_url"],
color=color,
stdout=self.stdout,
style_func=self.style,
)

View File

@ -19,7 +19,7 @@ def _process_document(doc_id):
if parser_class:
parser = parser_class(logging_group=None)
else:
print(f"{document} No parser for mime type {document.mime_type}")
print(f"{document} No parser for mime type {document.mime_type}") # noqa: T201
return
try:

View File

@ -5,7 +5,9 @@ from typing import Union
from asgiref.sync import async_to_sync
from channels.layers import get_channel_layer
from channels_redis.pubsub import RedisPubSubChannelLayer
if TYPE_CHECKING:
from channels_redis.pubsub import RedisPubSubChannelLayer
class ProgressStatusOptions(str, enum.Enum):

View File

@ -18,7 +18,6 @@ from django.db import close_old_connections
from django.db import models
from django.db.models import Q
from django.dispatch import receiver
from django.utils import termcolors
from django.utils import timezone
from filelock import FileLock
@ -54,6 +53,26 @@ def add_inbox_tags(sender, document: Document, logging_group=None, **kwargs):
document.tags.add(*inbox_tags)
def _suggestion_printer(
stdout,
style_func,
suggestion_type: str,
document: Document,
selected: MatchingModel,
base_url: Optional[str] = None,
):
"""
Smaller helper to reduce duplication when just outputting suggestions to the console
"""
doc_str = str(document)
if base_url is not None:
stdout.write(style_func.SUCCESS(doc_str))
stdout.write(style_func.SUCCESS(f"{base_url}/documents/{document.pk}"))
else:
stdout.write(style_func.SUCCESS(f"{doc_str} [{document.pk}]"))
stdout.write(f"Suggest {suggestion_type}: {selected}")
def set_correspondent(
sender,
document: Document,
@ -63,7 +82,8 @@ def set_correspondent(
use_first=True,
suggest=False,
base_url=None,
color=False,
stdout=None,
style_func=None,
**kwargs,
):
if document.correspondent and not replace:
@ -90,23 +110,14 @@ def set_correspondent(
if selected or replace:
if suggest:
if base_url:
print(
termcolors.colorize(str(document), fg="green")
if color
else str(document),
_suggestion_printer(
stdout,
style_func,
"correspondent",
document,
selected,
base_url,
)
print(f"{base_url}/documents/{document.pk}")
else:
print(
(
termcolors.colorize(str(document), fg="green")
if color
else str(document)
)
+ f" [{document.pk}]",
)
print(f"Suggest correspondent {selected}")
else:
logger.info(
f"Assigning correspondent {selected} to {document}",
@ -126,7 +137,8 @@ def set_document_type(
use_first=True,
suggest=False,
base_url=None,
color=False,
stdout=None,
style_func=None,
**kwargs,
):
if document.document_type and not replace:
@ -154,23 +166,14 @@ def set_document_type(
if selected or replace:
if suggest:
if base_url:
print(
termcolors.colorize(str(document), fg="green")
if color
else str(document),
_suggestion_printer(
stdout,
style_func,
"document type",
document,
selected,
base_url,
)
print(f"{base_url}/documents/{document.pk}")
else:
print(
(
termcolors.colorize(str(document), fg="green")
if color
else str(document)
)
+ f" [{document.pk}]",
)
print(f"Suggest document type {selected}")
else:
logger.info(
f"Assigning document type {selected} to {document}",
@ -189,7 +192,8 @@ def set_tags(
replace=False,
suggest=False,
base_url=None,
color=False,
stdout=None,
style_func=None,
**kwargs,
):
if replace:
@ -212,26 +216,16 @@ def set_tags(
]
if not relevant_tags and not extra_tags:
return
doc_str = style_func.SUCCESS(str(document))
if base_url:
print(
termcolors.colorize(str(document), fg="green")
if color
else str(document),
)
print(f"{base_url}/documents/{document.pk}")
stdout.write(doc_str)
stdout.write(f"{base_url}/documents/{document.pk}")
else:
print(
(
termcolors.colorize(str(document), fg="green")
if color
else str(document)
)
+ f" [{document.pk}]",
)
stdout.write(doc_str + style_func.SUCCESS(f" [{document.pk}]"))
if relevant_tags:
print("Suggest tags: " + ", ".join([t.name for t in relevant_tags]))
stdout.write("Suggest tags: " + ", ".join([t.name for t in relevant_tags]))
if extra_tags:
print("Extra tags: " + ", ".join([t.name for t in extra_tags]))
stdout.write("Extra tags: " + ", ".join([t.name for t in extra_tags]))
else:
if not relevant_tags:
return
@ -254,7 +248,8 @@ def set_storage_path(
use_first=True,
suggest=False,
base_url=None,
color=False,
stdout=None,
style_func=None,
**kwargs,
):
if document.storage_path and not replace:
@ -285,23 +280,14 @@ def set_storage_path(
if selected or replace:
if suggest:
if base_url:
print(
termcolors.colorize(str(document), fg="green")
if color
else str(document),
_suggestion_printer(
stdout,
style_func,
"storage directory",
document,
selected,
base_url,
)
print(f"{base_url}/documents/{document.pk}")
else:
print(
(
termcolors.colorize(str(document), fg="green")
if color
else str(document)
)
+ f" [{document.pk}]",
)
print(f"Suggest storage directory {selected}")
else:
logger.info(
f"Assigning storage path {selected} to {document}",

View File

@ -246,8 +246,6 @@ class TestBulkDownload(DirectoriesMixin, APITestCase):
self.doc3.title = "Title 2 - Doc 3"
self.doc3.save()
print(self.doc3.archive_path)
print(self.doc3.archive_filename)
response = self.client.post(
self.ENDPOINT,

View File

@ -14,6 +14,7 @@ from documents.barcodes import BarcodePlugin
from documents.data_models import ConsumableDocument
from documents.data_models import DocumentMetadataOverrides
from documents.data_models import DocumentSource
from documents.models import Tag
from documents.tests.utils import DirectoriesMixin
from documents.tests.utils import DocumentConsumeDelayMixin
from documents.tests.utils import DummyProgressManager
@ -741,3 +742,125 @@ class TestBarcodeZxing(TestBarcode):
@override_settings(CONSUMER_BARCODE_SCANNER="ZXING")
class TestAsnBarcodesZxing(TestAsnBarcode):
pass
class TestTagBarcode(DirectoriesMixin, SampleDirMixin, GetReaderPluginMixin, TestCase):
@contextmanager
def get_reader(self, filepath: Path) -> BarcodePlugin:
reader = BarcodePlugin(
ConsumableDocument(DocumentSource.ConsumeFolder, original_file=filepath),
DocumentMetadataOverrides(),
DummyProgressManager(filepath.name, None),
self.dirs.scratch_dir,
"task-id",
)
reader.setup()
yield reader
reader.cleanup()
@override_settings(CONSUMER_ENABLE_TAG_BARCODE=True)
def test_scan_file_without_matching_barcodes(self):
"""
GIVEN:
- PDF containing tag barcodes but none with matching prefix (default "TAG:")
WHEN:
- File is scanned for barcodes
THEN:
- No TAG has been created
"""
test_file = self.BARCODE_SAMPLE_DIR / "barcode-39-asn-custom-prefix.pdf"
with self.get_reader(test_file) as reader:
reader.run()
tags = reader.metadata.tag_ids
self.assertEqual(tags, None)
@override_settings(
CONSUMER_ENABLE_TAG_BARCODE=False,
CONSUMER_TAG_BARCODE_MAPPING={"CUSTOM-PREFIX-(.*)": "\\g<1>"},
)
def test_scan_file_with_matching_barcode_but_function_disabled(self):
"""
GIVEN:
- PDF containing a tag barcode with matching custom prefix
- The tag barcode functionality is disabled
WHEN:
- File is scanned for barcodes
THEN:
- No TAG has been created
"""
test_file = self.BARCODE_SAMPLE_DIR / "barcode-39-asn-custom-prefix.pdf"
with self.get_reader(test_file) as reader:
reader.run()
tags = reader.metadata.tag_ids
self.assertEqual(tags, None)
@override_settings(
CONSUMER_ENABLE_TAG_BARCODE=True,
CONSUMER_TAG_BARCODE_MAPPING={"CUSTOM-PREFIX-(.*)": "\\g<1>"},
)
def test_scan_file_for_tag_custom_prefix(self):
"""
GIVEN:
- PDF containing a tag barcode with custom prefix
- The barcode mapping accepts this prefix and removes it from the mapped tag value
- The created tag is the non-prefixed values
WHEN:
- File is scanned for barcodes
THEN:
- The TAG is located
- One TAG has been created
"""
test_file = self.BARCODE_SAMPLE_DIR / "barcode-39-asn-custom-prefix.pdf"
with self.get_reader(test_file) as reader:
reader.metadata.tag_ids = [99]
reader.run()
self.assertEqual(reader.pdf_file, test_file)
tags = reader.metadata.tag_ids
self.assertEqual(len(tags), 2)
self.assertEqual(tags[0], 99)
self.assertEqual(Tag.objects.get(name__iexact="00123").pk, tags[1])
@override_settings(
CONSUMER_ENABLE_TAG_BARCODE=True,
CONSUMER_TAG_BARCODE_MAPPING={"ASN(.*)": "\\g<1>"},
)
def test_scan_file_for_many_custom_tags(self):
"""
GIVEN:
- PDF containing multiple tag barcode with custom prefix
- The barcode mapping accepts this prefix and removes it from the mapped tag value
- The created tags are the non-prefixed values
WHEN:
- File is scanned for barcodes
THEN:
- The TAG is located
- File Tags have been created
"""
test_file = self.BARCODE_SAMPLE_DIR / "split-by-asn-1.pdf"
with self.get_reader(test_file) as reader:
reader.run()
tags = reader.metadata.tag_ids
self.assertEqual(len(tags), 5)
self.assertEqual(Tag.objects.get(name__iexact="00123").pk, tags[0])
self.assertEqual(Tag.objects.get(name__iexact="00124").pk, tags[1])
self.assertEqual(Tag.objects.get(name__iexact="00125").pk, tags[2])
self.assertEqual(Tag.objects.get(name__iexact="00126").pk, tags[3])
self.assertEqual(Tag.objects.get(name__iexact="00127").pk, tags[4])
@override_settings(
CONSUMER_ENABLE_TAG_BARCODE=True,
CONSUMER_TAG_BARCODE_MAPPING={"CUSTOM-PREFIX-(.*)": "\\g<3>"},
)
def test_scan_file_for_tag_raises_value_error(self):
"""
GIVEN:
- Any error occurs during tag barcode processing
THEN:
- The processing should be skipped and not break the import
"""
test_file = self.BARCODE_SAMPLE_DIR / "barcode-39-asn-custom-prefix.pdf"
with self.get_reader(test_file) as reader:
reader.run()
# expect error to be caught and logged only
tags = reader.metadata.tag_ids
self.assertEqual(tags, None)

View File

@ -88,10 +88,10 @@ class ConsumerThreadMixin(DocumentConsumeDelayMixin):
):
eq = filecmp.cmp(input_doc.original_file, self.sample_file, shallow=False)
if not eq:
print("Consumed an INVALID file.")
print("Consumed an INVALID file.") # noqa: T201
raise ConsumerError("Incomplete File READ FAILED")
else:
print("Consumed a perfectly valid file.")
print("Consumed a perfectly valid file.") # noqa: T201
def slow_write_file(self, target, incomplete=False):
with open(self.sample_file, "rb") as f:
@ -102,11 +102,11 @@ class ConsumerThreadMixin(DocumentConsumeDelayMixin):
with open(target, "wb") as f:
# this will take 2 seconds, since the file is about 20k.
print("Start writing file.")
print("Start writing file.") # noqa: T201
for b in chunked(1000, pdf_bytes):
f.write(b)
sleep(0.1)
print("file completed.")
print("file completed.") # noqa: T201
@override_settings(

View File

@ -196,7 +196,7 @@ class TestFuzzyMatchCommand(TestCase):
self.assertEqual(Document.objects.count(), 3)
stdout, _ = self.call_command("--delete")
print(stdout)
lines = [x.strip() for x in stdout.split("\n") if len(x.strip())]
self.assertEqual(len(lines), 3)
self.assertEqual(

View File

@ -1,16 +1,19 @@
from datetime import timedelta
from pathlib import Path
from typing import TYPE_CHECKING
from unittest import mock
from django.contrib.auth.models import Group
from django.contrib.auth.models import User
from django.db.models import QuerySet
from django.utils import timezone
from guardian.shortcuts import assign_perm
from guardian.shortcuts import get_groups_with_perms
from guardian.shortcuts import get_users_with_perms
from rest_framework.test import APITestCase
if TYPE_CHECKING:
from django.db.models import QuerySet
from documents import tasks
from documents.data_models import ConsumableDocument
from documents.data_models import DocumentSource

View File

@ -340,7 +340,6 @@ class DummyProgressManager:
def __init__(self, filename: str, task_id: Optional[str] = None) -> None:
self.filename = filename
self.task_id = task_id
print("hello world")
self.payloads = []
def __enter__(self):

View File

@ -2,7 +2,7 @@ msgid ""
msgstr ""
"Project-Id-Version: paperless-ngx\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2024-02-02 20:17-0800\n"
"POT-Creation-Date: 2024-02-07 06:20+0000\n"
"PO-Revision-Date: 2022-02-17 04:17\n"
"Last-Translator: \n"
"Language-Team: English\n"
@ -777,15 +777,136 @@ msgstr ""
msgid "Invalid color."
msgstr ""
#: documents/serialisers.py:1049
#: documents/serialisers.py:1060
#, python-format
msgid "File type %(type)s not supported"
msgstr ""
#: documents/serialisers.py:1152
#: documents/serialisers.py:1163
msgid "Invalid variable detected."
msgstr ""
#: documents/templates/account/login.html:14
msgid "Paperless-ngx sign in"
msgstr ""
#: documents/templates/account/login.html:47
msgid "Please sign in."
msgstr ""
#: documents/templates/account/login.html:50
msgid "Your username and password didn't match. Please try again."
msgstr ""
#: documents/templates/account/login.html:54
msgid "Share link was not found."
msgstr ""
#: documents/templates/account/login.html:58
msgid "Share link has expired."
msgstr ""
#: documents/templates/account/login.html:61
#: documents/templates/socialaccount/signup.html:56
msgid "Username"
msgstr ""
#: documents/templates/account/login.html:62
msgid "Password"
msgstr ""
#: documents/templates/account/login.html:72
msgid "Sign in"
msgstr ""
#: documents/templates/account/login.html:76
msgid "Forgot your password?"
msgstr ""
#: documents/templates/account/login.html:83
msgid "or sign in via"
msgstr ""
#: documents/templates/account/password_reset.html:15
msgid "Paperless-ngx reset password request"
msgstr ""
#: documents/templates/account/password_reset.html:43
msgid ""
"Enter your email address below, and we'll email instructions for setting a "
"new one."
msgstr ""
#: documents/templates/account/password_reset.html:46
msgid "An error occurred. Please try again."
msgstr ""
#: documents/templates/account/password_reset.html:49
#: documents/templates/socialaccount/signup.html:57
msgid "Email"
msgstr ""
#: documents/templates/account/password_reset.html:56
msgid "Send me instructions!"
msgstr ""
#: documents/templates/account/password_reset_done.html:14
msgid "Paperless-ngx reset password sent"
msgstr ""
#: documents/templates/account/password_reset_done.html:40
msgid "Check your inbox."
msgstr ""
#: documents/templates/account/password_reset_done.html:41
msgid ""
"We've emailed you instructions for setting your password. You should receive "
"the email shortly!"
msgstr ""
#: documents/templates/account/password_reset_from_key.html:15
msgid "Paperless-ngx reset password confirmation"
msgstr ""
#: documents/templates/account/password_reset_from_key.html:44
msgid "request a new password reset"
msgstr ""
#: documents/templates/account/password_reset_from_key.html:46
msgid "Set a new password."
msgstr ""
#: documents/templates/account/password_reset_from_key.html:50
msgid "Passwords did not match or too weak. Try again."
msgstr ""
#: documents/templates/account/password_reset_from_key.html:53
msgid "New Password"
msgstr ""
#: documents/templates/account/password_reset_from_key.html:54
msgid "Confirm Password"
msgstr ""
#: documents/templates/account/password_reset_from_key.html:65
msgid "Change my password"
msgstr ""
#: documents/templates/account/password_reset_from_key_done.html:14
msgid "Paperless-ngx reset password complete"
msgstr ""
#: documents/templates/account/password_reset_from_key_done.html:40
msgid "Password reset complete."
msgstr ""
#: documents/templates/account/password_reset_from_key_done.html:42
#, python-format
msgid ""
"Your new password has been set. You can now <a href=\"%(login_url)s\">log "
"in</a>"
msgstr ""
#: documents/templates/index.html:79
msgid "Paperless-ngx is loading..."
msgstr ""
@ -798,131 +919,40 @@ msgstr ""
msgid "Here's a link to the docs."
msgstr ""
#: documents/templates/registration/logged_out.html:14
msgid "Paperless-ngx signed out"
#: documents/templates/socialaccount/authentication_error.html:15
#: documents/templates/socialaccount/login.html:15
msgid "Paperless-ngx social account sign in"
msgstr ""
#: documents/templates/registration/logged_out.html:40
msgid "You have been successfully logged out. Bye!"
msgstr ""
#: documents/templates/registration/logged_out.html:41
msgid "Sign in again"
msgstr ""
#: documents/templates/registration/login.html:14
msgid "Paperless-ngx sign in"
msgstr ""
#: documents/templates/registration/login.html:41
msgid "Please sign in."
msgstr ""
#: documents/templates/registration/login.html:44
msgid "Your username and password didn't match. Please try again."
msgstr ""
#: documents/templates/registration/login.html:48
msgid "Share link was not found."
msgstr ""
#: documents/templates/registration/login.html:52
msgid "Share link has expired."
msgstr ""
#: documents/templates/registration/login.html:55
msgid "Username"
msgstr ""
#: documents/templates/registration/login.html:56
msgid "Password"
msgstr ""
#: documents/templates/registration/login.html:66
msgid "Sign in"
msgstr ""
#: documents/templates/registration/login.html:70
msgid "Forgot your password?"
msgstr ""
#: documents/templates/registration/password_reset_complete.html:14
msgid "Paperless-ngx reset password complete"
msgstr ""
#: documents/templates/registration/password_reset_complete.html:40
msgid "Password reset complete."
msgstr ""
#: documents/templates/registration/password_reset_complete.html:42
#: documents/templates/socialaccount/authentication_error.html:43
#, python-format
msgid ""
"Your new password has been set. You can now <a href=\"%(login_url)s\">log "
"in</a>"
"An error occurred while attempting to login via your social network account. "
"Back to the <a href=\"%(login_url)s\">login page</a>"
msgstr ""
#: documents/templates/registration/password_reset_confirm.html:14
msgid "Paperless-ngx reset password confirmation"
#: documents/templates/socialaccount/login.html:44
#, python-format
msgid "You are about to connect a new third-party account from %(provider)s."
msgstr ""
#: documents/templates/registration/password_reset_confirm.html:42
msgid "Set a new password."
#: documents/templates/socialaccount/login.html:47
msgid "Continue"
msgstr ""
#: documents/templates/registration/password_reset_confirm.html:46
msgid "Passwords did not match or too weak. Try again."
#: documents/templates/socialaccount/signup.html:14
msgid "Paperless-ngx social account sign up"
msgstr ""
#: documents/templates/registration/password_reset_confirm.html:49
msgid "New Password"
msgstr ""
#: documents/templates/registration/password_reset_confirm.html:50
msgid "Confirm Password"
msgstr ""
#: documents/templates/registration/password_reset_confirm.html:61
msgid "Change my password"
msgstr ""
#: documents/templates/registration/password_reset_confirm.html:65
msgid "request a new password reset"
msgstr ""
#: documents/templates/registration/password_reset_done.html:14
msgid "Paperless-ngx reset password sent"
msgstr ""
#: documents/templates/registration/password_reset_done.html:40
msgid "Check your inbox."
msgstr ""
#: documents/templates/registration/password_reset_done.html:41
#: documents/templates/socialaccount/signup.html:53
#, python-format
msgid ""
"We've emailed you instructions for setting your password. You should receive "
"the email shortly!"
"You are about to use your %(provider_name)s account to login to\n"
"%(site_name)s. As a final step, please complete the following form:"
msgstr ""
#: documents/templates/registration/password_reset_form.html:14
msgid "Paperless-ngx reset password request"
msgstr ""
#: documents/templates/registration/password_reset_form.html:41
msgid ""
"Enter your email address below, and we'll email instructions for setting a "
"new one."
msgstr ""
#: documents/templates/registration/password_reset_form.html:44
msgid "An error occurred. Please try again."
msgstr ""
#: documents/templates/registration/password_reset_form.html:47
msgid "Email"
msgstr ""
#: documents/templates/registration/password_reset_form.html:54
msgid "Send me instructions!"
#: documents/templates/socialaccount/signup.html:72
msgid "Sign up"
msgstr ""
#: documents/validators.py:17
@ -1088,135 +1118,135 @@ msgstr ""
msgid "paperless application settings"
msgstr ""
#: paperless/settings.py:617
#: paperless/settings.py:658
msgid "English (US)"
msgstr ""
#: paperless/settings.py:618
#: paperless/settings.py:659
msgid "Arabic"
msgstr ""
#: paperless/settings.py:619
#: paperless/settings.py:660
msgid "Afrikaans"
msgstr ""
#: paperless/settings.py:620
#: paperless/settings.py:661
msgid "Belarusian"
msgstr ""
#: paperless/settings.py:621
#: paperless/settings.py:662
msgid "Bulgarian"
msgstr ""
#: paperless/settings.py:622
#: paperless/settings.py:663
msgid "Catalan"
msgstr ""
#: paperless/settings.py:623
#: paperless/settings.py:664
msgid "Czech"
msgstr ""
#: paperless/settings.py:624
#: paperless/settings.py:665
msgid "Danish"
msgstr ""
#: paperless/settings.py:625
#: paperless/settings.py:666
msgid "German"
msgstr ""
#: paperless/settings.py:626
#: paperless/settings.py:667
msgid "Greek"
msgstr ""
#: paperless/settings.py:627
#: paperless/settings.py:668
msgid "English (GB)"
msgstr ""
#: paperless/settings.py:628
#: paperless/settings.py:669
msgid "Spanish"
msgstr ""
#: paperless/settings.py:629
#: paperless/settings.py:670
msgid "Finnish"
msgstr ""
#: paperless/settings.py:630
#: paperless/settings.py:671
msgid "French"
msgstr ""
#: paperless/settings.py:631
#: paperless/settings.py:672
msgid "Hungarian"
msgstr ""
#: paperless/settings.py:632
#: paperless/settings.py:673
msgid "Italian"
msgstr ""
#: paperless/settings.py:633
#: paperless/settings.py:674
msgid "Japanese"
msgstr ""
#: paperless/settings.py:634
#: paperless/settings.py:675
msgid "Luxembourgish"
msgstr ""
#: paperless/settings.py:635
#: paperless/settings.py:676
msgid "Norwegian"
msgstr ""
#: paperless/settings.py:636
#: paperless/settings.py:677
msgid "Dutch"
msgstr ""
#: paperless/settings.py:637
#: paperless/settings.py:678
msgid "Polish"
msgstr ""
#: paperless/settings.py:638
#: paperless/settings.py:679
msgid "Portuguese (Brazil)"
msgstr ""
#: paperless/settings.py:639
#: paperless/settings.py:680
msgid "Portuguese"
msgstr ""
#: paperless/settings.py:640
#: paperless/settings.py:681
msgid "Romanian"
msgstr ""
#: paperless/settings.py:641
#: paperless/settings.py:682
msgid "Russian"
msgstr ""
#: paperless/settings.py:642
#: paperless/settings.py:683
msgid "Slovak"
msgstr ""
#: paperless/settings.py:643
#: paperless/settings.py:684
msgid "Slovenian"
msgstr ""
#: paperless/settings.py:644
#: paperless/settings.py:685
msgid "Serbian"
msgstr ""
#: paperless/settings.py:645
#: paperless/settings.py:686
msgid "Swedish"
msgstr ""
#: paperless/settings.py:646
#: paperless/settings.py:687
msgid "Turkish"
msgstr ""
#: paperless/settings.py:647
#: paperless/settings.py:688
msgid "Ukrainian"
msgstr ""
#: paperless/settings.py:648
#: paperless/settings.py:689
msgid "Chinese Simplified"
msgstr ""
#: paperless/urls.py:214
#: paperless/urls.py:224
msgid "Paperless-ngx administration"
msgstr ""

View File

@ -27,4 +27,4 @@ class CustomSocialAccountAdapter(DefaultSocialAccountAdapter):
def populate_user(self, request, sociallogin, data):
# TODO: If default global permissions are implemented, should also be here
return super().populate_user(request, sociallogin, data)
return super().populate_user(request, sociallogin, data) # pragma: no cover

View File

@ -1,3 +1,5 @@
import logging
from django.conf import settings
from django.contrib import auth
from django.contrib.auth.middleware import PersistentRemoteUserMiddleware
@ -6,6 +8,8 @@ from django.http import HttpRequest
from django.utils.deprecation import MiddlewareMixin
from rest_framework import authentication
logger = logging.getLogger("paperless.auth")
class AutoLoginMiddleware(MiddlewareMixin):
def process_request(self, request: HttpRequest):
@ -35,7 +39,7 @@ class AngularApiAuthenticationOverride(authentication.BaseAuthentication):
and request.headers["Referer"].startswith("http://localhost:4200/")
):
user = User.objects.filter(is_staff=True).first()
print(f"Auto-Login with user {user}")
logger.debug(f"Auto-Login with user {user}")
return (user, None)
else:
return None

View File

@ -796,6 +796,11 @@ CACHES = {
},
}
if DEBUG and os.getenv("PAPERLESS_CACHE_BACKEND") is None:
CACHES["default"][
"BACKEND"
] = "django.core.cache.backends.locmem.LocMemCache" # pragma: no cover
def default_threads_per_worker(task_workers) -> int:
# always leave one core open
@ -878,6 +883,19 @@ CONSUMER_BARCODE_UPSCALE: Final[float] = __get_float(
CONSUMER_BARCODE_DPI: Final[int] = __get_int("PAPERLESS_CONSUMER_BARCODE_DPI", 300)
CONSUMER_ENABLE_TAG_BARCODE: Final[bool] = __get_boolean(
"PAPERLESS_CONSUMER_ENABLE_TAG_BARCODE",
)
CONSUMER_TAG_BARCODE_MAPPING = dict(
json.loads(
os.getenv(
"PAPERLESS_CONSUMER_TAG_BARCODE_MAPPING",
'{"TAG:(.*)": "\\\\g<1>"}',
),
),
)
CONSUMER_ENABLE_COLLATE_DOUBLE_SIDED: Final[bool] = __get_boolean(
"PAPERLESS_CONSUMER_ENABLE_COLLATE_DOUBLE_SIDED",
)