Merge branch 'dev' into dev
This commit is contained in:
commit
3c86ad5140
8
.github/workflows/ci.yml
vendored
8
.github/workflows/ci.yml
vendored
@ -184,7 +184,7 @@ jobs:
|
|||||||
cache-dependency-path: 'src-ui/package-lock.json'
|
cache-dependency-path: 'src-ui/package-lock.json'
|
||||||
- name: Cache frontend dependencies
|
- name: Cache frontend dependencies
|
||||||
id: cache-frontend-deps
|
id: cache-frontend-deps
|
||||||
uses: actions/cache@v3
|
uses: actions/cache@v4
|
||||||
with:
|
with:
|
||||||
path: |
|
path: |
|
||||||
~/.npm
|
~/.npm
|
||||||
@ -221,7 +221,7 @@ jobs:
|
|||||||
cache-dependency-path: 'src-ui/package-lock.json'
|
cache-dependency-path: 'src-ui/package-lock.json'
|
||||||
- name: Cache frontend dependencies
|
- name: Cache frontend dependencies
|
||||||
id: cache-frontend-deps
|
id: cache-frontend-deps
|
||||||
uses: actions/cache@v3
|
uses: actions/cache@v4
|
||||||
with:
|
with:
|
||||||
path: |
|
path: |
|
||||||
~/.npm
|
~/.npm
|
||||||
@ -283,7 +283,7 @@ jobs:
|
|||||||
merge-multiple: true
|
merge-multiple: true
|
||||||
-
|
-
|
||||||
name: Upload frontend coverage to Codecov
|
name: Upload frontend coverage to Codecov
|
||||||
uses: codecov/codecov-action@v3
|
uses: codecov/codecov-action@v4
|
||||||
with:
|
with:
|
||||||
# not required for public repos, but intermittently fails otherwise
|
# not required for public repos, but intermittently fails otherwise
|
||||||
token: ${{ secrets.CODECOV_TOKEN }}
|
token: ${{ secrets.CODECOV_TOKEN }}
|
||||||
@ -299,7 +299,7 @@ jobs:
|
|||||||
path: src/
|
path: src/
|
||||||
-
|
-
|
||||||
name: Upload coverage to Codecov
|
name: Upload coverage to Codecov
|
||||||
uses: codecov/codecov-action@v3
|
uses: codecov/codecov-action@v4
|
||||||
with:
|
with:
|
||||||
# not required for public repos, but intermittently fails otherwise
|
# not required for public repos, but intermittently fails otherwise
|
||||||
token: ${{ secrets.CODECOV_TOKEN }}
|
token: ${{ secrets.CODECOV_TOKEN }}
|
||||||
|
13
README.md
13
README.md
@ -3,6 +3,7 @@
|
|||||||
[](https://docs.paperless-ngx.com)
|
[](https://docs.paperless-ngx.com)
|
||||||
[](https://codecov.io/gh/paperless-ngx/paperless-ngx)
|
[](https://codecov.io/gh/paperless-ngx/paperless-ngx)
|
||||||
[](https://matrix.to/#/%23paperlessngx%3Amatrix.org)
|
[](https://matrix.to/#/%23paperlessngx%3Amatrix.org)
|
||||||
|
[](https://demo.paperless-ngx.com)
|
||||||
|
|
||||||
<p align="center">
|
<p align="center">
|
||||||
<picture>
|
<picture>
|
||||||
@ -20,6 +21,8 @@ Paperless-ngx is a document management system that transforms your physical docu
|
|||||||
|
|
||||||
Paperless-ngx is the official successor to the original [Paperless](https://github.com/the-paperless-project/paperless) & [Paperless-ng](https://github.com/jonaswinkler/paperless-ng) projects and is designed to distribute the responsibility of advancing and supporting the project among a team of people. [Consider joining us!](#community-support)
|
Paperless-ngx is the official successor to the original [Paperless](https://github.com/the-paperless-project/paperless) & [Paperless-ng](https://github.com/jonaswinkler/paperless-ng) projects and is designed to distribute the responsibility of advancing and supporting the project among a team of people. [Consider joining us!](#community-support)
|
||||||
|
|
||||||
|
Thanks to the generous folks at [DigitalOcean](https://m.do.co/c/8d70b916d462), a demo is available at [demo.paperless-ngx.com](https://demo.paperless-ngx.com) using login `demo` / `demo`. _Note: demo content is reset frequently and confidential information should not be uploaded._
|
||||||
|
|
||||||
- [Features](#features)
|
- [Features](#features)
|
||||||
- [Getting started](#getting-started)
|
- [Getting started](#getting-started)
|
||||||
- [Contributing](#contributing)
|
- [Contributing](#contributing)
|
||||||
@ -30,6 +33,16 @@ Paperless-ngx is the official successor to the original [Paperless](https://gith
|
|||||||
- [Affiliated Projects](#affiliated-projects)
|
- [Affiliated Projects](#affiliated-projects)
|
||||||
- [Important Note](#important-note)
|
- [Important Note](#important-note)
|
||||||
|
|
||||||
|
<p align="right">This project is supported by:<br/>
|
||||||
|
<a href="https://m.do.co/c/8d70b916d462" style="padding-top: 4px; display: block;">
|
||||||
|
<picture>
|
||||||
|
<source media="(prefers-color-scheme: dark)" srcset="https://opensource.nyc3.cdn.digitaloceanspaces.com/attribution/assets/SVG/DO_Logo_horizontal_white.svg" width="140px">
|
||||||
|
<source media="(prefers-color-scheme: light)" srcset="https://opensource.nyc3.cdn.digitaloceanspaces.com/attribution/assets/SVG/DO_Logo_horizontal_black_.svg" width="140px">
|
||||||
|
<img src="https://opensource.nyc3.cdn.digitaloceanspaces.com/attribution/assets/SVG/DO_Logo_horizontal_black_.svg" width="140px">
|
||||||
|
</picture>
|
||||||
|
</a>
|
||||||
|
</p>
|
||||||
|
|
||||||
# Features
|
# Features
|
||||||
|
|
||||||
<picture>
|
<picture>
|
||||||
|
@ -1,14 +1,15 @@
|
|||||||
#!/usr/bin/env bash
|
#!/usr/bin/env bash
|
||||||
|
|
||||||
|
SUPERVISORD_WORKING_DIR="${PAPERLESS_SUPERVISORD_WORKING_DIR:-$PWD}"
|
||||||
rootless_args=()
|
rootless_args=()
|
||||||
if [ "$(id -u)" == "$(id -u paperless)" ]; then
|
if [ "$(id -u)" == "$(id -u paperless)" ]; then
|
||||||
rootless_args=(
|
rootless_args=(
|
||||||
--user
|
--user
|
||||||
paperless
|
paperless
|
||||||
--logfile
|
--logfile
|
||||||
supervisord.log
|
"${SUPERVISORD_WORKING_DIR}/supervisord.log"
|
||||||
--pidfile
|
--pidfile
|
||||||
supervisord.pid
|
"${SUPERVISORD_WORKING_DIR}/supervisord.pid"
|
||||||
)
|
)
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
@ -517,6 +517,18 @@ existing tables) with:
|
|||||||
an older system may fix issues that can arise while setting up Paperless-ngx but
|
an older system may fix issues that can arise while setting up Paperless-ngx but
|
||||||
`utf8mb3` can cause issues with consumption (where `utf8mb4` does not).
|
`utf8mb3` can cause issues with consumption (where `utf8mb4` does not).
|
||||||
|
|
||||||
|
### Missing timezones
|
||||||
|
|
||||||
|
MySQL as well as MariaDB do not have any timezone information by default (though some
|
||||||
|
docker images such as the official MariaDB image take care of this for you) which will
|
||||||
|
cause unexpected behavior with date-based queries.
|
||||||
|
|
||||||
|
To fix this, execute one of the following commands:
|
||||||
|
|
||||||
|
MySQL: `mysql_tzinfo_to_sql /usr/share/zoneinfo | mysql -u root mysql -p`
|
||||||
|
|
||||||
|
MariaDB: `mariadb-tzinfo-to-sql /usr/share/zoneinfo | mariadb -u root mysql -p`
|
||||||
|
|
||||||
## Barcodes {#barcodes}
|
## Barcodes {#barcodes}
|
||||||
|
|
||||||
Paperless is able to utilize barcodes for automatically performing some tasks.
|
Paperless is able to utilize barcodes for automatically performing some tasks.
|
||||||
|
78
docs/api.md
78
docs/api.md
@ -139,7 +139,7 @@ document. Paperless only reports PDF metadata at this point.
|
|||||||
|
|
||||||
## Authorization
|
## Authorization
|
||||||
|
|
||||||
The REST api provides three different forms of authentication.
|
The REST api provides four different forms of authentication.
|
||||||
|
|
||||||
1. Basic authentication
|
1. Basic authentication
|
||||||
|
|
||||||
@ -177,6 +177,12 @@ The REST api provides three different forms of authentication.
|
|||||||
|
|
||||||
Tokens can also be managed in the Django admin.
|
Tokens can also be managed in the Django admin.
|
||||||
|
|
||||||
|
4. Remote User authentication
|
||||||
|
|
||||||
|
If enabled (see
|
||||||
|
[configuration](configuration.md#PAPERLESS_ENABLE_HTTP_REMOTE_USER_API)),
|
||||||
|
you can authenticate against the API using Remote User auth.
|
||||||
|
|
||||||
## Searching for documents
|
## Searching for documents
|
||||||
|
|
||||||
Full text searching is available on the `/api/documents/` endpoint. Two
|
Full text searching is available on the `/api/documents/` endpoint. Two
|
||||||
@ -185,7 +191,7 @@ results:
|
|||||||
|
|
||||||
- `/api/documents/?query=your%20search%20query`: Search for a document
|
- `/api/documents/?query=your%20search%20query`: Search for a document
|
||||||
using a full text query. For details on the syntax, see [Basic Usage - Searching](usage.md#basic-usage_searching).
|
using a full text query. For details on the syntax, see [Basic Usage - Searching](usage.md#basic-usage_searching).
|
||||||
- `/api/documents/?more_like=1234`: Search for documents similar to
|
- `/api/documents/?more_like_id=1234`: Search for documents similar to
|
||||||
the document with id 1234.
|
the document with id 1234.
|
||||||
|
|
||||||
Pagination works exactly the same as it does for normal requests on this
|
Pagination works exactly the same as it does for normal requests on this
|
||||||
@ -324,6 +330,64 @@ granted). You can pass the parameter `full_perms=true` to API calls to view the
|
|||||||
full permissions of objects in a format that mirrors the `set_permissions`
|
full permissions of objects in a format that mirrors the `set_permissions`
|
||||||
parameter above.
|
parameter above.
|
||||||
|
|
||||||
|
## Bulk Editing
|
||||||
|
|
||||||
|
The API supports various bulk-editing operations which are executed asynchronously.
|
||||||
|
|
||||||
|
### Documents
|
||||||
|
|
||||||
|
For bulk operations on documents, use the endpoint `/api/bulk_edit/` which accepts
|
||||||
|
a json payload of the format:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"documents": [LIST_OF_DOCUMENT_IDS],
|
||||||
|
"method": METHOD, // see below
|
||||||
|
"parameters": args // see below
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
The following methods are supported:
|
||||||
|
|
||||||
|
- `set_correspondent`
|
||||||
|
- Requires `parameters`: `{ "correspondent": CORRESPONDENT_ID }`
|
||||||
|
- `set_document_type`
|
||||||
|
- Requires `parameters`: `{ "document_type": DOCUMENT_TYPE_ID }`
|
||||||
|
- `set_storage_path`
|
||||||
|
- Requires `parameters`: `{ "storage_path": STORAGE_PATH_ID }`
|
||||||
|
- `add_tag`
|
||||||
|
- Requires `parameters`: `{ "tag": TAG_ID }`
|
||||||
|
- `remove_tag`
|
||||||
|
- Requires `parameters`: `{ "tag": TAG_ID }`
|
||||||
|
- `modify_tags`
|
||||||
|
- Requires `parameters`: `{ "add_tags": [LIST_OF_TAG_IDS] }` and / or `{ "remove_tags": [LIST_OF_TAG_IDS] }`
|
||||||
|
- `delete`
|
||||||
|
- No `parameters` required
|
||||||
|
- `redo_ocr`
|
||||||
|
- No `parameters` required
|
||||||
|
- `set_permissions`
|
||||||
|
- Requires `parameters`:
|
||||||
|
- `"permissions": PERMISSIONS_OBJ` (see format [above](#permissions)) and / or
|
||||||
|
- `"owner": OWNER_ID or null`
|
||||||
|
- `"merge": true or false` (defaults to false)
|
||||||
|
- The `merge` flag determines if the supplied permissions will overwrite all existing permissions (including
|
||||||
|
removing them) or be merged with existing permissions.
|
||||||
|
|
||||||
|
### Objects
|
||||||
|
|
||||||
|
Bulk editing for objects (tags, document types etc.) currently supports only updating permissions, using
|
||||||
|
the endpoint: `/api/bulk_edit_object_perms/` which requires a json payload of the format:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"objects": [LIST_OF_OBJECT_IDS],
|
||||||
|
"object_type": "tags", "correspondents", "document_types" or "storage_paths"
|
||||||
|
"owner": OWNER_ID // optional
|
||||||
|
"permissions": { "view": { "users": [] ... }, "change": { ... } }, // (see 'set_permissions' format above)
|
||||||
|
"merge": true / false // defaults to false, see above
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
## API Versioning
|
## API Versioning
|
||||||
|
|
||||||
The REST API is versioned since Paperless-ngx 1.3.0.
|
The REST API is versioned since Paperless-ngx 1.3.0.
|
||||||
@ -380,3 +444,13 @@ Initial API version.
|
|||||||
color to use for a specific tag, which is either black or white
|
color to use for a specific tag, which is either black or white
|
||||||
depending on the brightness of `Tag.color`.
|
depending on the brightness of `Tag.color`.
|
||||||
- Removed field `Tag.colour`.
|
- Removed field `Tag.colour`.
|
||||||
|
|
||||||
|
#### Version 3
|
||||||
|
|
||||||
|
- Permissions endpoints have been added.
|
||||||
|
- The format of the `/api/ui_settings/` has changed.
|
||||||
|
|
||||||
|
#### Version 4
|
||||||
|
|
||||||
|
- Consumption templates were refactored to workflows and API endpoints
|
||||||
|
changed as such.
|
||||||
|
@ -1,5 +1,15 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
## paperless-ngx 2.4.3
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
- Fix: Ensure the scratch directory exists before consuming via the folder [@stumpylog](https://github.com/stumpylog) ([#5579](https://github.com/paperless-ngx/paperless-ngx/pull/5579))
|
||||||
|
|
||||||
|
### All App Changes
|
||||||
|
|
||||||
|
- Fix: Ensure the scratch directory exists before consuming via the folder [@stumpylog](https://github.com/stumpylog) ([#5579](https://github.com/paperless-ngx/paperless-ngx/pull/5579))
|
||||||
|
|
||||||
## paperless-ngx 2.4.2
|
## paperless-ngx 2.4.2
|
||||||
|
|
||||||
### Bug Fixes
|
### Bug Fixes
|
||||||
|
@ -34,6 +34,8 @@ matcher.
|
|||||||
`redis://<username>:<password>@<host>:<port>`
|
`redis://<username>:<password>@<host>:<port>`
|
||||||
- With the requirepass option PAPERLESS_REDIS =
|
- With the requirepass option PAPERLESS_REDIS =
|
||||||
`redis://:<password>@<host>:<port>`
|
`redis://:<password>@<host>:<port>`
|
||||||
|
- To include the redis database index PAPERLESS_REDIS =
|
||||||
|
`redis://<username>:<password>@<host>:<port>/<DBIndex>`
|
||||||
|
|
||||||
[More information on securing your Redis
|
[More information on securing your Redis
|
||||||
Instance](https://redis.io/docs/getting-started/#securing-redis).
|
Instance](https://redis.io/docs/getting-started/#securing-redis).
|
||||||
@ -462,9 +464,21 @@ applications.
|
|||||||
|
|
||||||
Defaults to "false" which disables this feature.
|
Defaults to "false" which disables this feature.
|
||||||
|
|
||||||
|
#### [`PAPERLESS_ENABLE_HTTP_REMOTE_USER_API=<bool>`](#PAPERLESS_ENABLE_HTTP_REMOTE_USER_API) {#PAPERLESS_ENABLE_HTTP_REMOTE_USER_API}
|
||||||
|
|
||||||
|
: Allows authentication via HTTP_REMOTE_USER directly against the API
|
||||||
|
|
||||||
|
!!! warning
|
||||||
|
|
||||||
|
See the warning above about securing your installation when using remote user header authentication. This setting is separate from
|
||||||
|
`PAPERLESS_ENABLE_HTTP_REMOTE_USER` to avoid introducing a security vulnerability to existing reverse proxy setups. As above,
|
||||||
|
ensure that your reverse proxy does not simply pass the `Remote-User` header from the internet to paperless.
|
||||||
|
|
||||||
|
Defaults to "false" which disables this feature.
|
||||||
|
|
||||||
#### [`PAPERLESS_HTTP_REMOTE_USER_HEADER_NAME=<str>`](#PAPERLESS_HTTP_REMOTE_USER_HEADER_NAME) {#PAPERLESS_HTTP_REMOTE_USER_HEADER_NAME}
|
#### [`PAPERLESS_HTTP_REMOTE_USER_HEADER_NAME=<str>`](#PAPERLESS_HTTP_REMOTE_USER_HEADER_NAME) {#PAPERLESS_HTTP_REMOTE_USER_HEADER_NAME}
|
||||||
|
|
||||||
: If "PAPERLESS_ENABLE_HTTP_REMOTE_USER" is enabled, this
|
: If "PAPERLESS_ENABLE_HTTP_REMOTE_USER" or `PAPERLESS_ENABLE_HTTP_REMOTE_USER_API` are enabled, this
|
||||||
property allows to customize the name of the HTTP header from which
|
property allows to customize the name of the HTTP header from which
|
||||||
the authenticated username is extracted. Values are in terms of
|
the authenticated username is extracted. Values are in terms of
|
||||||
[HttpRequest.META](https://docs.djangoproject.com/en/4.1/ref/request-response/#django.http.HttpRequest.META).
|
[HttpRequest.META](https://docs.djangoproject.com/en/4.1/ref/request-response/#django.http.HttpRequest.META).
|
||||||
@ -1378,6 +1392,12 @@ started by the container.
|
|||||||
|
|
||||||
You can read more about this in the [advanced documentation](advanced_usage.md#celery-monitoring).
|
You can read more about this in the [advanced documentation](advanced_usage.md#celery-monitoring).
|
||||||
|
|
||||||
|
#### [`PAPERLESS_SUPERVISORD_WORKING_DIR=<defined>`](#PAPERLESS_SUPERVISORD_WORKING_DIR) {#PAPERLESS_SUPERVISORD_WORKING_DIR}
|
||||||
|
|
||||||
|
: If this environment variable is defined, the `supervisord.log` and `supervisord.pid` file will be created under the specified path in `PAPERLESS_SUPERVISORD_WORKING_DIR`. Setting `PAPERLESS_SUPERVISORD_WORKING_DIR=/tmp` and `PYTHONPYCACHEPREFIX=/tmp/pycache` would allow paperless to work on a read-only filesystem.
|
||||||
|
|
||||||
|
Please take note that the `PAPERLESS_DATA_DIR` and `PAPERLESS_MEDIA_ROOT` paths still have to be writable, just like the `PAPERLESS_SUPERVISORD_WORKING_DIR`. The can be archived by using bind or volume mounts. Only works in the container is run as user *paperless*
|
||||||
|
|
||||||
## Frontend Settings
|
## Frontend Settings
|
||||||
|
|
||||||
#### [`PAPERLESS_APP_TITLE=<bool>`](#PAPERLESS_APP_TITLE) {#PAPERLESS_APP_TITLE}
|
#### [`PAPERLESS_APP_TITLE=<bool>`](#PAPERLESS_APP_TITLE) {#PAPERLESS_APP_TITLE}
|
||||||
|
@ -6,6 +6,14 @@
|
|||||||
physical documents into a searchable online archive so you can keep, well, _less paper_.
|
physical documents into a searchable online archive so you can keep, well, _less paper_.
|
||||||
|
|
||||||
[Get started](setup.md){ .md-button .md-button--primary .index-callout }
|
[Get started](setup.md){ .md-button .md-button--primary .index-callout }
|
||||||
|
[Demo](https://demo.paperless-ngx.com){ .md-button .md-button--secondary target=\_blank }
|
||||||
|
|
||||||
|
<div style="display: flex; justify-content: end; margin-top: -1.5rem;">
|
||||||
|
<a href="https://m.do.co/c/8d70b916d462" target="_blank">
|
||||||
|
<img src="https://opensource.nyc3.cdn.digitaloceanspaces.com/attribution/assets/PoweredByDO/DO_Powered_by_Badge_white.svg#only-dark" class="no-lightbox" width="150px">
|
||||||
|
<img src="https://opensource.nyc3.cdn.digitaloceanspaces.com/attribution/assets/PoweredByDO/DO_Powered_by_Badge_black.svg#only-light" class="no-lightbox" width="150px">
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
<div class="grid-right" markdown>
|
<div class="grid-right" markdown>
|
||||||
|
@ -73,4 +73,6 @@ extra:
|
|||||||
link: https://matrix.to/#/#paperless:matrix.org
|
link: https://matrix.to/#/#paperless:matrix.org
|
||||||
plugins:
|
plugins:
|
||||||
- search
|
- search
|
||||||
- glightbox
|
- glightbox:
|
||||||
|
skip_classes:
|
||||||
|
- no-lightbox
|
||||||
|
@ -31,6 +31,7 @@
|
|||||||
"fr-FR": "src/locale/messages.fr_FR.xlf",
|
"fr-FR": "src/locale/messages.fr_FR.xlf",
|
||||||
"hu-HU": "src/locale/messages.hu_HU.xlf",
|
"hu-HU": "src/locale/messages.hu_HU.xlf",
|
||||||
"it-IT": "src/locale/messages.it_IT.xlf",
|
"it-IT": "src/locale/messages.it_IT.xlf",
|
||||||
|
"ja-JP": "src/locale/messages.ja_JP.xlf",
|
||||||
"lb-LU": "src/locale/messages.lb_LU.xlf",
|
"lb-LU": "src/locale/messages.lb_LU.xlf",
|
||||||
"nl-NL": "src/locale/messages.nl_NL.xlf",
|
"nl-NL": "src/locale/messages.nl_NL.xlf",
|
||||||
"no-NO": "src/locale/messages.no_NO.xlf",
|
"no-NO": "src/locale/messages.no_NO.xlf",
|
||||||
|
@ -2700,7 +2700,7 @@
|
|||||||
"bodySize": -1
|
"bodySize": -1
|
||||||
},
|
},
|
||||||
"response": {
|
"response": {
|
||||||
"status": -1,
|
"status": 200,
|
||||||
"statusText": "",
|
"statusText": "",
|
||||||
"httpVersion": "HTTP/1.1",
|
"httpVersion": "HTTP/1.1",
|
||||||
"cookies": [],
|
"cookies": [],
|
||||||
@ -2734,7 +2734,7 @@
|
|||||||
"bodySize": -1
|
"bodySize": -1
|
||||||
},
|
},
|
||||||
"response": {
|
"response": {
|
||||||
"status": -1,
|
"status": 200,
|
||||||
"statusText": "",
|
"statusText": "",
|
||||||
"httpVersion": "HTTP/1.1",
|
"httpVersion": "HTTP/1.1",
|
||||||
"cookies": [],
|
"cookies": [],
|
||||||
@ -2768,7 +2768,7 @@
|
|||||||
"bodySize": -1
|
"bodySize": -1
|
||||||
},
|
},
|
||||||
"response": {
|
"response": {
|
||||||
"status": -1,
|
"status": 200,
|
||||||
"statusText": "",
|
"statusText": "",
|
||||||
"httpVersion": "HTTP/1.1",
|
"httpVersion": "HTTP/1.1",
|
||||||
"cookies": [],
|
"cookies": [],
|
||||||
@ -2802,7 +2802,7 @@
|
|||||||
"bodySize": -1
|
"bodySize": -1
|
||||||
},
|
},
|
||||||
"response": {
|
"response": {
|
||||||
"status": -1,
|
"status": 200,
|
||||||
"statusText": "",
|
"statusText": "",
|
||||||
"httpVersion": "HTTP/1.1",
|
"httpVersion": "HTTP/1.1",
|
||||||
"cookies": [],
|
"cookies": [],
|
||||||
@ -2836,7 +2836,7 @@
|
|||||||
"bodySize": -1
|
"bodySize": -1
|
||||||
},
|
},
|
||||||
"response": {
|
"response": {
|
||||||
"status": -1,
|
"status": 200,
|
||||||
"statusText": "",
|
"statusText": "",
|
||||||
"httpVersion": "HTTP/1.1",
|
"httpVersion": "HTTP/1.1",
|
||||||
"cookies": [],
|
"cookies": [],
|
||||||
@ -2870,7 +2870,7 @@
|
|||||||
"bodySize": -1
|
"bodySize": -1
|
||||||
},
|
},
|
||||||
"response": {
|
"response": {
|
||||||
"status": -1,
|
"status": 200,
|
||||||
"statusText": "",
|
"statusText": "",
|
||||||
"httpVersion": "HTTP/1.1",
|
"httpVersion": "HTTP/1.1",
|
||||||
"cookies": [],
|
"cookies": [],
|
||||||
@ -2904,7 +2904,7 @@
|
|||||||
"bodySize": -1
|
"bodySize": -1
|
||||||
},
|
},
|
||||||
"response": {
|
"response": {
|
||||||
"status": -1,
|
"status": 200,
|
||||||
"statusText": "",
|
"statusText": "",
|
||||||
"httpVersion": "HTTP/1.1",
|
"httpVersion": "HTTP/1.1",
|
||||||
"cookies": [],
|
"cookies": [],
|
||||||
@ -2938,7 +2938,7 @@
|
|||||||
"bodySize": -1
|
"bodySize": -1
|
||||||
},
|
},
|
||||||
"response": {
|
"response": {
|
||||||
"status": -1,
|
"status": 200,
|
||||||
"statusText": "",
|
"statusText": "",
|
||||||
"httpVersion": "HTTP/1.1",
|
"httpVersion": "HTTP/1.1",
|
||||||
"cookies": [],
|
"cookies": [],
|
||||||
@ -2972,7 +2972,7 @@
|
|||||||
"bodySize": -1
|
"bodySize": -1
|
||||||
},
|
},
|
||||||
"response": {
|
"response": {
|
||||||
"status": -1,
|
"status": 200,
|
||||||
"statusText": "",
|
"statusText": "",
|
||||||
"httpVersion": "HTTP/1.1",
|
"httpVersion": "HTTP/1.1",
|
||||||
"cookies": [],
|
"cookies": [],
|
||||||
@ -3006,7 +3006,7 @@
|
|||||||
"bodySize": -1
|
"bodySize": -1
|
||||||
},
|
},
|
||||||
"response": {
|
"response": {
|
||||||
"status": -1,
|
"status": 200,
|
||||||
"statusText": "",
|
"statusText": "",
|
||||||
"httpVersion": "HTTP/1.1",
|
"httpVersion": "HTTP/1.1",
|
||||||
"cookies": [],
|
"cookies": [],
|
||||||
@ -3040,7 +3040,7 @@
|
|||||||
"bodySize": -1
|
"bodySize": -1
|
||||||
},
|
},
|
||||||
"response": {
|
"response": {
|
||||||
"status": -1,
|
"status": 200,
|
||||||
"statusText": "",
|
"statusText": "",
|
||||||
"httpVersion": "HTTP/1.1",
|
"httpVersion": "HTTP/1.1",
|
||||||
"cookies": [],
|
"cookies": [],
|
||||||
@ -3074,7 +3074,7 @@
|
|||||||
"bodySize": -1
|
"bodySize": -1
|
||||||
},
|
},
|
||||||
"response": {
|
"response": {
|
||||||
"status": -1,
|
"status": 200,
|
||||||
"statusText": "",
|
"statusText": "",
|
||||||
"httpVersion": "HTTP/1.1",
|
"httpVersion": "HTTP/1.1",
|
||||||
"cookies": [],
|
"cookies": [],
|
||||||
@ -3108,7 +3108,7 @@
|
|||||||
"bodySize": -1
|
"bodySize": -1
|
||||||
},
|
},
|
||||||
"response": {
|
"response": {
|
||||||
"status": -1,
|
"status": 200,
|
||||||
"statusText": "",
|
"statusText": "",
|
||||||
"httpVersion": "HTTP/1.1",
|
"httpVersion": "HTTP/1.1",
|
||||||
"cookies": [],
|
"cookies": [],
|
||||||
@ -3142,7 +3142,7 @@
|
|||||||
"bodySize": -1
|
"bodySize": -1
|
||||||
},
|
},
|
||||||
"response": {
|
"response": {
|
||||||
"status": -1,
|
"status": 200,
|
||||||
"statusText": "",
|
"statusText": "",
|
||||||
"httpVersion": "HTTP/1.1",
|
"httpVersion": "HTTP/1.1",
|
||||||
"cookies": [],
|
"cookies": [],
|
||||||
@ -3176,7 +3176,7 @@
|
|||||||
"bodySize": -1
|
"bodySize": -1
|
||||||
},
|
},
|
||||||
"response": {
|
"response": {
|
||||||
"status": -1,
|
"status": 200,
|
||||||
"statusText": "",
|
"statusText": "",
|
||||||
"httpVersion": "HTTP/1.1",
|
"httpVersion": "HTTP/1.1",
|
||||||
"cookies": [],
|
"cookies": [],
|
||||||
@ -3210,7 +3210,7 @@
|
|||||||
"bodySize": -1
|
"bodySize": -1
|
||||||
},
|
},
|
||||||
"response": {
|
"response": {
|
||||||
"status": -1,
|
"status": 200,
|
||||||
"statusText": "",
|
"statusText": "",
|
||||||
"httpVersion": "HTTP/1.1",
|
"httpVersion": "HTTP/1.1",
|
||||||
"cookies": [],
|
"cookies": [],
|
||||||
@ -3244,7 +3244,7 @@
|
|||||||
"bodySize": -1
|
"bodySize": -1
|
||||||
},
|
},
|
||||||
"response": {
|
"response": {
|
||||||
"status": -1,
|
"status": 200,
|
||||||
"statusText": "",
|
"statusText": "",
|
||||||
"httpVersion": "HTTP/1.1",
|
"httpVersion": "HTTP/1.1",
|
||||||
"cookies": [],
|
"cookies": [],
|
||||||
@ -3278,7 +3278,7 @@
|
|||||||
"bodySize": -1
|
"bodySize": -1
|
||||||
},
|
},
|
||||||
"response": {
|
"response": {
|
||||||
"status": -1,
|
"status": 200,
|
||||||
"statusText": "",
|
"statusText": "",
|
||||||
"httpVersion": "HTTP/1.1",
|
"httpVersion": "HTTP/1.1",
|
||||||
"cookies": [],
|
"cookies": [],
|
||||||
@ -3312,7 +3312,7 @@
|
|||||||
"bodySize": -1
|
"bodySize": -1
|
||||||
},
|
},
|
||||||
"response": {
|
"response": {
|
||||||
"status": -1,
|
"status": 200,
|
||||||
"statusText": "",
|
"statusText": "",
|
||||||
"httpVersion": "HTTP/1.1",
|
"httpVersion": "HTTP/1.1",
|
||||||
"cookies": [],
|
"cookies": [],
|
||||||
|
@ -425,7 +425,7 @@
|
|||||||
"bodySize": -1
|
"bodySize": -1
|
||||||
},
|
},
|
||||||
"response": {
|
"response": {
|
||||||
"status": -1,
|
"status": 200,
|
||||||
"statusText": "",
|
"statusText": "",
|
||||||
"httpVersion": "HTTP/1.1",
|
"httpVersion": "HTTP/1.1",
|
||||||
"cookies": [],
|
"cookies": [],
|
||||||
@ -470,7 +470,7 @@
|
|||||||
"bodySize": -1
|
"bodySize": -1
|
||||||
},
|
},
|
||||||
"response": {
|
"response": {
|
||||||
"status": -1,
|
"status": 200,
|
||||||
"statusText": "",
|
"statusText": "",
|
||||||
"httpVersion": "HTTP/1.1",
|
"httpVersion": "HTTP/1.1",
|
||||||
"cookies": [],
|
"cookies": [],
|
||||||
@ -645,7 +645,7 @@
|
|||||||
"bodySize": -1
|
"bodySize": -1
|
||||||
},
|
},
|
||||||
"response": {
|
"response": {
|
||||||
"status": -1,
|
"status": 200,
|
||||||
"statusText": "",
|
"statusText": "",
|
||||||
"httpVersion": "HTTP/1.1",
|
"httpVersion": "HTTP/1.1",
|
||||||
"cookies": [],
|
"cookies": [],
|
||||||
@ -685,7 +685,7 @@
|
|||||||
"bodySize": -1
|
"bodySize": -1
|
||||||
},
|
},
|
||||||
"response": {
|
"response": {
|
||||||
"status": -1,
|
"status": 200,
|
||||||
"statusText": "",
|
"statusText": "",
|
||||||
"httpVersion": "HTTP/1.1",
|
"httpVersion": "HTTP/1.1",
|
||||||
"cookies": [],
|
"cookies": [],
|
||||||
@ -729,7 +729,7 @@
|
|||||||
"bodySize": -1
|
"bodySize": -1
|
||||||
},
|
},
|
||||||
"response": {
|
"response": {
|
||||||
"status": -1,
|
"status": 200,
|
||||||
"statusText": "",
|
"statusText": "",
|
||||||
"httpVersion": "HTTP/1.1",
|
"httpVersion": "HTTP/1.1",
|
||||||
"cookies": [],
|
"cookies": [],
|
||||||
|
@ -843,7 +843,7 @@
|
|||||||
"bodySize": -1
|
"bodySize": -1
|
||||||
},
|
},
|
||||||
"response": {
|
"response": {
|
||||||
"status": -1,
|
"status": 200,
|
||||||
"statusText": "",
|
"statusText": "",
|
||||||
"httpVersion": "HTTP/1.1",
|
"httpVersion": "HTTP/1.1",
|
||||||
"cookies": [],
|
"cookies": [],
|
||||||
@ -994,7 +994,7 @@
|
|||||||
"bodySize": -1
|
"bodySize": -1
|
||||||
},
|
},
|
||||||
"response": {
|
"response": {
|
||||||
"status": -1,
|
"status": 200,
|
||||||
"statusText": "",
|
"statusText": "",
|
||||||
"httpVersion": "HTTP/1.1",
|
"httpVersion": "HTTP/1.1",
|
||||||
"cookies": [],
|
"cookies": [],
|
||||||
|
@ -996,7 +996,7 @@
|
|||||||
"bodySize": -1
|
"bodySize": -1
|
||||||
},
|
},
|
||||||
"response": {
|
"response": {
|
||||||
"status": -1,
|
"status": 200,
|
||||||
"statusText": "",
|
"statusText": "",
|
||||||
"httpVersion": "HTTP/1.1",
|
"httpVersion": "HTTP/1.1",
|
||||||
"cookies": [],
|
"cookies": [],
|
||||||
@ -1301,7 +1301,7 @@
|
|||||||
"bodySize": -1
|
"bodySize": -1
|
||||||
},
|
},
|
||||||
"response": {
|
"response": {
|
||||||
"status": -1,
|
"status": 200,
|
||||||
"statusText": "",
|
"statusText": "",
|
||||||
"httpVersion": "HTTP/1.1",
|
"httpVersion": "HTTP/1.1",
|
||||||
"cookies": [],
|
"cookies": [],
|
||||||
@ -1484,7 +1484,7 @@
|
|||||||
"bodySize": -1
|
"bodySize": -1
|
||||||
},
|
},
|
||||||
"response": {
|
"response": {
|
||||||
"status": -1,
|
"status": 200,
|
||||||
"statusText": "",
|
"statusText": "",
|
||||||
"httpVersion": "HTTP/1.1",
|
"httpVersion": "HTTP/1.1",
|
||||||
"cookies": [],
|
"cookies": [],
|
||||||
@ -1518,7 +1518,7 @@
|
|||||||
"bodySize": -1
|
"bodySize": -1
|
||||||
},
|
},
|
||||||
"response": {
|
"response": {
|
||||||
"status": -1,
|
"status": 200,
|
||||||
"statusText": "",
|
"statusText": "",
|
||||||
"httpVersion": "HTTP/1.1",
|
"httpVersion": "HTTP/1.1",
|
||||||
"cookies": [],
|
"cookies": [],
|
||||||
@ -1552,7 +1552,7 @@
|
|||||||
"bodySize": -1
|
"bodySize": -1
|
||||||
},
|
},
|
||||||
"response": {
|
"response": {
|
||||||
"status": -1,
|
"status": 200,
|
||||||
"statusText": "",
|
"statusText": "",
|
||||||
"httpVersion": "HTTP/1.1",
|
"httpVersion": "HTTP/1.1",
|
||||||
"cookies": [],
|
"cookies": [],
|
||||||
@ -1586,7 +1586,7 @@
|
|||||||
"bodySize": -1
|
"bodySize": -1
|
||||||
},
|
},
|
||||||
"response": {
|
"response": {
|
||||||
"status": -1,
|
"status": 200,
|
||||||
"statusText": "",
|
"statusText": "",
|
||||||
"httpVersion": "HTTP/1.1",
|
"httpVersion": "HTTP/1.1",
|
||||||
"cookies": [],
|
"cookies": [],
|
||||||
@ -1620,7 +1620,7 @@
|
|||||||
"bodySize": -1
|
"bodySize": -1
|
||||||
},
|
},
|
||||||
"response": {
|
"response": {
|
||||||
"status": -1,
|
"status": 200,
|
||||||
"statusText": "",
|
"statusText": "",
|
||||||
"httpVersion": "HTTP/1.1",
|
"httpVersion": "HTTP/1.1",
|
||||||
"cookies": [],
|
"cookies": [],
|
||||||
@ -1654,7 +1654,7 @@
|
|||||||
"bodySize": -1
|
"bodySize": -1
|
||||||
},
|
},
|
||||||
"response": {
|
"response": {
|
||||||
"status": -1,
|
"status": 200,
|
||||||
"statusText": "",
|
"statusText": "",
|
||||||
"httpVersion": "HTTP/1.1",
|
"httpVersion": "HTTP/1.1",
|
||||||
"cookies": [],
|
"cookies": [],
|
||||||
@ -1688,7 +1688,7 @@
|
|||||||
"bodySize": -1
|
"bodySize": -1
|
||||||
},
|
},
|
||||||
"response": {
|
"response": {
|
||||||
"status": -1,
|
"status": 200,
|
||||||
"statusText": "",
|
"statusText": "",
|
||||||
"httpVersion": "HTTP/1.1",
|
"httpVersion": "HTTP/1.1",
|
||||||
"cookies": [],
|
"cookies": [],
|
||||||
@ -1722,7 +1722,7 @@
|
|||||||
"bodySize": -1
|
"bodySize": -1
|
||||||
},
|
},
|
||||||
"response": {
|
"response": {
|
||||||
"status": -1,
|
"status": 200,
|
||||||
"statusText": "",
|
"statusText": "",
|
||||||
"httpVersion": "HTTP/1.1",
|
"httpVersion": "HTTP/1.1",
|
||||||
"cookies": [],
|
"cookies": [],
|
||||||
@ -1756,7 +1756,7 @@
|
|||||||
"bodySize": -1
|
"bodySize": -1
|
||||||
},
|
},
|
||||||
"response": {
|
"response": {
|
||||||
"status": -1,
|
"status": 200,
|
||||||
"statusText": "",
|
"statusText": "",
|
||||||
"httpVersion": "HTTP/1.1",
|
"httpVersion": "HTTP/1.1",
|
||||||
"cookies": [],
|
"cookies": [],
|
||||||
@ -1790,7 +1790,7 @@
|
|||||||
"bodySize": -1
|
"bodySize": -1
|
||||||
},
|
},
|
||||||
"response": {
|
"response": {
|
||||||
"status": -1,
|
"status": 200,
|
||||||
"statusText": "",
|
"statusText": "",
|
||||||
"httpVersion": "HTTP/1.1",
|
"httpVersion": "HTTP/1.1",
|
||||||
"cookies": [],
|
"cookies": [],
|
||||||
@ -1824,7 +1824,7 @@
|
|||||||
"bodySize": -1
|
"bodySize": -1
|
||||||
},
|
},
|
||||||
"response": {
|
"response": {
|
||||||
"status": -1,
|
"status": 200,
|
||||||
"statusText": "",
|
"statusText": "",
|
||||||
"httpVersion": "HTTP/1.1",
|
"httpVersion": "HTTP/1.1",
|
||||||
"cookies": [],
|
"cookies": [],
|
||||||
@ -1858,7 +1858,7 @@
|
|||||||
"bodySize": -1
|
"bodySize": -1
|
||||||
},
|
},
|
||||||
"response": {
|
"response": {
|
||||||
"status": -1,
|
"status": 200,
|
||||||
"statusText": "",
|
"statusText": "",
|
||||||
"httpVersion": "HTTP/1.1",
|
"httpVersion": "HTTP/1.1",
|
||||||
"cookies": [],
|
"cookies": [],
|
||||||
@ -1892,7 +1892,7 @@
|
|||||||
"bodySize": -1
|
"bodySize": -1
|
||||||
},
|
},
|
||||||
"response": {
|
"response": {
|
||||||
"status": -1,
|
"status": 200,
|
||||||
"statusText": "",
|
"statusText": "",
|
||||||
"httpVersion": "HTTP/1.1",
|
"httpVersion": "HTTP/1.1",
|
||||||
"cookies": [],
|
"cookies": [],
|
||||||
@ -1926,7 +1926,7 @@
|
|||||||
"bodySize": -1
|
"bodySize": -1
|
||||||
},
|
},
|
||||||
"response": {
|
"response": {
|
||||||
"status": -1,
|
"status": 200,
|
||||||
"statusText": "",
|
"statusText": "",
|
||||||
"httpVersion": "HTTP/1.1",
|
"httpVersion": "HTTP/1.1",
|
||||||
"cookies": [],
|
"cookies": [],
|
||||||
@ -1960,7 +1960,7 @@
|
|||||||
"bodySize": -1
|
"bodySize": -1
|
||||||
},
|
},
|
||||||
"response": {
|
"response": {
|
||||||
"status": -1,
|
"status": 200,
|
||||||
"statusText": "",
|
"statusText": "",
|
||||||
"httpVersion": "HTTP/1.1",
|
"httpVersion": "HTTP/1.1",
|
||||||
"cookies": [],
|
"cookies": [],
|
||||||
@ -1994,7 +1994,7 @@
|
|||||||
"bodySize": -1
|
"bodySize": -1
|
||||||
},
|
},
|
||||||
"response": {
|
"response": {
|
||||||
"status": -1,
|
"status": 200,
|
||||||
"statusText": "",
|
"statusText": "",
|
||||||
"httpVersion": "HTTP/1.1",
|
"httpVersion": "HTTP/1.1",
|
||||||
"cookies": [],
|
"cookies": [],
|
||||||
@ -2028,7 +2028,7 @@
|
|||||||
"bodySize": -1
|
"bodySize": -1
|
||||||
},
|
},
|
||||||
"response": {
|
"response": {
|
||||||
"status": -1,
|
"status": 200,
|
||||||
"statusText": "",
|
"statusText": "",
|
||||||
"httpVersion": "HTTP/1.1",
|
"httpVersion": "HTTP/1.1",
|
||||||
"cookies": [],
|
"cookies": [],
|
||||||
@ -2062,7 +2062,7 @@
|
|||||||
"bodySize": -1
|
"bodySize": -1
|
||||||
},
|
},
|
||||||
"response": {
|
"response": {
|
||||||
"status": -1,
|
"status": 200,
|
||||||
"statusText": "",
|
"statusText": "",
|
||||||
"httpVersion": "HTTP/1.1",
|
"httpVersion": "HTTP/1.1",
|
||||||
"cookies": [],
|
"cookies": [],
|
||||||
@ -2096,7 +2096,7 @@
|
|||||||
"bodySize": -1
|
"bodySize": -1
|
||||||
},
|
},
|
||||||
"response": {
|
"response": {
|
||||||
"status": -1,
|
"status": 200,
|
||||||
"statusText": "",
|
"statusText": "",
|
||||||
"httpVersion": "HTTP/1.1",
|
"httpVersion": "HTTP/1.1",
|
||||||
"cookies": [],
|
"cookies": [],
|
||||||
@ -2130,7 +2130,7 @@
|
|||||||
"bodySize": -1
|
"bodySize": -1
|
||||||
},
|
},
|
||||||
"response": {
|
"response": {
|
||||||
"status": -1,
|
"status": 200,
|
||||||
"statusText": "",
|
"statusText": "",
|
||||||
"httpVersion": "HTTP/1.1",
|
"httpVersion": "HTTP/1.1",
|
||||||
"cookies": [],
|
"cookies": [],
|
||||||
@ -2164,7 +2164,7 @@
|
|||||||
"bodySize": -1
|
"bodySize": -1
|
||||||
},
|
},
|
||||||
"response": {
|
"response": {
|
||||||
"status": -1,
|
"status": 200,
|
||||||
"statusText": "",
|
"statusText": "",
|
||||||
"httpVersion": "HTTP/1.1",
|
"httpVersion": "HTTP/1.1",
|
||||||
"cookies": [],
|
"cookies": [],
|
||||||
@ -2198,7 +2198,7 @@
|
|||||||
"bodySize": -1
|
"bodySize": -1
|
||||||
},
|
},
|
||||||
"response": {
|
"response": {
|
||||||
"status": -1,
|
"status": 200,
|
||||||
"statusText": "",
|
"statusText": "",
|
||||||
"httpVersion": "HTTP/1.1",
|
"httpVersion": "HTTP/1.1",
|
||||||
"cookies": [],
|
"cookies": [],
|
||||||
@ -2232,7 +2232,7 @@
|
|||||||
"bodySize": -1
|
"bodySize": -1
|
||||||
},
|
},
|
||||||
"response": {
|
"response": {
|
||||||
"status": -1,
|
"status": 200,
|
||||||
"statusText": "",
|
"statusText": "",
|
||||||
"httpVersion": "HTTP/1.1",
|
"httpVersion": "HTTP/1.1",
|
||||||
"cookies": [],
|
"cookies": [],
|
||||||
@ -2266,7 +2266,7 @@
|
|||||||
"bodySize": -1
|
"bodySize": -1
|
||||||
},
|
},
|
||||||
"response": {
|
"response": {
|
||||||
"status": -1,
|
"status": 200,
|
||||||
"statusText": "",
|
"statusText": "",
|
||||||
"httpVersion": "HTTP/1.1",
|
"httpVersion": "HTTP/1.1",
|
||||||
"cookies": [],
|
"cookies": [],
|
||||||
@ -2300,7 +2300,7 @@
|
|||||||
"bodySize": -1
|
"bodySize": -1
|
||||||
},
|
},
|
||||||
"response": {
|
"response": {
|
||||||
"status": -1,
|
"status": 200,
|
||||||
"statusText": "",
|
"statusText": "",
|
||||||
"httpVersion": "HTTP/1.1",
|
"httpVersion": "HTTP/1.1",
|
||||||
"cookies": [],
|
"cookies": [],
|
||||||
@ -2334,7 +2334,7 @@
|
|||||||
"bodySize": -1
|
"bodySize": -1
|
||||||
},
|
},
|
||||||
"response": {
|
"response": {
|
||||||
"status": -1,
|
"status": 200,
|
||||||
"statusText": "",
|
"statusText": "",
|
||||||
"httpVersion": "HTTP/1.1",
|
"httpVersion": "HTTP/1.1",
|
||||||
"cookies": [],
|
"cookies": [],
|
||||||
@ -2368,7 +2368,7 @@
|
|||||||
"bodySize": -1
|
"bodySize": -1
|
||||||
},
|
},
|
||||||
"response": {
|
"response": {
|
||||||
"status": -1,
|
"status": 200,
|
||||||
"statusText": "",
|
"statusText": "",
|
||||||
"httpVersion": "HTTP/1.1",
|
"httpVersion": "HTTP/1.1",
|
||||||
"cookies": [],
|
"cookies": [],
|
||||||
@ -2402,7 +2402,7 @@
|
|||||||
"bodySize": -1
|
"bodySize": -1
|
||||||
},
|
},
|
||||||
"response": {
|
"response": {
|
||||||
"status": -1,
|
"status": 200,
|
||||||
"statusText": "",
|
"statusText": "",
|
||||||
"httpVersion": "HTTP/1.1",
|
"httpVersion": "HTTP/1.1",
|
||||||
"cookies": [],
|
"cookies": [],
|
||||||
@ -2436,7 +2436,7 @@
|
|||||||
"bodySize": -1
|
"bodySize": -1
|
||||||
},
|
},
|
||||||
"response": {
|
"response": {
|
||||||
"status": -1,
|
"status": 200,
|
||||||
"statusText": "",
|
"statusText": "",
|
||||||
"httpVersion": "HTTP/1.1",
|
"httpVersion": "HTTP/1.1",
|
||||||
"cookies": [],
|
"cookies": [],
|
||||||
@ -2470,7 +2470,7 @@
|
|||||||
"bodySize": -1
|
"bodySize": -1
|
||||||
},
|
},
|
||||||
"response": {
|
"response": {
|
||||||
"status": -1,
|
"status": 200,
|
||||||
"statusText": "",
|
"statusText": "",
|
||||||
"httpVersion": "HTTP/1.1",
|
"httpVersion": "HTTP/1.1",
|
||||||
"cookies": [],
|
"cookies": [],
|
||||||
|
File diff suppressed because it is too large
Load Diff
4036
src-ui/package-lock.json
generated
4036
src-ui/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@ -11,17 +11,17 @@
|
|||||||
},
|
},
|
||||||
"private": true,
|
"private": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@angular/cdk": "^17.0.4",
|
"@angular/cdk": "^17.1.2",
|
||||||
"@angular/common": "~17.0.8",
|
"@angular/common": "~17.1.2",
|
||||||
"@angular/compiler": "~17.0.8",
|
"@angular/compiler": "~17.1.2",
|
||||||
"@angular/core": "~17.0.8",
|
"@angular/core": "~17.1.2",
|
||||||
"@angular/forms": "~17.0.8",
|
"@angular/forms": "~17.1.2",
|
||||||
"@angular/localize": "~17.0.8",
|
"@angular/localize": "~17.1.2",
|
||||||
"@angular/platform-browser": "~17.0.8",
|
"@angular/platform-browser": "~17.1.2",
|
||||||
"@angular/platform-browser-dynamic": "~17.0.8",
|
"@angular/platform-browser-dynamic": "~17.1.2",
|
||||||
"@angular/router": "~17.0.8",
|
"@angular/router": "~17.1.2",
|
||||||
"@ng-bootstrap/ng-bootstrap": "^16.0.0",
|
"@ng-bootstrap/ng-bootstrap": "^16.0.0",
|
||||||
"@ng-select/ng-select": "^12.0.4",
|
"@ng-select/ng-select": "^12.0.6",
|
||||||
"@ngneat/dirty-check-forms": "^3.0.3",
|
"@ngneat/dirty-check-forms": "^3.0.3",
|
||||||
"@popperjs/core": "^2.11.8",
|
"@popperjs/core": "^2.11.8",
|
||||||
"bootstrap": "^5.3.2",
|
"bootstrap": "^5.3.2",
|
||||||
@ -31,33 +31,33 @@
|
|||||||
"ngx-color": "^9.0.0",
|
"ngx-color": "^9.0.0",
|
||||||
"ngx-cookie-service": "^17.0.1",
|
"ngx-cookie-service": "^17.0.1",
|
||||||
"ngx-file-drop": "^16.0.0",
|
"ngx-file-drop": "^16.0.0",
|
||||||
"ngx-ui-tour-ng-bootstrap": "^14.0.1",
|
"ngx-ui-tour-ng-bootstrap": "^14.0.2",
|
||||||
"pdfjs-dist": "^3.11.174",
|
"pdfjs-dist": "^3.11.174",
|
||||||
"rxjs": "^7.8.1",
|
"rxjs": "^7.8.1",
|
||||||
"tslib": "^2.6.2",
|
"tslib": "^2.6.2",
|
||||||
"uuid": "^9.0.1",
|
"uuid": "^9.0.1",
|
||||||
"zone.js": "^0.14.2"
|
"zone.js": "^0.14.3"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@angular-builders/jest": "17.0.0",
|
"@angular-builders/jest": "17.0.0",
|
||||||
"@angular-devkit/build-angular": "~17.0.8",
|
"@angular-devkit/build-angular": "~17.1.2",
|
||||||
"@angular-eslint/builder": "17.1.1",
|
"@angular-eslint/builder": "17.2.1",
|
||||||
"@angular-eslint/eslint-plugin": "17.1.1",
|
"@angular-eslint/eslint-plugin": "17.2.1",
|
||||||
"@angular-eslint/eslint-plugin-template": "17.1.1",
|
"@angular-eslint/eslint-plugin-template": "17.2.1",
|
||||||
"@angular-eslint/schematics": "17.1.1",
|
"@angular-eslint/schematics": "17.2.1",
|
||||||
"@angular-eslint/template-parser": "17.1.1",
|
"@angular-eslint/template-parser": "17.2.1",
|
||||||
"@angular/cli": "~17.0.8",
|
"@angular/cli": "~17.1.2",
|
||||||
"@angular/compiler-cli": "~17.0.7",
|
"@angular/compiler-cli": "~17.1.2",
|
||||||
"@playwright/test": "^1.40.1",
|
"@playwright/test": "^1.41.2",
|
||||||
"@types/jest": "^29.5.10",
|
"@types/jest": "^29.5.12",
|
||||||
"@types/node": "^20.10.6",
|
"@types/node": "^20.11.16",
|
||||||
"@typescript-eslint/eslint-plugin": "^6.17.0",
|
"@typescript-eslint/eslint-plugin": "^6.20.0",
|
||||||
"@typescript-eslint/parser": "^6.17.0",
|
"@typescript-eslint/parser": "^6.20.0",
|
||||||
"concurrently": "^8.2.2",
|
"concurrently": "^8.2.2",
|
||||||
"eslint": "^8.56.0",
|
"eslint": "^8.56.0",
|
||||||
"jest": "29.7.0",
|
"jest": "29.7.0",
|
||||||
"jest-environment-jsdom": "^29.7.0",
|
"jest-environment-jsdom": "^29.7.0",
|
||||||
"jest-preset-angular": "^13.1.4",
|
"jest-preset-angular": "^14.0.0",
|
||||||
"jest-websocket-mock": "^2.5.0",
|
"jest-websocket-mock": "^2.5.0",
|
||||||
"patch-package": "^8.0.0",
|
"patch-package": "^8.0.0",
|
||||||
"ts-node": "~10.9.1",
|
"ts-node": "~10.9.1",
|
||||||
|
@ -23,6 +23,7 @@ import localeFi from '@angular/common/locales/fi'
|
|||||||
import localeFr from '@angular/common/locales/fr'
|
import localeFr from '@angular/common/locales/fr'
|
||||||
import localeHu from '@angular/common/locales/hu'
|
import localeHu from '@angular/common/locales/hu'
|
||||||
import localeIt from '@angular/common/locales/it'
|
import localeIt from '@angular/common/locales/it'
|
||||||
|
import localeJa from '@angular/common/locales/ja'
|
||||||
import localeLb from '@angular/common/locales/lb'
|
import localeLb from '@angular/common/locales/lb'
|
||||||
import localeNl from '@angular/common/locales/nl'
|
import localeNl from '@angular/common/locales/nl'
|
||||||
import localeNo from '@angular/common/locales/no'
|
import localeNo from '@angular/common/locales/no'
|
||||||
@ -53,6 +54,7 @@ registerLocaleData(localeFi)
|
|||||||
registerLocaleData(localeFr)
|
registerLocaleData(localeFr)
|
||||||
registerLocaleData(localeHu)
|
registerLocaleData(localeHu)
|
||||||
registerLocaleData(localeIt)
|
registerLocaleData(localeIt)
|
||||||
|
registerLocaleData(localeJa)
|
||||||
registerLocaleData(localeLb)
|
registerLocaleData(localeLb)
|
||||||
registerLocaleData(localeNl)
|
registerLocaleData(localeNl)
|
||||||
registerLocaleData(localeNo)
|
registerLocaleData(localeNo)
|
||||||
|
@ -295,6 +295,7 @@ import localeFi from '@angular/common/locales/fi'
|
|||||||
import localeFr from '@angular/common/locales/fr'
|
import localeFr from '@angular/common/locales/fr'
|
||||||
import localeHu from '@angular/common/locales/hu'
|
import localeHu from '@angular/common/locales/hu'
|
||||||
import localeIt from '@angular/common/locales/it'
|
import localeIt from '@angular/common/locales/it'
|
||||||
|
import localeJa from '@angular/common/locales/ja'
|
||||||
import localeLb from '@angular/common/locales/lb'
|
import localeLb from '@angular/common/locales/lb'
|
||||||
import localeNl from '@angular/common/locales/nl'
|
import localeNl from '@angular/common/locales/nl'
|
||||||
import localeNo from '@angular/common/locales/no'
|
import localeNo from '@angular/common/locales/no'
|
||||||
@ -325,6 +326,7 @@ registerLocaleData(localeFi)
|
|||||||
registerLocaleData(localeFr)
|
registerLocaleData(localeFr)
|
||||||
registerLocaleData(localeHu)
|
registerLocaleData(localeHu)
|
||||||
registerLocaleData(localeIt)
|
registerLocaleData(localeIt)
|
||||||
|
registerLocaleData(localeJa)
|
||||||
registerLocaleData(localeLb)
|
registerLocaleData(localeLb)
|
||||||
registerLocaleData(localeNl)
|
registerLocaleData(localeNl)
|
||||||
registerLocaleData(localeNo)
|
registerLocaleData(localeNo)
|
||||||
|
@ -158,6 +158,14 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<h4 class="mt-4" i18n>Document editing</h4>
|
||||||
|
|
||||||
|
<div class="row mb-3">
|
||||||
|
<div class="offset-md-3 col">
|
||||||
|
<pngx-input-check i18n-title title="Automatically remove inbox tag(s) on save" formControlName="documentEditingRemoveInboxTags"></pngx-input-check>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<h4 class="mt-4" i18n>Bulk editing</h4>
|
<h4 class="mt-4" i18n>Bulk editing</h4>
|
||||||
|
|
||||||
<div class="row mb-3">
|
<div class="row mb-3">
|
||||||
|
@ -289,7 +289,7 @@ describe('SettingsComponent', () => {
|
|||||||
expect(toastErrorSpy).toHaveBeenCalled()
|
expect(toastErrorSpy).toHaveBeenCalled()
|
||||||
expect(storeSpy).toHaveBeenCalled()
|
expect(storeSpy).toHaveBeenCalled()
|
||||||
expect(appearanceSettingsSpy).not.toHaveBeenCalled()
|
expect(appearanceSettingsSpy).not.toHaveBeenCalled()
|
||||||
expect(setSpy).toHaveBeenCalledTimes(24)
|
expect(setSpy).toHaveBeenCalledTimes(25)
|
||||||
|
|
||||||
// succeed
|
// succeed
|
||||||
storeSpy.mockReturnValueOnce(of(true))
|
storeSpy.mockReturnValueOnce(of(true))
|
||||||
|
@ -88,6 +88,7 @@ export class SettingsComponent
|
|||||||
defaultPermsViewGroups: new FormControl(null),
|
defaultPermsViewGroups: new FormControl(null),
|
||||||
defaultPermsEditUsers: new FormControl(null),
|
defaultPermsEditUsers: new FormControl(null),
|
||||||
defaultPermsEditGroups: new FormControl(null),
|
defaultPermsEditGroups: new FormControl(null),
|
||||||
|
documentEditingRemoveInboxTags: new FormControl(null),
|
||||||
|
|
||||||
notificationsConsumerNewDocument: new FormControl(null),
|
notificationsConsumerNewDocument: new FormControl(null),
|
||||||
notificationsConsumerSuccess: new FormControl(null),
|
notificationsConsumerSuccess: new FormControl(null),
|
||||||
@ -271,6 +272,9 @@ export class SettingsComponent
|
|||||||
defaultPermsEditGroups: this.settings.get(
|
defaultPermsEditGroups: this.settings.get(
|
||||||
SETTINGS_KEYS.DEFAULT_PERMS_EDIT_GROUPS
|
SETTINGS_KEYS.DEFAULT_PERMS_EDIT_GROUPS
|
||||||
),
|
),
|
||||||
|
documentEditingRemoveInboxTags: this.settings.get(
|
||||||
|
SETTINGS_KEYS.DOCUMENT_EDITING_REMOVE_INBOX_TAGS
|
||||||
|
),
|
||||||
savedViews: {},
|
savedViews: {},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -484,6 +488,10 @@ export class SettingsComponent
|
|||||||
SETTINGS_KEYS.DEFAULT_PERMS_EDIT_GROUPS,
|
SETTINGS_KEYS.DEFAULT_PERMS_EDIT_GROUPS,
|
||||||
this.settingsForm.value.defaultPermsEditGroups
|
this.settingsForm.value.defaultPermsEditGroups
|
||||||
)
|
)
|
||||||
|
this.settings.set(
|
||||||
|
SETTINGS_KEYS.DOCUMENT_EDITING_REMOVE_INBOX_TAGS,
|
||||||
|
this.settingsForm.value.documentEditingRemoveInboxTags
|
||||||
|
)
|
||||||
this.settings.setLanguage(this.settingsForm.value.displayLanguage)
|
this.settings.setLanguage(this.settingsForm.value.displayLanguage)
|
||||||
this.settings
|
this.settings
|
||||||
.storeSettings()
|
.storeSettings()
|
||||||
|
@ -47,22 +47,25 @@ describe('NumberComponent', () => {
|
|||||||
expect(component.value).toEqual(1002)
|
expect(component.value).toEqual(1002)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should support float & monetary values', () => {
|
it('should support float, monetary values & scientific notation', () => {
|
||||||
component.writeValue(11.13)
|
const mockFn = jest.fn()
|
||||||
expect(component.value).toEqual(11)
|
component.registerOnChange(mockFn)
|
||||||
|
|
||||||
|
component.step = 1
|
||||||
|
component.onChange(11.13)
|
||||||
|
expect(mockFn).toHaveBeenCalledWith(11)
|
||||||
|
|
||||||
|
component.onChange(1.23456789e8)
|
||||||
|
expect(mockFn).toHaveBeenCalledWith(123456789)
|
||||||
|
|
||||||
|
component.step = 0.01
|
||||||
|
component.onChange(11.1)
|
||||||
|
expect(mockFn).toHaveBeenCalledWith('11.10')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should display monetary values fixed to 2 decimals', () => {
|
||||||
component.step = 0.01
|
component.step = 0.01
|
||||||
component.writeValue(11.1)
|
component.writeValue(11.1)
|
||||||
expect(component.value).toEqual('11.10')
|
expect(component.value).toEqual('11.10')
|
||||||
component.step = 0.1
|
|
||||||
component.writeValue(12.3456)
|
|
||||||
expect(component.value).toEqual(12.3456)
|
|
||||||
// float (step = .1) doesn't force 2 decimals
|
|
||||||
component.writeValue(11.1)
|
|
||||||
expect(component.value).toEqual(11.1)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('should support scientific notation', () => {
|
|
||||||
component.writeValue(1.23456789e8)
|
|
||||||
expect(component.value).toEqual(123456789)
|
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
@ -36,9 +36,18 @@ export class NumberComponent extends AbstractInputComponent<number> {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
registerOnChange(fn: any): void {
|
||||||
|
this.onChange = (newValue: any) => {
|
||||||
|
// number validation
|
||||||
|
if (this.step === 1 && newValue?.toString().indexOf('e') === -1)
|
||||||
|
newValue = parseInt(newValue, 10)
|
||||||
|
if (this.step === 0.01) newValue = parseFloat(newValue).toFixed(2)
|
||||||
|
fn(newValue)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
writeValue(newValue: any): void {
|
writeValue(newValue: any): void {
|
||||||
if (this.step === 1 && newValue?.toString().indexOf('e') === -1)
|
// Allow monetary values to be displayed with 2 decimals
|
||||||
newValue = parseInt(newValue, 10)
|
|
||||||
if (this.step === 0.01) newValue = parseFloat(newValue).toFixed(2)
|
if (this.step === 0.01) newValue = parseFloat(newValue).toFixed(2)
|
||||||
super.writeValue(newValue)
|
super.writeValue(newValue)
|
||||||
}
|
}
|
||||||
|
@ -15,7 +15,7 @@
|
|||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
<div [ngClass]="{'col-md-9': horizontal, 'align-items-center': horizontal, 'd-flex': horizontal}">
|
<div [ngClass]="{'align-items-center': horizontal, 'd-flex': horizontal}">
|
||||||
<div class="form-check form-switch">
|
<div class="form-check form-switch">
|
||||||
<input #inputField type="checkbox" class="form-check-input" [id]="inputId" [(ngModel)]="value" (change)="onChange(value)" (blur)="onTouched()" [disabled]="disabled">
|
<input #inputField type="checkbox" class="form-check-input" [id]="inputId" [(ngModel)]="value" (change)="onChange(value)" (blur)="onTouched()" [disabled]="disabled">
|
||||||
@if (horizontal) {
|
@if (horizontal) {
|
||||||
|
@ -5,12 +5,15 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="modal-body">
|
<div class="modal-body">
|
||||||
|
|
||||||
@if (!object && message) {
|
|
||||||
<p class="mb-3" [innerHTML]="message | safeHtml"></p>
|
|
||||||
}
|
|
||||||
|
|
||||||
<form [formGroup]="form">
|
<form [formGroup]="form">
|
||||||
<pngx-permissions-form [users]="users" formControlName="permissions_form"></pngx-permissions-form>
|
<div class="form-group">
|
||||||
|
<pngx-permissions-form [users]="users" formControlName="permissions_form"></pngx-permissions-form>
|
||||||
|
</div>
|
||||||
|
<div class="form-group mt-4">
|
||||||
|
<div class="offset-lg-3 row">
|
||||||
|
<pngx-input-switch i18n-title title="Merge with existing permissions" [horizontal]="true" [hint]="hint" formControlName="merge"></pngx-input-switch>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
@ -20,5 +23,5 @@
|
|||||||
<span class="visually-hidden" i18n>Loading...</span>
|
<span class="visually-hidden" i18n>Loading...</span>
|
||||||
}
|
}
|
||||||
<button type="button" class="btn btn-outline-primary" (click)="cancelClicked()" [disabled]="!buttonsEnabled" i18n>Cancel</button>
|
<button type="button" class="btn btn-outline-primary" (click)="cancelClicked()" [disabled]="!buttonsEnabled" i18n>Cancel</button>
|
||||||
<button type="button" class="btn btn-primary" (click)="confirmClicked.emit(permissions)" [disabled]="!buttonsEnabled" i18n>Confirm</button>
|
<button type="button" class="btn btn-primary" (click)="confirm()" [disabled]="!buttonsEnabled" i18n>Confirm</button>
|
||||||
</div>
|
</div>
|
||||||
|
@ -11,6 +11,7 @@ import { NgSelectModule } from '@ng-select/ng-select'
|
|||||||
import { FormsModule, ReactiveFormsModule } from '@angular/forms'
|
import { FormsModule, ReactiveFormsModule } from '@angular/forms'
|
||||||
import { PermissionsUserComponent } from '../input/permissions/permissions-user/permissions-user.component'
|
import { PermissionsUserComponent } from '../input/permissions/permissions-user/permissions-user.component'
|
||||||
import { PermissionsGroupComponent } from '../input/permissions/permissions-group/permissions-group.component'
|
import { PermissionsGroupComponent } from '../input/permissions/permissions-group/permissions-group.component'
|
||||||
|
import { SwitchComponent } from '../input/switch/switch.component'
|
||||||
|
|
||||||
const set_permissions = {
|
const set_permissions = {
|
||||||
owner: 10,
|
owner: 10,
|
||||||
@ -37,6 +38,7 @@ describe('PermissionsDialogComponent', () => {
|
|||||||
PermissionsDialogComponent,
|
PermissionsDialogComponent,
|
||||||
SafeHtmlPipe,
|
SafeHtmlPipe,
|
||||||
SelectComponent,
|
SelectComponent,
|
||||||
|
SwitchComponent,
|
||||||
PermissionsFormComponent,
|
PermissionsFormComponent,
|
||||||
PermissionsUserComponent,
|
PermissionsUserComponent,
|
||||||
PermissionsGroupComponent,
|
PermissionsGroupComponent,
|
||||||
@ -112,4 +114,23 @@ describe('PermissionsDialogComponent', () => {
|
|||||||
expect(component.title).toEqual(`Edit permissions for ${obj.name}`)
|
expect(component.title).toEqual(`Edit permissions for ${obj.name}`)
|
||||||
expect(component.permissions).toEqual(set_permissions)
|
expect(component.permissions).toEqual(set_permissions)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('should toggle hint based on object existence (if editing) or merge flag', () => {
|
||||||
|
component.form.get('merge').setValue(true)
|
||||||
|
expect(component.hint.includes('Existing')).toBeTruthy()
|
||||||
|
component.form.get('merge').setValue(false)
|
||||||
|
expect(component.hint.includes('will be replaced')).toBeTruthy()
|
||||||
|
component.object = {}
|
||||||
|
expect(component.hint).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should emit permissions and merge flag on confirm', () => {
|
||||||
|
const confirmSpy = jest.spyOn(component.confirmClicked, 'emit')
|
||||||
|
component.form.get('permissions_form').setValue(set_permissions)
|
||||||
|
component.confirm()
|
||||||
|
expect(confirmSpy).toHaveBeenCalledWith({
|
||||||
|
permissions: set_permissions,
|
||||||
|
merge: true,
|
||||||
|
})
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
@ -32,6 +32,7 @@ export class PermissionsDialogComponent {
|
|||||||
this.o = o
|
this.o = o
|
||||||
this.title = $localize`Edit permissions for ` + o['name']
|
this.title = $localize`Edit permissions for ` + o['name']
|
||||||
this.form.patchValue({
|
this.form.patchValue({
|
||||||
|
merge: true,
|
||||||
permissions_form: {
|
permissions_form: {
|
||||||
owner: o.owner,
|
owner: o.owner,
|
||||||
set_permissions: o.permissions,
|
set_permissions: o.permissions,
|
||||||
@ -43,8 +44,9 @@ export class PermissionsDialogComponent {
|
|||||||
return this.o
|
return this.o
|
||||||
}
|
}
|
||||||
|
|
||||||
form = new FormGroup({
|
public form = new FormGroup({
|
||||||
permissions_form: new FormControl(),
|
permissions_form: new FormControl(),
|
||||||
|
merge: new FormControl(true),
|
||||||
})
|
})
|
||||||
|
|
||||||
buttonsEnabled: boolean = true
|
buttonsEnabled: boolean = true
|
||||||
@ -66,11 +68,21 @@ export class PermissionsDialogComponent {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Input()
|
get hint(): string {
|
||||||
message =
|
if (this.object) return null
|
||||||
$localize`Note that permissions set here will override any existing permissions`
|
return this.form.get('merge').value
|
||||||
|
? $localize`Existing owner, user and group permissions will be merged with these settings.`
|
||||||
|
: $localize`Any and all existing owner, user and group permissions will be replaced.`
|
||||||
|
}
|
||||||
|
|
||||||
cancelClicked() {
|
cancelClicked() {
|
||||||
this.activeModal.close()
|
this.activeModal.close()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
confirm() {
|
||||||
|
this.confirmClicked.emit({
|
||||||
|
permissions: this.permissions,
|
||||||
|
merge: this.form.get('merge').value,
|
||||||
|
})
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
@ -62,22 +62,24 @@
|
|||||||
<i-bs width="1em" height="1em" name="check"></i-bs>
|
<i-bs width="1em" height="1em" name="check"></i-bs>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
<div class="me-1 w-100">
|
@if (permissionsService.currentUserCan(PermissionAction.View, PermissionType.User)) {
|
||||||
<ng-select
|
<div class="me-1 w-100">
|
||||||
name="user"
|
<ng-select
|
||||||
class="user-select small"
|
name="user"
|
||||||
[(ngModel)]="selectionModel.includeUsers"
|
class="user-select small"
|
||||||
[disabled]="disabled"
|
[(ngModel)]="selectionModel.includeUsers"
|
||||||
[clearable]="false"
|
[disabled]="disabled"
|
||||||
[items]="users"
|
[clearable]="false"
|
||||||
bindLabel="username"
|
[items]="users"
|
||||||
multiple="true"
|
bindLabel="username"
|
||||||
bindValue="id"
|
multiple="true"
|
||||||
placeholder="Users"
|
bindValue="id"
|
||||||
i18n-placeholder
|
placeholder="Users"
|
||||||
(change)="onUserSelect()">
|
i18n-placeholder
|
||||||
</ng-select>
|
(change)="onUserSelect()">
|
||||||
</div>
|
</ng-select>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
</button>
|
</button>
|
||||||
@if (selectionModel.ownerFilter === OwnerFilterType.NONE || selectionModel.ownerFilter === OwnerFilterType.NOT_SELF) {
|
@if (selectionModel.ownerFilter === OwnerFilterType.NONE || selectionModel.ownerFilter === OwnerFilterType.NOT_SELF) {
|
||||||
<div class="list-group-item list-group-item-action d-flex align-items-center p-2 ps-3 border-bottom-0 border-start-0 border-end-0">
|
<div class="list-group-item list-group-item-action d-flex align-items-center p-2 ps-3 border-bottom-0 border-start-0 border-end-0">
|
||||||
|
@ -67,7 +67,7 @@ export class PermissionsFilterDropdownComponent extends ComponentWithPermissions
|
|||||||
}
|
}
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
permissionsService: PermissionsService,
|
public permissionsService: PermissionsService,
|
||||||
userService: UserService,
|
userService: UserService,
|
||||||
private settingsService: SettingsService
|
private settingsService: SettingsService
|
||||||
) {
|
) {
|
||||||
|
@ -15,8 +15,14 @@
|
|||||||
<tr>
|
<tr>
|
||||||
<th scope="col" i18n>Created</th>
|
<th scope="col" i18n>Created</th>
|
||||||
<th scope="col" i18n>Title</th>
|
<th scope="col" i18n>Title</th>
|
||||||
<th scope="col" class="d-none d-md-table-cell" i18n>Tags</th>
|
@if (permissionsService.currentUserCan(PermissionAction.View, PermissionType.Tag)) {
|
||||||
<th scope="col" class="d-none d-md-table-cell" i18n>Correspondent</th>
|
<th scope="col" class="d-none d-md-table-cell" i18n>Tags</th>
|
||||||
|
}
|
||||||
|
@if (permissionsService.currentUserCan(PermissionAction.View, PermissionType.Correspondent)) {
|
||||||
|
<th scope="col" class="d-none d-md-table-cell" i18n>Correspondent</th>
|
||||||
|
} @else {
|
||||||
|
<th scope="col" class="d-none d-md-table-cell"></th>
|
||||||
|
}
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
@ -26,13 +32,15 @@
|
|||||||
<td class="py-2 py-md-3">
|
<td class="py-2 py-md-3">
|
||||||
<a routerLink="/documents/{{doc.id}}" title="Edit" i18n-title class="btn-link text-dark text-decoration-none py-2 py-md-3">{{doc.title | documentTitle}}</a>
|
<a routerLink="/documents/{{doc.id}}" title="Edit" i18n-title class="btn-link text-dark text-decoration-none py-2 py-md-3">{{doc.title | documentTitle}}</a>
|
||||||
</td>
|
</td>
|
||||||
<td class="py-2 py-md-3 d-none d-md-table-cell">
|
@if (permissionsService.currentUserCan(PermissionAction.View, PermissionType.Tag)) {
|
||||||
@for (t of doc.tags$ | async; track t) {
|
<td class="py-2 py-md-3 d-none d-md-table-cell">
|
||||||
<pngx-tag [tag]="t" class="ms-1" (click)="clickTag(t, $event)"></pngx-tag>
|
@for (t of doc.tags$ | async; track t) {
|
||||||
}
|
<pngx-tag [tag]="t" class="ms-1" (click)="clickTag(t, $event)"></pngx-tag>
|
||||||
</td>
|
}
|
||||||
|
</td>
|
||||||
|
}
|
||||||
<td class="position-relative py-2 py-md-3 d-none d-md-table-cell">
|
<td class="position-relative py-2 py-md-3 d-none d-md-table-cell">
|
||||||
@if (doc.correspondent !== null) {
|
@if (permissionsService.currentUserCan(PermissionAction.View, PermissionType.Correspondent) && doc.correspondent !== null) {
|
||||||
<a class="btn-link text-dark text-decoration-none py-2 py-md-3" routerLink="/documents" [queryParams]="getCorrespondentQueryParams(doc.correspondent)">{{(doc.correspondent$ | async)?.name}}</a>
|
<a class="btn-link text-dark text-decoration-none py-2 py-md-3" routerLink="/documents" [queryParams]="getCorrespondentQueryParams(doc.correspondent)">{{(doc.correspondent$ | async)?.name}}</a>
|
||||||
}
|
}
|
||||||
<div class="btn-group position-absolute top-50 end-0 translate-middle-y">
|
<div class="btn-group position-absolute top-50 end-0 translate-middle-y">
|
||||||
|
@ -22,6 +22,7 @@ import { DocumentListViewService } from 'src/app/services/document-list-view.ser
|
|||||||
import { ComponentWithPermissions } from 'src/app/components/with-permissions/with-permissions.component'
|
import { ComponentWithPermissions } from 'src/app/components/with-permissions/with-permissions.component'
|
||||||
import { NgbPopover } from '@ng-bootstrap/ng-bootstrap'
|
import { NgbPopover } from '@ng-bootstrap/ng-bootstrap'
|
||||||
import { queryParamsFromFilterRules } from 'src/app/utils/query-params'
|
import { queryParamsFromFilterRules } from 'src/app/utils/query-params'
|
||||||
|
import { PermissionsService } from 'src/app/services/permissions.service'
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'pngx-saved-view-widget',
|
selector: 'pngx-saved-view-widget',
|
||||||
@ -40,7 +41,8 @@ export class SavedViewWidgetComponent
|
|||||||
private list: DocumentListViewService,
|
private list: DocumentListViewService,
|
||||||
private consumerStatusService: ConsumerStatusService,
|
private consumerStatusService: ConsumerStatusService,
|
||||||
public openDocumentsService: OpenDocumentsService,
|
public openDocumentsService: OpenDocumentsService,
|
||||||
public documentListViewService: DocumentListViewService
|
public documentListViewService: DocumentListViewService,
|
||||||
|
public permissionsService: PermissionsService
|
||||||
) {
|
) {
|
||||||
super()
|
super()
|
||||||
}
|
}
|
||||||
|
@ -1,5 +1,8 @@
|
|||||||
import { DatePipe } from '@angular/common'
|
import { DatePipe } from '@angular/common'
|
||||||
import { HttpClientTestingModule } from '@angular/common/http/testing'
|
import {
|
||||||
|
HttpClientTestingModule,
|
||||||
|
HttpTestingController,
|
||||||
|
} from '@angular/common/http/testing'
|
||||||
import {
|
import {
|
||||||
ComponentFixture,
|
ComponentFixture,
|
||||||
TestBed,
|
TestBed,
|
||||||
@ -71,6 +74,7 @@ import { CustomFieldDataType } from 'src/app/data/custom-field'
|
|||||||
import { CustomFieldsService } from 'src/app/services/rest/custom-fields.service'
|
import { CustomFieldsService } from 'src/app/services/rest/custom-fields.service'
|
||||||
import { PdfViewerComponent } from '../common/pdf-viewer/pdf-viewer.component'
|
import { PdfViewerComponent } from '../common/pdf-viewer/pdf-viewer.component'
|
||||||
import { NgxBootstrapIconsModule, allIcons } from 'ngx-bootstrap-icons'
|
import { NgxBootstrapIconsModule, allIcons } from 'ngx-bootstrap-icons'
|
||||||
|
import { environment } from 'src/environments/environment'
|
||||||
|
|
||||||
const doc: Document = {
|
const doc: Document = {
|
||||||
id: 3,
|
id: 3,
|
||||||
@ -136,6 +140,7 @@ describe('DocumentDetailComponent', () => {
|
|||||||
let documentListViewService: DocumentListViewService
|
let documentListViewService: DocumentListViewService
|
||||||
let settingsService: SettingsService
|
let settingsService: SettingsService
|
||||||
let customFieldsService: CustomFieldsService
|
let customFieldsService: CustomFieldsService
|
||||||
|
let httpTestingController: HttpTestingController
|
||||||
|
|
||||||
let currentUserCan = true
|
let currentUserCan = true
|
||||||
let currentUserHasObjectPermissions = true
|
let currentUserHasObjectPermissions = true
|
||||||
@ -266,6 +271,7 @@ describe('DocumentDetailComponent', () => {
|
|||||||
settingsService.currentUser = { id: 1 }
|
settingsService.currentUser = { id: 1 }
|
||||||
customFieldsService = TestBed.inject(CustomFieldsService)
|
customFieldsService = TestBed.inject(CustomFieldsService)
|
||||||
fixture = TestBed.createComponent(DocumentDetailComponent)
|
fixture = TestBed.createComponent(DocumentDetailComponent)
|
||||||
|
httpTestingController = TestBed.inject(HttpTestingController)
|
||||||
component = fixture.componentInstance
|
component = fixture.componentInstance
|
||||||
})
|
})
|
||||||
|
|
||||||
@ -350,6 +356,26 @@ describe('DocumentDetailComponent', () => {
|
|||||||
expect(component.documentForm.disabled).toBeTruthy()
|
expect(component.documentForm.disabled).toBeTruthy()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('should not attempt to retrieve objects if user does not have permissions', () => {
|
||||||
|
currentUserCan = false
|
||||||
|
initNormally()
|
||||||
|
expect(component.correspondents).toBeUndefined()
|
||||||
|
expect(component.documentTypes).toBeUndefined()
|
||||||
|
expect(component.storagePaths).toBeUndefined()
|
||||||
|
expect(component.users).toBeUndefined()
|
||||||
|
httpTestingController.expectNone(`${environment.apiBaseUrl}documents/tags/`)
|
||||||
|
httpTestingController.expectNone(
|
||||||
|
`${environment.apiBaseUrl}documents/correspondents/`
|
||||||
|
)
|
||||||
|
httpTestingController.expectNone(
|
||||||
|
`${environment.apiBaseUrl}documents/document_types/`
|
||||||
|
)
|
||||||
|
httpTestingController.expectNone(
|
||||||
|
`${environment.apiBaseUrl}documents/storage_paths/`
|
||||||
|
)
|
||||||
|
currentUserCan = true
|
||||||
|
})
|
||||||
|
|
||||||
it('should support creating document type', () => {
|
it('should support creating document type', () => {
|
||||||
initNormally()
|
initNormally()
|
||||||
let openModal: NgbModalRef
|
let openModal: NgbModalRef
|
||||||
|
@ -250,25 +250,50 @@ export class DocumentDetailComponent
|
|||||||
Object.assign(this.document, docValues)
|
Object.assign(this.document, docValues)
|
||||||
})
|
})
|
||||||
|
|
||||||
this.correspondentService
|
if (
|
||||||
.listAll()
|
this.permissionsService.currentUserCan(
|
||||||
.pipe(first(), takeUntil(this.unsubscribeNotifier))
|
PermissionAction.View,
|
||||||
.subscribe((result) => (this.correspondents = result.results))
|
PermissionType.Correspondent
|
||||||
|
)
|
||||||
this.documentTypeService
|
) {
|
||||||
.listAll()
|
this.correspondentService
|
||||||
.pipe(first(), takeUntil(this.unsubscribeNotifier))
|
.listAll()
|
||||||
.subscribe((result) => (this.documentTypes = result.results))
|
.pipe(first(), takeUntil(this.unsubscribeNotifier))
|
||||||
|
.subscribe((result) => (this.correspondents = result.results))
|
||||||
this.storagePathService
|
}
|
||||||
.listAll()
|
if (
|
||||||
.pipe(first(), takeUntil(this.unsubscribeNotifier))
|
this.permissionsService.currentUserCan(
|
||||||
.subscribe((result) => (this.storagePaths = result.results))
|
PermissionAction.View,
|
||||||
|
PermissionType.DocumentType
|
||||||
this.userService
|
)
|
||||||
.listAll()
|
) {
|
||||||
.pipe(first(), takeUntil(this.unsubscribeNotifier))
|
this.documentTypeService
|
||||||
.subscribe((result) => (this.users = result.results))
|
.listAll()
|
||||||
|
.pipe(first(), takeUntil(this.unsubscribeNotifier))
|
||||||
|
.subscribe((result) => (this.documentTypes = result.results))
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
this.permissionsService.currentUserCan(
|
||||||
|
PermissionAction.View,
|
||||||
|
PermissionType.StoragePath
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
this.storagePathService
|
||||||
|
.listAll()
|
||||||
|
.pipe(first(), takeUntil(this.unsubscribeNotifier))
|
||||||
|
.subscribe((result) => (this.storagePaths = result.results))
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
this.permissionsService.currentUserCan(
|
||||||
|
PermissionAction.View,
|
||||||
|
PermissionType.User
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
this.userService
|
||||||
|
.listAll()
|
||||||
|
.pipe(first(), takeUntil(this.unsubscribeNotifier))
|
||||||
|
.subscribe((result) => (this.users = result.results))
|
||||||
|
}
|
||||||
|
|
||||||
this.getCustomFields()
|
this.getCustomFields()
|
||||||
|
|
||||||
@ -605,7 +630,9 @@ export class DocumentDetailComponent
|
|||||||
.update(this.document)
|
.update(this.document)
|
||||||
.pipe(first())
|
.pipe(first())
|
||||||
.subscribe({
|
.subscribe({
|
||||||
next: () => {
|
next: (docValues) => {
|
||||||
|
// in case data changed while saving eg removing inbox_tags
|
||||||
|
this.documentForm.patchValue(docValues)
|
||||||
this.store.next(this.documentForm.value)
|
this.store.next(this.documentForm.value)
|
||||||
this.toastService.showInfo($localize`Document saved successfully.`)
|
this.toastService.showInfo($localize`Document saved successfully.`)
|
||||||
close && this.close()
|
close && this.close()
|
||||||
|
@ -17,51 +17,59 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="d-flex align-items-center gap-2" *pngxIfPermissions="{ action: PermissionAction.Change, type: PermissionType.Document }">
|
<div class="d-flex align-items-center gap-2" *pngxIfPermissions="{ action: PermissionAction.Change, type: PermissionType.Document }">
|
||||||
<label class="me-2" i18n>Edit:</label>
|
<label class="me-2" i18n>Edit:</label>
|
||||||
<pngx-filterable-dropdown title="Tags" icon="tag-fill" i18n-title
|
@if (permissionService.currentUserCan(PermissionAction.View, PermissionType.Tag)) {
|
||||||
filterPlaceholder="Filter tags" i18n-filterPlaceholder
|
<pngx-filterable-dropdown title="Tags" icon="tag-fill" i18n-title
|
||||||
[items]="tags"
|
filterPlaceholder="Filter tags" i18n-filterPlaceholder
|
||||||
[disabled]="!userCanEditAll"
|
[items]="tags"
|
||||||
[editing]="true"
|
[disabled]="!userCanEditAll"
|
||||||
[manyToOne]="true"
|
[editing]="true"
|
||||||
[applyOnClose]="applyOnClose"
|
[manyToOne]="true"
|
||||||
(opened)="openTagsDropdown()"
|
[applyOnClose]="applyOnClose"
|
||||||
[(selectionModel)]="tagSelectionModel"
|
(opened)="openTagsDropdown()"
|
||||||
[documentCounts]="tagDocumentCounts"
|
[(selectionModel)]="tagSelectionModel"
|
||||||
(apply)="setTags($event)">
|
[documentCounts]="tagDocumentCounts"
|
||||||
</pngx-filterable-dropdown>
|
(apply)="setTags($event)">
|
||||||
<pngx-filterable-dropdown title="Correspondent" icon="person-fill" i18n-title
|
</pngx-filterable-dropdown>
|
||||||
filterPlaceholder="Filter correspondents" i18n-filterPlaceholder
|
}
|
||||||
[items]="correspondents"
|
@if (permissionService.currentUserCan(PermissionAction.View, PermissionType.Correspondent)) {
|
||||||
[disabled]="!userCanEditAll"
|
<pngx-filterable-dropdown title="Correspondent" icon="person-fill" i18n-title
|
||||||
[editing]="true"
|
filterPlaceholder="Filter correspondents" i18n-filterPlaceholder
|
||||||
[applyOnClose]="applyOnClose"
|
[items]="correspondents"
|
||||||
(opened)="openCorrespondentDropdown()"
|
[disabled]="!userCanEditAll"
|
||||||
[(selectionModel)]="correspondentSelectionModel"
|
[editing]="true"
|
||||||
[documentCounts]="correspondentDocumentCounts"
|
[applyOnClose]="applyOnClose"
|
||||||
(apply)="setCorrespondents($event)">
|
(opened)="openCorrespondentDropdown()"
|
||||||
</pngx-filterable-dropdown>
|
[(selectionModel)]="correspondentSelectionModel"
|
||||||
<pngx-filterable-dropdown title="Document type" icon="file-earmark-fill" i18n-title
|
[documentCounts]="correspondentDocumentCounts"
|
||||||
filterPlaceholder="Filter document types" i18n-filterPlaceholder
|
(apply)="setCorrespondents($event)">
|
||||||
[items]="documentTypes"
|
</pngx-filterable-dropdown>
|
||||||
[disabled]="!userCanEditAll"
|
}
|
||||||
[editing]="true"
|
@if (permissionService.currentUserCan(PermissionAction.View, PermissionType.DocumentType)) {
|
||||||
[applyOnClose]="applyOnClose"
|
<pngx-filterable-dropdown title="Document type" icon="file-earmark-fill" i18n-title
|
||||||
(opened)="openDocumentTypeDropdown()"
|
filterPlaceholder="Filter document types" i18n-filterPlaceholder
|
||||||
[(selectionModel)]="documentTypeSelectionModel"
|
[items]="documentTypes"
|
||||||
[documentCounts]="documentTypeDocumentCounts"
|
[disabled]="!userCanEditAll"
|
||||||
(apply)="setDocumentTypes($event)">
|
[editing]="true"
|
||||||
</pngx-filterable-dropdown>
|
[applyOnClose]="applyOnClose"
|
||||||
<pngx-filterable-dropdown title="Storage path" icon="folder-fill" i18n-title
|
(opened)="openDocumentTypeDropdown()"
|
||||||
filterPlaceholder="Filter storage paths" i18n-filterPlaceholder
|
[(selectionModel)]="documentTypeSelectionModel"
|
||||||
[items]="storagePaths"
|
[documentCounts]="documentTypeDocumentCounts"
|
||||||
[disabled]="!userCanEditAll"
|
(apply)="setDocumentTypes($event)">
|
||||||
[editing]="true"
|
</pngx-filterable-dropdown>
|
||||||
[applyOnClose]="applyOnClose"
|
}
|
||||||
(opened)="openStoragePathDropdown()"
|
@if (permissionService.currentUserCan(PermissionAction.View, PermissionType.StoragePath)) {
|
||||||
[(selectionModel)]="storagePathsSelectionModel"
|
<pngx-filterable-dropdown title="Storage path" icon="folder-fill" i18n-title
|
||||||
[documentCounts]="storagePathDocumentCounts"
|
filterPlaceholder="Filter storage paths" i18n-filterPlaceholder
|
||||||
(apply)="setStoragePaths($event)">
|
[items]="storagePaths"
|
||||||
</pngx-filterable-dropdown>
|
[disabled]="!userCanEditAll"
|
||||||
|
[editing]="true"
|
||||||
|
[applyOnClose]="applyOnClose"
|
||||||
|
(opened)="openStoragePathDropdown()"
|
||||||
|
[(selectionModel)]="storagePathsSelectionModel"
|
||||||
|
[documentCounts]="storagePathDocumentCounts"
|
||||||
|
(apply)="setStoragePaths($event)">
|
||||||
|
</pngx-filterable-dropdown>
|
||||||
|
}
|
||||||
</div>
|
</div>
|
||||||
<div class="d-flex align-items-center gap-2 ms-auto">
|
<div class="d-flex align-items-center gap-2 ms-auto">
|
||||||
<div class="btn-toolbar">
|
<div class="btn-toolbar">
|
||||||
|
@ -41,6 +41,7 @@ import { PermissionsUserComponent } from '../../common/input/permissions/permiss
|
|||||||
import { NgSelectModule } from '@ng-select/ng-select'
|
import { NgSelectModule } from '@ng-select/ng-select'
|
||||||
import { GroupService } from 'src/app/services/rest/group.service'
|
import { GroupService } from 'src/app/services/rest/group.service'
|
||||||
import { NgxBootstrapIconsModule, allIcons } from 'ngx-bootstrap-icons'
|
import { NgxBootstrapIconsModule, allIcons } from 'ngx-bootstrap-icons'
|
||||||
|
import { SwitchComponent } from '../../common/input/switch/switch.component'
|
||||||
|
|
||||||
const selectionData: SelectionData = {
|
const selectionData: SelectionData = {
|
||||||
selected_tags: [
|
selected_tags: [
|
||||||
@ -81,6 +82,7 @@ describe('BulkEditorComponent', () => {
|
|||||||
SelectComponent,
|
SelectComponent,
|
||||||
PermissionsGroupComponent,
|
PermissionsGroupComponent,
|
||||||
PermissionsUserComponent,
|
PermissionsUserComponent,
|
||||||
|
SwitchComponent,
|
||||||
],
|
],
|
||||||
providers: [
|
providers: [
|
||||||
PermissionsService,
|
PermissionsService,
|
||||||
@ -851,7 +853,18 @@ describe('BulkEditorComponent', () => {
|
|||||||
fixture.detectChanges()
|
fixture.detectChanges()
|
||||||
component.setPermissions()
|
component.setPermissions()
|
||||||
expect(modal).not.toBeUndefined()
|
expect(modal).not.toBeUndefined()
|
||||||
modal.componentInstance.confirmClicked.next()
|
const perms = {
|
||||||
|
permissions: {
|
||||||
|
view_users: [],
|
||||||
|
change_users: [],
|
||||||
|
view_groups: [],
|
||||||
|
change_groups: [],
|
||||||
|
},
|
||||||
|
}
|
||||||
|
modal.componentInstance.confirmClicked.emit({
|
||||||
|
permissions: perms,
|
||||||
|
merge: true,
|
||||||
|
})
|
||||||
let req = httpTestingController.expectOne(
|
let req = httpTestingController.expectOne(
|
||||||
`${environment.apiBaseUrl}documents/bulk_edit/`
|
`${environment.apiBaseUrl}documents/bulk_edit/`
|
||||||
)
|
)
|
||||||
@ -859,7 +872,10 @@ describe('BulkEditorComponent', () => {
|
|||||||
expect(req.request.body).toEqual({
|
expect(req.request.body).toEqual({
|
||||||
documents: [3, 4],
|
documents: [3, 4],
|
||||||
method: 'set_permissions',
|
method: 'set_permissions',
|
||||||
parameters: undefined,
|
parameters: {
|
||||||
|
permissions: perms.permissions,
|
||||||
|
merge: true,
|
||||||
|
},
|
||||||
})
|
})
|
||||||
httpTestingController.match(
|
httpTestingController.match(
|
||||||
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true`
|
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true`
|
||||||
@ -868,4 +884,22 @@ describe('BulkEditorComponent', () => {
|
|||||||
`${environment.apiBaseUrl}documents/?page=1&page_size=100000&fields=id`
|
`${environment.apiBaseUrl}documents/?page=1&page_size=100000&fields=id`
|
||||||
) // listAllFilteredIds
|
) // listAllFilteredIds
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('should not attempt to retrieve objects if user does not have permissions', () => {
|
||||||
|
jest.spyOn(permissionsService, 'currentUserCan').mockReturnValue(true)
|
||||||
|
expect(component.tags).toBeUndefined()
|
||||||
|
expect(component.correspondents).toBeUndefined()
|
||||||
|
expect(component.documentTypes).toBeUndefined()
|
||||||
|
expect(component.storagePaths).toBeUndefined()
|
||||||
|
httpTestingController.expectNone(`${environment.apiBaseUrl}documents/tags/`)
|
||||||
|
httpTestingController.expectNone(
|
||||||
|
`${environment.apiBaseUrl}documents/correspondents/`
|
||||||
|
)
|
||||||
|
httpTestingController.expectNone(
|
||||||
|
`${environment.apiBaseUrl}documents/document_types/`
|
||||||
|
)
|
||||||
|
httpTestingController.expectNone(
|
||||||
|
`${environment.apiBaseUrl}documents/storage_paths/`
|
||||||
|
)
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
@ -115,22 +115,50 @@ export class BulkEditorComponent
|
|||||||
}
|
}
|
||||||
|
|
||||||
ngOnInit() {
|
ngOnInit() {
|
||||||
this.tagService
|
if (
|
||||||
.listAll()
|
this.permissionService.currentUserCan(
|
||||||
.pipe(first())
|
PermissionAction.View,
|
||||||
.subscribe((result) => (this.tags = result.results))
|
PermissionType.Tag
|
||||||
this.correspondentService
|
)
|
||||||
.listAll()
|
) {
|
||||||
.pipe(first())
|
this.tagService
|
||||||
.subscribe((result) => (this.correspondents = result.results))
|
.listAll()
|
||||||
this.documentTypeService
|
.pipe(first())
|
||||||
.listAll()
|
.subscribe((result) => (this.tags = result.results))
|
||||||
.pipe(first())
|
}
|
||||||
.subscribe((result) => (this.documentTypes = result.results))
|
if (
|
||||||
this.storagePathService
|
this.permissionService.currentUserCan(
|
||||||
.listAll()
|
PermissionAction.View,
|
||||||
.pipe(first())
|
PermissionType.Correspondent
|
||||||
.subscribe((result) => (this.storagePaths = result.results))
|
)
|
||||||
|
) {
|
||||||
|
this.correspondentService
|
||||||
|
.listAll()
|
||||||
|
.pipe(first())
|
||||||
|
.subscribe((result) => (this.correspondents = result.results))
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
this.permissionService.currentUserCan(
|
||||||
|
PermissionAction.View,
|
||||||
|
PermissionType.DocumentType
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
this.documentTypeService
|
||||||
|
.listAll()
|
||||||
|
.pipe(first())
|
||||||
|
.subscribe((result) => (this.documentTypes = result.results))
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
this.permissionService.currentUserCan(
|
||||||
|
PermissionAction.View,
|
||||||
|
PermissionType.StoragePath
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
this.storagePathService
|
||||||
|
.listAll()
|
||||||
|
.pipe(first())
|
||||||
|
.subscribe((result) => (this.storagePaths = result.results))
|
||||||
|
}
|
||||||
|
|
||||||
this.downloadForm
|
this.downloadForm
|
||||||
.get('downloadFileTypeArchive')
|
.get('downloadFileTypeArchive')
|
||||||
@ -512,9 +540,14 @@ export class BulkEditorComponent
|
|||||||
let modal = this.modalService.open(PermissionsDialogComponent, {
|
let modal = this.modalService.open(PermissionsDialogComponent, {
|
||||||
backdrop: 'static',
|
backdrop: 'static',
|
||||||
})
|
})
|
||||||
modal.componentInstance.confirmClicked.subscribe((permissions) => {
|
modal.componentInstance.confirmClicked.subscribe(
|
||||||
modal.componentInstance.buttonsEnabled = false
|
({ permissions, merge }) => {
|
||||||
this.executeBulkOperation(modal, 'set_permissions', permissions)
|
modal.componentInstance.buttonsEnabled = false
|
||||||
})
|
this.executeBulkOperation(modal, 'set_permissions', {
|
||||||
|
...permissions,
|
||||||
|
merge,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -79,7 +79,7 @@ export class DocumentCardSmallComponent extends ComponentWithPermissions {
|
|||||||
|
|
||||||
getTagsLimited$() {
|
getTagsLimited$() {
|
||||||
const limit = this.document.notes.length > 0 ? 6 : 7
|
const limit = this.document.notes.length > 0 ? 6 : 7
|
||||||
return this.document.tags$.pipe(
|
return this.document.tags$?.pipe(
|
||||||
map((tags) => {
|
map((tags) => {
|
||||||
if (tags.length > limit) {
|
if (tags.length > limit) {
|
||||||
this.moreTags = tags.length - (limit - 1)
|
this.moreTags = tags.length - (limit - 1)
|
||||||
|
@ -18,7 +18,7 @@
|
|||||||
</select>
|
</select>
|
||||||
}
|
}
|
||||||
@if (_textFilter) {
|
@if (_textFilter) {
|
||||||
<button class="btn btn-link btn-sm px-0 position-absolute top-0 end-0 z-10" (click)="resetTextField()">
|
<button class="btn btn-link btn-sm px-2 position-absolute top-0 end-0 z-10" (click)="resetTextField()">
|
||||||
<i-bs width="1em" height="1em" name="x"></i-bs>
|
<i-bs width="1em" height="1em" name="x"></i-bs>
|
||||||
</button>
|
</button>
|
||||||
}
|
}
|
||||||
@ -29,7 +29,8 @@
|
|||||||
<div class="col-auto">
|
<div class="col-auto">
|
||||||
<div class="d-flex flex-wrap gap-3">
|
<div class="d-flex flex-wrap gap-3">
|
||||||
<div class="d-flex flex-wrap gap-2">
|
<div class="d-flex flex-wrap gap-2">
|
||||||
<pngx-filterable-dropdown class="flex-fill" title="Tags" icon="tag-fill" i18n-title
|
@if (permissionsService.currentUserCan(PermissionAction.View, PermissionType.Tag)) {
|
||||||
|
<pngx-filterable-dropdown class="flex-fill" title="Tags" icon="tag-fill" i18n-title
|
||||||
filterPlaceholder="Filter tags" i18n-filterPlaceholder
|
filterPlaceholder="Filter tags" i18n-filterPlaceholder
|
||||||
[items]="tags"
|
[items]="tags"
|
||||||
[manyToOne]="true"
|
[manyToOne]="true"
|
||||||
@ -37,31 +38,38 @@
|
|||||||
(selectionModelChange)="updateRules()"
|
(selectionModelChange)="updateRules()"
|
||||||
(opened)="onTagsDropdownOpen()"
|
(opened)="onTagsDropdownOpen()"
|
||||||
[documentCounts]="tagDocumentCounts"
|
[documentCounts]="tagDocumentCounts"
|
||||||
[allowSelectNone]="true"></pngx-filterable-dropdown>
|
[allowSelectNone]="true"></pngx-filterable-dropdown>
|
||||||
<pngx-filterable-dropdown class="flex-fill" title="Correspondent" icon="person-fill" i18n-title
|
}
|
||||||
|
@if (permissionsService.currentUserCan(PermissionAction.View, PermissionType.Correspondent)) {
|
||||||
|
<pngx-filterable-dropdown class="flex-fill" title="Correspondent" icon="person-fill" i18n-title
|
||||||
filterPlaceholder="Filter correspondents" i18n-filterPlaceholder
|
filterPlaceholder="Filter correspondents" i18n-filterPlaceholder
|
||||||
[items]="correspondents"
|
[items]="correspondents"
|
||||||
[(selectionModel)]="correspondentSelectionModel"
|
[(selectionModel)]="correspondentSelectionModel"
|
||||||
(selectionModelChange)="updateRules()"
|
(selectionModelChange)="updateRules()"
|
||||||
(opened)="onCorrespondentDropdownOpen()"
|
(opened)="onCorrespondentDropdownOpen()"
|
||||||
[documentCounts]="correspondentDocumentCounts"
|
[documentCounts]="correspondentDocumentCounts"
|
||||||
[allowSelectNone]="true"></pngx-filterable-dropdown>
|
[allowSelectNone]="true"></pngx-filterable-dropdown>
|
||||||
<pngx-filterable-dropdown class="flex-fill" title="Document type" icon="file-earmark-fill" i18n-title
|
}
|
||||||
filterPlaceholder="Filter document types" i18n-filterPlaceholder
|
@if (permissionsService.currentUserCan(PermissionAction.View, PermissionType.DocumentType)) {
|
||||||
[items]="documentTypes"
|
<pngx-filterable-dropdown class="flex-fill" title="Document type" icon="file-earmark-fill" i18n-title
|
||||||
[(selectionModel)]="documentTypeSelectionModel"
|
filterPlaceholder="Filter document types" i18n-filterPlaceholder
|
||||||
(selectionModelChange)="updateRules()"
|
[items]="documentTypes"
|
||||||
(opened)="onDocumentTypeDropdownOpen()"
|
[(selectionModel)]="documentTypeSelectionModel"
|
||||||
[documentCounts]="documentTypeDocumentCounts"
|
(selectionModelChange)="updateRules()"
|
||||||
[allowSelectNone]="true"></pngx-filterable-dropdown>
|
(opened)="onDocumentTypeDropdownOpen()"
|
||||||
<pngx-filterable-dropdown class="flex-fill" title="Storage path" icon="folder-fill" i18n-title
|
[documentCounts]="documentTypeDocumentCounts"
|
||||||
|
[allowSelectNone]="true"></pngx-filterable-dropdown>
|
||||||
|
}
|
||||||
|
@if (permissionsService.currentUserCan(PermissionAction.View, PermissionType.StoragePath)) {
|
||||||
|
<pngx-filterable-dropdown class="flex-fill" title="Storage path" icon="folder-fill" i18n-title
|
||||||
filterPlaceholder="Filter storage paths" i18n-filterPlaceholder
|
filterPlaceholder="Filter storage paths" i18n-filterPlaceholder
|
||||||
[items]="storagePaths"
|
[items]="storagePaths"
|
||||||
[(selectionModel)]="storagePathSelectionModel"
|
[(selectionModel)]="storagePathSelectionModel"
|
||||||
(selectionModelChange)="updateRules()"
|
(selectionModelChange)="updateRules()"
|
||||||
(opened)="onStoragePathDropdownOpen()"
|
(opened)="onStoragePathDropdownOpen()"
|
||||||
[documentCounts]="storagePathDocumentCounts"
|
[documentCounts]="storagePathDocumentCounts"
|
||||||
[allowSelectNone]="true"></pngx-filterable-dropdown>
|
[allowSelectNone]="true"></pngx-filterable-dropdown>
|
||||||
|
}
|
||||||
</div>
|
</div>
|
||||||
<div class="d-flex flex-wrap gap-2">
|
<div class="d-flex flex-wrap gap-2">
|
||||||
<pngx-date-dropdown
|
<pngx-date-dropdown
|
||||||
|
@ -1,5 +1,8 @@
|
|||||||
import { DatePipe } from '@angular/common'
|
import { DatePipe } from '@angular/common'
|
||||||
import { HttpClientTestingModule } from '@angular/common/http/testing'
|
import {
|
||||||
|
HttpClientTestingModule,
|
||||||
|
HttpTestingController,
|
||||||
|
} from '@angular/common/http/testing'
|
||||||
import {
|
import {
|
||||||
ComponentFixture,
|
ComponentFixture,
|
||||||
fakeAsync,
|
fakeAsync,
|
||||||
@ -78,6 +81,11 @@ import {
|
|||||||
} from '../../common/permissions-filter-dropdown/permissions-filter-dropdown.component'
|
} from '../../common/permissions-filter-dropdown/permissions-filter-dropdown.component'
|
||||||
import { FilterEditorComponent } from './filter-editor.component'
|
import { FilterEditorComponent } from './filter-editor.component'
|
||||||
import { NgxBootstrapIconsModule, allIcons } from 'ngx-bootstrap-icons'
|
import { NgxBootstrapIconsModule, allIcons } from 'ngx-bootstrap-icons'
|
||||||
|
import {
|
||||||
|
PermissionType,
|
||||||
|
PermissionsService,
|
||||||
|
} from 'src/app/services/permissions.service'
|
||||||
|
import { environment } from 'src/environments/environment'
|
||||||
|
|
||||||
const tags: Tag[] = [
|
const tags: Tag[] = [
|
||||||
{
|
{
|
||||||
@ -135,6 +143,8 @@ describe('FilterEditorComponent', () => {
|
|||||||
let fixture: ComponentFixture<FilterEditorComponent>
|
let fixture: ComponentFixture<FilterEditorComponent>
|
||||||
let documentService: DocumentService
|
let documentService: DocumentService
|
||||||
let settingsService: SettingsService
|
let settingsService: SettingsService
|
||||||
|
let permissionsService: PermissionsService
|
||||||
|
let httpTestingController: HttpTestingController
|
||||||
|
|
||||||
beforeEach(fakeAsync(() => {
|
beforeEach(fakeAsync(() => {
|
||||||
TestBed.configureTestingModule({
|
TestBed.configureTestingModule({
|
||||||
@ -199,6 +209,15 @@ describe('FilterEditorComponent', () => {
|
|||||||
documentService = TestBed.inject(DocumentService)
|
documentService = TestBed.inject(DocumentService)
|
||||||
settingsService = TestBed.inject(SettingsService)
|
settingsService = TestBed.inject(SettingsService)
|
||||||
settingsService.currentUser = users[0]
|
settingsService.currentUser = users[0]
|
||||||
|
permissionsService = TestBed.inject(PermissionsService)
|
||||||
|
jest
|
||||||
|
.spyOn(permissionsService, 'currentUserCan')
|
||||||
|
.mockImplementation((action, type) => {
|
||||||
|
// a little hack-ish, permissions filter dropdown causes reactive forms issue due to ng-select
|
||||||
|
// trying to apply formControlName
|
||||||
|
return type !== PermissionType.User
|
||||||
|
})
|
||||||
|
httpTestingController = TestBed.inject(HttpTestingController)
|
||||||
fixture = TestBed.createComponent(FilterEditorComponent)
|
fixture = TestBed.createComponent(FilterEditorComponent)
|
||||||
component = fixture.componentInstance
|
component = fixture.componentInstance
|
||||||
component.filterRules = []
|
component.filterRules = []
|
||||||
@ -206,6 +225,24 @@ describe('FilterEditorComponent', () => {
|
|||||||
tick()
|
tick()
|
||||||
}))
|
}))
|
||||||
|
|
||||||
|
it('should not attempt to retrieve objects if user does not have permissions', () => {
|
||||||
|
jest.spyOn(permissionsService, 'currentUserCan').mockReset()
|
||||||
|
jest
|
||||||
|
.spyOn(permissionsService, 'currentUserCan')
|
||||||
|
.mockImplementation((action, type) => false)
|
||||||
|
component.ngOnInit()
|
||||||
|
httpTestingController.expectNone(`${environment.apiBaseUrl}documents/tags/`)
|
||||||
|
httpTestingController.expectNone(
|
||||||
|
`${environment.apiBaseUrl}documents/correspondents/`
|
||||||
|
)
|
||||||
|
httpTestingController.expectNone(
|
||||||
|
`${environment.apiBaseUrl}documents/document_types/`
|
||||||
|
)
|
||||||
|
httpTestingController.expectNone(
|
||||||
|
`${environment.apiBaseUrl}documents/storage_paths/`
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
// SET filterRules
|
// SET filterRules
|
||||||
|
|
||||||
it('should ingest text filter rules for doc title', fakeAsync(() => {
|
it('should ingest text filter rules for doc title', fakeAsync(() => {
|
||||||
|
@ -70,6 +70,12 @@ import {
|
|||||||
OwnerFilterType,
|
OwnerFilterType,
|
||||||
PermissionsSelectionModel,
|
PermissionsSelectionModel,
|
||||||
} from '../../common/permissions-filter-dropdown/permissions-filter-dropdown.component'
|
} from '../../common/permissions-filter-dropdown/permissions-filter-dropdown.component'
|
||||||
|
import {
|
||||||
|
PermissionAction,
|
||||||
|
PermissionType,
|
||||||
|
PermissionsService,
|
||||||
|
} from 'src/app/services/permissions.service'
|
||||||
|
import { ComponentWithPermissions } from '../../with-permissions/with-permissions.component'
|
||||||
|
|
||||||
const TEXT_FILTER_TARGET_TITLE = 'title'
|
const TEXT_FILTER_TARGET_TITLE = 'title'
|
||||||
const TEXT_FILTER_TARGET_TITLE_CONTENT = 'title-content'
|
const TEXT_FILTER_TARGET_TITLE_CONTENT = 'title-content'
|
||||||
@ -155,7 +161,10 @@ const DEFAULT_TEXT_FILTER_MODIFIER_OPTIONS = [
|
|||||||
templateUrl: './filter-editor.component.html',
|
templateUrl: './filter-editor.component.html',
|
||||||
styleUrls: ['./filter-editor.component.scss'],
|
styleUrls: ['./filter-editor.component.scss'],
|
||||||
})
|
})
|
||||||
export class FilterEditorComponent implements OnInit, OnDestroy {
|
export class FilterEditorComponent
|
||||||
|
extends ComponentWithPermissions
|
||||||
|
implements OnInit, OnDestroy
|
||||||
|
{
|
||||||
generateFilterName() {
|
generateFilterName() {
|
||||||
if (this.filterRules.length == 1) {
|
if (this.filterRules.length == 1) {
|
||||||
let rule = this.filterRules[0]
|
let rule = this.filterRules[0]
|
||||||
@ -224,8 +233,11 @@ export class FilterEditorComponent implements OnInit, OnDestroy {
|
|||||||
private tagService: TagService,
|
private tagService: TagService,
|
||||||
private correspondentService: CorrespondentService,
|
private correspondentService: CorrespondentService,
|
||||||
private documentService: DocumentService,
|
private documentService: DocumentService,
|
||||||
private storagePathService: StoragePathService
|
private storagePathService: StoragePathService,
|
||||||
) {}
|
public permissionsService: PermissionsService
|
||||||
|
) {
|
||||||
|
super()
|
||||||
|
}
|
||||||
|
|
||||||
@ViewChild('textFilterInput')
|
@ViewChild('textFilterInput')
|
||||||
textFilterInput: ElementRef
|
textFilterInput: ElementRef
|
||||||
@ -872,18 +884,46 @@ export class FilterEditorComponent implements OnInit, OnDestroy {
|
|||||||
subscription: Subscription
|
subscription: Subscription
|
||||||
|
|
||||||
ngOnInit() {
|
ngOnInit() {
|
||||||
this.tagService
|
if (
|
||||||
.listAll()
|
this.permissionsService.currentUserCan(
|
||||||
.subscribe((result) => (this.tags = result.results))
|
PermissionAction.View,
|
||||||
this.correspondentService
|
PermissionType.Tag
|
||||||
.listAll()
|
)
|
||||||
.subscribe((result) => (this.correspondents = result.results))
|
) {
|
||||||
this.documentTypeService
|
this.tagService
|
||||||
.listAll()
|
.listAll()
|
||||||
.subscribe((result) => (this.documentTypes = result.results))
|
.subscribe((result) => (this.tags = result.results))
|
||||||
this.storagePathService
|
}
|
||||||
.listAll()
|
if (
|
||||||
.subscribe((result) => (this.storagePaths = result.results))
|
this.permissionsService.currentUserCan(
|
||||||
|
PermissionAction.View,
|
||||||
|
PermissionType.Correspondent
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
this.correspondentService
|
||||||
|
.listAll()
|
||||||
|
.subscribe((result) => (this.correspondents = result.results))
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
this.permissionsService.currentUserCan(
|
||||||
|
PermissionAction.View,
|
||||||
|
PermissionType.DocumentType
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
this.documentTypeService
|
||||||
|
.listAll()
|
||||||
|
.subscribe((result) => (this.documentTypes = result.results))
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
this.permissionsService.currentUserCan(
|
||||||
|
PermissionAction.View,
|
||||||
|
PermissionType.StoragePath
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
this.storagePathService
|
||||||
|
.listAll()
|
||||||
|
.subscribe((result) => (this.storagePaths = result.results))
|
||||||
|
}
|
||||||
|
|
||||||
this.textFilterDebounce = new Subject<string>()
|
this.textFilterDebounce = new Subject<string>()
|
||||||
|
|
||||||
|
@ -41,6 +41,7 @@ import { TagsComponent } from '../../common/input/tags/tags.component'
|
|||||||
import { FormsModule, ReactiveFormsModule } from '@angular/forms'
|
import { FormsModule, ReactiveFormsModule } from '@angular/forms'
|
||||||
import { EditDialogMode } from '../../common/edit-dialog/edit-dialog.component'
|
import { EditDialogMode } from '../../common/edit-dialog/edit-dialog.component'
|
||||||
import { NgxBootstrapIconsModule, allIcons } from 'ngx-bootstrap-icons'
|
import { NgxBootstrapIconsModule, allIcons } from 'ngx-bootstrap-icons'
|
||||||
|
import { SwitchComponent } from '../../common/input/switch/switch.component'
|
||||||
|
|
||||||
const mailAccounts = [
|
const mailAccounts = [
|
||||||
{ id: 1, name: 'account1' },
|
{ id: 1, name: 'account1' },
|
||||||
@ -82,6 +83,7 @@ describe('MailComponent', () => {
|
|||||||
PermissionsGroupComponent,
|
PermissionsGroupComponent,
|
||||||
PermissionsDialogComponent,
|
PermissionsDialogComponent,
|
||||||
PermissionsFormComponent,
|
PermissionsFormComponent,
|
||||||
|
SwitchComponent,
|
||||||
],
|
],
|
||||||
providers: [CustomDatePipe, DatePipe, PermissionsGuard],
|
providers: [CustomDatePipe, DatePipe, PermissionsGuard],
|
||||||
imports: [
|
imports: [
|
||||||
@ -267,11 +269,11 @@ describe('MailComponent', () => {
|
|||||||
rulePatchSpy.mockReturnValueOnce(
|
rulePatchSpy.mockReturnValueOnce(
|
||||||
throwError(() => new Error('error saving perms'))
|
throwError(() => new Error('error saving perms'))
|
||||||
)
|
)
|
||||||
dialog.confirmClicked.emit(perms)
|
dialog.confirmClicked.emit({ permissions: perms, merge: true })
|
||||||
expect(rulePatchSpy).toHaveBeenCalled()
|
expect(rulePatchSpy).toHaveBeenCalled()
|
||||||
expect(toastErrorSpy).toHaveBeenCalled()
|
expect(toastErrorSpy).toHaveBeenCalled()
|
||||||
rulePatchSpy.mockReturnValueOnce(of(mailRules[0] as MailRule))
|
rulePatchSpy.mockReturnValueOnce(of(mailRules[0] as MailRule))
|
||||||
dialog.confirmClicked.emit(perms)
|
dialog.confirmClicked.emit({ permissions: perms, merge: true })
|
||||||
expect(toastInfoSpy).toHaveBeenCalledWith('Permissions updated')
|
expect(toastInfoSpy).toHaveBeenCalledWith('Permissions updated')
|
||||||
|
|
||||||
modalService.dismissAll()
|
modalService.dismissAll()
|
||||||
@ -299,8 +301,7 @@ describe('MailComponent', () => {
|
|||||||
expect(modal).not.toBeUndefined()
|
expect(modal).not.toBeUndefined()
|
||||||
let dialog = modal.componentInstance as PermissionsDialogComponent
|
let dialog = modal.componentInstance as PermissionsDialogComponent
|
||||||
expect(dialog.object).toEqual(mailAccounts[0])
|
expect(dialog.object).toEqual(mailAccounts[0])
|
||||||
dialog = modal.componentInstance as PermissionsDialogComponent
|
dialog.confirmClicked.emit({ permissions: perms, merge: true })
|
||||||
dialog.confirmClicked.emit(perms)
|
|
||||||
expect(accountPatchSpy).toHaveBeenCalled()
|
expect(accountPatchSpy).toHaveBeenCalled()
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
@ -200,22 +200,27 @@ export class MailComponent
|
|||||||
const dialog: PermissionsDialogComponent =
|
const dialog: PermissionsDialogComponent =
|
||||||
modal.componentInstance as PermissionsDialogComponent
|
modal.componentInstance as PermissionsDialogComponent
|
||||||
dialog.object = object
|
dialog.object = object
|
||||||
modal.componentInstance.confirmClicked.subscribe((permissions) => {
|
modal.componentInstance.confirmClicked.subscribe(
|
||||||
modal.componentInstance.buttonsEnabled = false
|
({ permissions, merge }) => {
|
||||||
const service: AbstractPaperlessService<MailRule | MailAccount> =
|
modal.componentInstance.buttonsEnabled = false
|
||||||
'account' in object ? this.mailRuleService : this.mailAccountService
|
const service: AbstractPaperlessService<MailRule | MailAccount> =
|
||||||
object.owner = permissions['owner']
|
'account' in object ? this.mailRuleService : this.mailAccountService
|
||||||
object['set_permissions'] = permissions['set_permissions']
|
object.owner = permissions['owner']
|
||||||
service.patch(object).subscribe({
|
object['set_permissions'] = permissions['set_permissions']
|
||||||
next: () => {
|
service.patch(object).subscribe({
|
||||||
this.toastService.showInfo($localize`Permissions updated`)
|
next: () => {
|
||||||
modal.close()
|
this.toastService.showInfo($localize`Permissions updated`)
|
||||||
},
|
modal.close()
|
||||||
error: (e) => {
|
},
|
||||||
this.toastService.showError($localize`Error updating permissions`, e)
|
error: (e) => {
|
||||||
},
|
this.toastService.showError(
|
||||||
})
|
$localize`Error updating permissions`,
|
||||||
})
|
e
|
||||||
|
)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
userCanEdit(obj: ObjectWithPermissions): boolean {
|
userCanEdit(obj: ObjectWithPermissions): boolean {
|
||||||
|
@ -264,13 +264,19 @@ describe('ManagementListComponent', () => {
|
|||||||
throwError(() => new Error('error setting permissions'))
|
throwError(() => new Error('error setting permissions'))
|
||||||
)
|
)
|
||||||
const errorToastSpy = jest.spyOn(toastService, 'showError')
|
const errorToastSpy = jest.spyOn(toastService, 'showError')
|
||||||
modal.componentInstance.confirmClicked.emit()
|
modal.componentInstance.confirmClicked.emit({
|
||||||
|
permissions: {},
|
||||||
|
merge: true,
|
||||||
|
})
|
||||||
expect(bulkEditPermsSpy).toHaveBeenCalled()
|
expect(bulkEditPermsSpy).toHaveBeenCalled()
|
||||||
expect(errorToastSpy).toHaveBeenCalled()
|
expect(errorToastSpy).toHaveBeenCalled()
|
||||||
|
|
||||||
const successToastSpy = jest.spyOn(toastService, 'showInfo')
|
const successToastSpy = jest.spyOn(toastService, 'showInfo')
|
||||||
bulkEditPermsSpy.mockReturnValueOnce(of('OK'))
|
bulkEditPermsSpy.mockReturnValueOnce(of('OK'))
|
||||||
modal.componentInstance.confirmClicked.emit()
|
modal.componentInstance.confirmClicked.emit({
|
||||||
|
permissions: {},
|
||||||
|
merge: true,
|
||||||
|
})
|
||||||
expect(bulkEditPermsSpy).toHaveBeenCalled()
|
expect(bulkEditPermsSpy).toHaveBeenCalled()
|
||||||
expect(successToastSpy).toHaveBeenCalled()
|
expect(successToastSpy).toHaveBeenCalled()
|
||||||
})
|
})
|
||||||
|
@ -279,12 +279,13 @@ export abstract class ManagementListComponent<T extends ObjectWithId>
|
|||||||
backdrop: 'static',
|
backdrop: 'static',
|
||||||
})
|
})
|
||||||
modal.componentInstance.confirmClicked.subscribe(
|
modal.componentInstance.confirmClicked.subscribe(
|
||||||
(permissions: { owner: number; set_permissions: PermissionsObject }) => {
|
({ permissions, merge }) => {
|
||||||
modal.componentInstance.buttonsEnabled = false
|
modal.componentInstance.buttonsEnabled = false
|
||||||
this.service
|
this.service
|
||||||
.bulk_update_permissions(
|
.bulk_update_permissions(
|
||||||
Array.from(this.selectedObjects),
|
Array.from(this.selectedObjects),
|
||||||
permissions
|
permissions,
|
||||||
|
merge
|
||||||
)
|
)
|
||||||
.subscribe({
|
.subscribe({
|
||||||
next: () => {
|
next: () => {
|
||||||
|
@ -63,4 +63,7 @@ export interface Document extends ObjectWithPermissions {
|
|||||||
__search_hit__?: SearchHit
|
__search_hit__?: SearchHit
|
||||||
|
|
||||||
custom_fields?: CustomFieldInstance[]
|
custom_fields?: CustomFieldInstance[]
|
||||||
|
|
||||||
|
// write-only field
|
||||||
|
remove_inbox_tags?: boolean
|
||||||
}
|
}
|
||||||
|
@ -53,6 +53,8 @@ export const SETTINGS_KEYS = {
|
|||||||
DEFAULT_PERMS_VIEW_GROUPS: 'general-settings:permissions:default-view-groups',
|
DEFAULT_PERMS_VIEW_GROUPS: 'general-settings:permissions:default-view-groups',
|
||||||
DEFAULT_PERMS_EDIT_USERS: 'general-settings:permissions:default-edit-users',
|
DEFAULT_PERMS_EDIT_USERS: 'general-settings:permissions:default-edit-users',
|
||||||
DEFAULT_PERMS_EDIT_GROUPS: 'general-settings:permissions:default-edit-groups',
|
DEFAULT_PERMS_EDIT_GROUPS: 'general-settings:permissions:default-edit-groups',
|
||||||
|
DOCUMENT_EDITING_REMOVE_INBOX_TAGS:
|
||||||
|
'general-settings:document-editing:remove-inbox-tags',
|
||||||
}
|
}
|
||||||
|
|
||||||
export const SETTINGS: UiSetting[] = [
|
export const SETTINGS: UiSetting[] = [
|
||||||
@ -206,4 +208,9 @@ export const SETTINGS: UiSetting[] = [
|
|||||||
type: 'string',
|
type: 'string',
|
||||||
default: '',
|
default: '',
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
key: SETTINGS_KEYS.DOCUMENT_EDITING_REMOVE_INBOX_TAGS,
|
||||||
|
type: 'boolean',
|
||||||
|
default: false,
|
||||||
|
},
|
||||||
]
|
]
|
||||||
|
@ -53,10 +53,14 @@ export const commonAbstractNameFilterPaperlessServiceTests = (
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
subscription = service
|
subscription = service
|
||||||
.bulk_update_permissions([1, 2], {
|
.bulk_update_permissions(
|
||||||
owner,
|
[1, 2],
|
||||||
set_permissions: permissions,
|
{
|
||||||
})
|
owner,
|
||||||
|
set_permissions: permissions,
|
||||||
|
},
|
||||||
|
true
|
||||||
|
)
|
||||||
.subscribe()
|
.subscribe()
|
||||||
const req = httpTestingController.expectOne(
|
const req = httpTestingController.expectOne(
|
||||||
`${environment.apiBaseUrl}bulk_edit_object_perms/`
|
`${environment.apiBaseUrl}bulk_edit_object_perms/`
|
||||||
|
@ -26,13 +26,15 @@ export abstract class AbstractNameFilterService<
|
|||||||
|
|
||||||
bulk_update_permissions(
|
bulk_update_permissions(
|
||||||
objects: Array<number>,
|
objects: Array<number>,
|
||||||
permissions: { owner: number; set_permissions: PermissionsObject }
|
permissions: { owner: number; set_permissions: PermissionsObject },
|
||||||
|
merge: boolean
|
||||||
): Observable<string> {
|
): Observable<string> {
|
||||||
return this.http.post<string>(`${this.baseUrl}bulk_edit_object_perms/`, {
|
return this.http.post<string>(`${this.baseUrl}bulk_edit_object_perms/`, {
|
||||||
objects,
|
objects,
|
||||||
object_type: this.resourceName,
|
object_type: this.resourceName,
|
||||||
owner: permissions.owner,
|
owner: permissions.owner,
|
||||||
permissions: permissions.set_permissions,
|
permissions: permissions.set_permissions,
|
||||||
|
merge,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -7,10 +7,13 @@ import { TestBed } from '@angular/core/testing'
|
|||||||
import { environment } from 'src/environments/environment'
|
import { environment } from 'src/environments/environment'
|
||||||
import { DocumentService } from './document.service'
|
import { DocumentService } from './document.service'
|
||||||
import { FILTER_TITLE } from 'src/app/data/filter-rule-type'
|
import { FILTER_TITLE } from 'src/app/data/filter-rule-type'
|
||||||
|
import { SettingsService } from '../settings.service'
|
||||||
|
import { SETTINGS_KEYS } from 'src/app/data/ui-settings'
|
||||||
|
|
||||||
let httpTestingController: HttpTestingController
|
let httpTestingController: HttpTestingController
|
||||||
let service: DocumentService
|
let service: DocumentService
|
||||||
let subscription: Subscription
|
let subscription: Subscription
|
||||||
|
let settingsService: SettingsService
|
||||||
const endpoint = 'documents'
|
const endpoint = 'documents'
|
||||||
const documents = [
|
const documents = [
|
||||||
{
|
{
|
||||||
@ -34,6 +37,17 @@ const documents = [
|
|||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
TestBed.configureTestingModule({
|
||||||
|
providers: [DocumentService],
|
||||||
|
imports: [HttpClientTestingModule],
|
||||||
|
})
|
||||||
|
|
||||||
|
httpTestingController = TestBed.inject(HttpTestingController)
|
||||||
|
service = TestBed.inject(DocumentService)
|
||||||
|
settingsService = TestBed.inject(SettingsService)
|
||||||
|
})
|
||||||
|
|
||||||
describe(`DocumentService`, () => {
|
describe(`DocumentService`, () => {
|
||||||
// common tests e.g. commonAbstractPaperlessServiceTests differ slightly
|
// common tests e.g. commonAbstractPaperlessServiceTests differ slightly
|
||||||
it('should call appropriate api endpoint for list all', () => {
|
it('should call appropriate api endpoint for list all', () => {
|
||||||
@ -237,16 +251,21 @@ describe(`DocumentService`, () => {
|
|||||||
)
|
)
|
||||||
expect(req.request.method).toEqual('GET')
|
expect(req.request.method).toEqual('GET')
|
||||||
})
|
})
|
||||||
})
|
|
||||||
|
|
||||||
beforeEach(() => {
|
it('should pass remove_inbox_tags setting to update', () => {
|
||||||
TestBed.configureTestingModule({
|
subscription = service.update(documents[0]).subscribe()
|
||||||
providers: [DocumentService],
|
let req = httpTestingController.expectOne(
|
||||||
imports: [HttpClientTestingModule],
|
`${environment.apiBaseUrl}${endpoint}/${documents[0].id}/`
|
||||||
|
)
|
||||||
|
expect(req.request.body.remove_inbox_tags).toEqual(false)
|
||||||
|
|
||||||
|
settingsService.set(SETTINGS_KEYS.DOCUMENT_EDITING_REMOVE_INBOX_TAGS, true)
|
||||||
|
subscription = service.update(documents[0]).subscribe()
|
||||||
|
req = httpTestingController.expectOne(
|
||||||
|
`${environment.apiBaseUrl}${endpoint}/${documents[0].id}/`
|
||||||
|
)
|
||||||
|
expect(req.request.body.remove_inbox_tags).toEqual(true)
|
||||||
})
|
})
|
||||||
|
|
||||||
httpTestingController = TestBed.inject(HttpTestingController)
|
|
||||||
service = TestBed.inject(DocumentService)
|
|
||||||
})
|
})
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
|
@ -2,7 +2,7 @@ import { Injectable } from '@angular/core'
|
|||||||
import { Document } from 'src/app/data/document'
|
import { Document } from 'src/app/data/document'
|
||||||
import { DocumentMetadata } from 'src/app/data/document-metadata'
|
import { DocumentMetadata } from 'src/app/data/document-metadata'
|
||||||
import { AbstractPaperlessService } from './abstract-paperless-service'
|
import { AbstractPaperlessService } from './abstract-paperless-service'
|
||||||
import { HttpClient, HttpParams } from '@angular/common/http'
|
import { HttpClient } from '@angular/common/http'
|
||||||
import { Observable } from 'rxjs'
|
import { Observable } from 'rxjs'
|
||||||
import { Results } from 'src/app/data/results'
|
import { Results } from 'src/app/data/results'
|
||||||
import { FilterRule } from 'src/app/data/filter-rule'
|
import { FilterRule } from 'src/app/data/filter-rule'
|
||||||
@ -13,6 +13,13 @@ import { TagService } from './tag.service'
|
|||||||
import { DocumentSuggestions } from 'src/app/data/document-suggestions'
|
import { DocumentSuggestions } from 'src/app/data/document-suggestions'
|
||||||
import { queryParamsFromFilterRules } from '../../utils/query-params'
|
import { queryParamsFromFilterRules } from '../../utils/query-params'
|
||||||
import { StoragePathService } from './storage-path.service'
|
import { StoragePathService } from './storage-path.service'
|
||||||
|
import {
|
||||||
|
PermissionAction,
|
||||||
|
PermissionType,
|
||||||
|
PermissionsService,
|
||||||
|
} from '../permissions.service'
|
||||||
|
import { SettingsService } from '../settings.service'
|
||||||
|
import { SETTINGS, SETTINGS_KEYS } from 'src/app/data/ui-settings'
|
||||||
|
|
||||||
export const DOCUMENT_SORT_FIELDS = [
|
export const DOCUMENT_SORT_FIELDS = [
|
||||||
{ field: 'archive_serial_number', name: $localize`ASN` },
|
{ field: 'archive_serial_number', name: $localize`ASN` },
|
||||||
@ -57,21 +64,41 @@ export class DocumentService extends AbstractPaperlessService<Document> {
|
|||||||
private correspondentService: CorrespondentService,
|
private correspondentService: CorrespondentService,
|
||||||
private documentTypeService: DocumentTypeService,
|
private documentTypeService: DocumentTypeService,
|
||||||
private tagService: TagService,
|
private tagService: TagService,
|
||||||
private storagePathService: StoragePathService
|
private storagePathService: StoragePathService,
|
||||||
|
private permissionsService: PermissionsService,
|
||||||
|
private settingsService: SettingsService
|
||||||
) {
|
) {
|
||||||
super(http, 'documents')
|
super(http, 'documents')
|
||||||
}
|
}
|
||||||
|
|
||||||
addObservablesToDocument(doc: Document) {
|
addObservablesToDocument(doc: Document) {
|
||||||
if (doc.correspondent) {
|
if (
|
||||||
|
doc.correspondent &&
|
||||||
|
this.permissionsService.currentUserCan(
|
||||||
|
PermissionAction.View,
|
||||||
|
PermissionType.Correspondent
|
||||||
|
)
|
||||||
|
) {
|
||||||
doc.correspondent$ = this.correspondentService.getCached(
|
doc.correspondent$ = this.correspondentService.getCached(
|
||||||
doc.correspondent
|
doc.correspondent
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
if (doc.document_type) {
|
if (
|
||||||
|
doc.document_type &&
|
||||||
|
this.permissionsService.currentUserCan(
|
||||||
|
PermissionAction.View,
|
||||||
|
PermissionType.DocumentType
|
||||||
|
)
|
||||||
|
) {
|
||||||
doc.document_type$ = this.documentTypeService.getCached(doc.document_type)
|
doc.document_type$ = this.documentTypeService.getCached(doc.document_type)
|
||||||
}
|
}
|
||||||
if (doc.tags) {
|
if (
|
||||||
|
doc.tags &&
|
||||||
|
this.permissionsService.currentUserCan(
|
||||||
|
PermissionAction.View,
|
||||||
|
PermissionType.Tag
|
||||||
|
)
|
||||||
|
) {
|
||||||
doc.tags$ = this.tagService
|
doc.tags$ = this.tagService
|
||||||
.getCachedMany(doc.tags)
|
.getCachedMany(doc.tags)
|
||||||
.pipe(
|
.pipe(
|
||||||
@ -80,7 +107,13 @@ export class DocumentService extends AbstractPaperlessService<Document> {
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
if (doc.storage_path) {
|
if (
|
||||||
|
doc.storage_path &&
|
||||||
|
this.permissionsService.currentUserCan(
|
||||||
|
PermissionAction.View,
|
||||||
|
PermissionType.StoragePath
|
||||||
|
)
|
||||||
|
) {
|
||||||
doc.storage_path$ = this.storagePathService.getCached(doc.storage_path)
|
doc.storage_path$ = this.storagePathService.getCached(doc.storage_path)
|
||||||
}
|
}
|
||||||
return doc
|
return doc
|
||||||
@ -150,6 +183,9 @@ export class DocumentService extends AbstractPaperlessService<Document> {
|
|||||||
update(o: Document): Observable<Document> {
|
update(o: Document): Observable<Document> {
|
||||||
// we want to only set created_date
|
// we want to only set created_date
|
||||||
o.created = undefined
|
o.created = undefined
|
||||||
|
o.remove_inbox_tags = this.settingsService.get(
|
||||||
|
SETTINGS_KEYS.DOCUMENT_EDITING_REMOVE_INBOX_TAGS
|
||||||
|
)
|
||||||
return super.update(o)
|
return super.update(o)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -131,6 +131,12 @@ const LANGUAGE_OPTIONS = [
|
|||||||
englishName: 'Italian',
|
englishName: 'Italian',
|
||||||
dateInputFormat: 'dd/mm/yyyy',
|
dateInputFormat: 'dd/mm/yyyy',
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
code: 'ja-jp',
|
||||||
|
name: $localize`Japanese`,
|
||||||
|
englishName: 'Japanese',
|
||||||
|
dateInputFormat: 'yyyy/mm/dd',
|
||||||
|
},
|
||||||
{
|
{
|
||||||
code: 'lb-lu',
|
code: 'lb-lu',
|
||||||
name: $localize`Luxembourgish`,
|
name: $localize`Luxembourgish`,
|
||||||
|
@ -5,7 +5,7 @@ export const environment = {
|
|||||||
apiBaseUrl: document.baseURI + 'api/',
|
apiBaseUrl: document.baseURI + 'api/',
|
||||||
apiVersion: '4',
|
apiVersion: '4',
|
||||||
appTitle: 'Paperless-ngx',
|
appTitle: 'Paperless-ngx',
|
||||||
version: '2.4.2-dev',
|
version: '2.4.3-dev',
|
||||||
webSocketHost: window.location.host,
|
webSocketHost: window.location.host,
|
||||||
webSocketProtocol: window.location.protocol == 'https:' ? 'wss:' : 'ws:',
|
webSocketProtocol: window.location.protocol == 'https:' ? 'wss:' : 'ws:',
|
||||||
webSocketBaseUrl: base_url.pathname + 'ws/',
|
webSocketBaseUrl: base_url.pathname + 'ws/',
|
||||||
|
@ -589,7 +589,7 @@
|
|||||||
<context context-type="sourcefile">src/app/components/admin/config/config.component.ts</context>
|
<context context-type="sourcefile">src/app/components/admin/config/config.component.ts</context>
|
||||||
<context context-type="linenumber">107</context>
|
<context context-type="linenumber">107</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
<target state="needs-translation">Invalid JSON</target>
|
<target state="translated">JSON غير صالح</target>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="5103146006962696736" datatype="html">
|
<trans-unit id="5103146006962696736" datatype="html">
|
||||||
<source>Configuration updated</source>
|
<source>Configuration updated</source>
|
||||||
@ -657,7 +657,7 @@
|
|||||||
<context context-type="sourcefile">src/app/components/admin/tasks/tasks.component.html</context>
|
<context context-type="sourcefile">src/app/components/admin/tasks/tasks.component.html</context>
|
||||||
<context context-type="linenumber">16</context>
|
<context context-type="linenumber">16</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
<target state="needs-translation">Auto refresh</target>
|
<target state="translated">تحديث تلقائي</target>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="3894950702316166331" datatype="html">
|
<trans-unit id="3894950702316166331" datatype="html">
|
||||||
<source>Loading...</source>
|
<source>Loading...</source>
|
||||||
|
@ -497,7 +497,7 @@
|
|||||||
<context context-type="sourcefile">src/app/components/admin/config/config.component.html</context>
|
<context context-type="sourcefile">src/app/components/admin/config/config.component.html</context>
|
||||||
<context context-type="linenumber">34</context>
|
<context context-type="linenumber">34</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
<target state="needs-translation">Enable</target>
|
<target state="translated">Ενεργοποίηση</target>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="3823219296477075982" datatype="html">
|
<trans-unit id="3823219296477075982" datatype="html">
|
||||||
<source>Discard</source>
|
<source>Discard</source>
|
||||||
@ -589,7 +589,7 @@
|
|||||||
<context context-type="sourcefile">src/app/components/admin/config/config.component.ts</context>
|
<context context-type="sourcefile">src/app/components/admin/config/config.component.ts</context>
|
||||||
<context context-type="linenumber">107</context>
|
<context context-type="linenumber">107</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
<target state="needs-translation">Invalid JSON</target>
|
<target state="translated">Μη έγκυρο JSON</target>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="5103146006962696736" datatype="html">
|
<trans-unit id="5103146006962696736" datatype="html">
|
||||||
<source>Configuration updated</source>
|
<source>Configuration updated</source>
|
||||||
@ -657,7 +657,7 @@
|
|||||||
<context context-type="sourcefile">src/app/components/admin/tasks/tasks.component.html</context>
|
<context context-type="sourcefile">src/app/components/admin/tasks/tasks.component.html</context>
|
||||||
<context context-type="linenumber">16</context>
|
<context context-type="linenumber">16</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
<target state="needs-translation">Auto refresh</target>
|
<target state="translated">Αυτόματη ανανέωση</target>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="3894950702316166331" datatype="html">
|
<trans-unit id="3894950702316166331" datatype="html">
|
||||||
<source>Loading...</source>
|
<source>Loading...</source>
|
||||||
@ -2469,7 +2469,7 @@
|
|||||||
<context context-type="sourcefile">src/app/components/dashboard/widgets/statistics-widget/statistics-widget.component.html</context>
|
<context context-type="sourcefile">src/app/components/dashboard/widgets/statistics-widget/statistics-widget.component.html</context>
|
||||||
<context context-type="linenumber">74</context>
|
<context context-type="linenumber">74</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
<target state="needs-translation">Document Types</target>
|
<target state="translated">Τύποι Εγγράφων</target>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="5421255270838137624" datatype="html">
|
<trans-unit id="5421255270838137624" datatype="html">
|
||||||
<source>Storage Paths</source>
|
<source>Storage Paths</source>
|
||||||
@ -2485,7 +2485,7 @@
|
|||||||
<context context-type="sourcefile">src/app/components/dashboard/widgets/statistics-widget/statistics-widget.component.html</context>
|
<context context-type="sourcefile">src/app/components/dashboard/widgets/statistics-widget/statistics-widget.component.html</context>
|
||||||
<context context-type="linenumber">82</context>
|
<context context-type="linenumber">82</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
<target state="needs-translation">Storage Paths</target>
|
<target state="translated">Διαδρομές Αποθήκευσης</target>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="3188389494264426470" datatype="html">
|
<trans-unit id="3188389494264426470" datatype="html">
|
||||||
<source>Custom Fields</source>
|
<source>Custom Fields</source>
|
||||||
@ -2521,7 +2521,7 @@
|
|||||||
<context context-type="sourcefile">src/app/components/manage/workflows/workflows.component.html</context>
|
<context context-type="sourcefile">src/app/components/manage/workflows/workflows.component.html</context>
|
||||||
<context context-type="linenumber">2</context>
|
<context context-type="linenumber">2</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
<target state="needs-translation">Workflows</target>
|
<target state="translated">Ροές εργασίας</target>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="1292737233370901804" datatype="html">
|
<trans-unit id="1292737233370901804" datatype="html">
|
||||||
<source>Mail</source>
|
<source>Mail</source>
|
||||||
@ -3981,7 +3981,7 @@
|
|||||||
<context context-type="sourcefile">src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.ts</context>
|
<context context-type="sourcefile">src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.ts</context>
|
||||||
<context context-type="linenumber">54</context>
|
<context context-type="linenumber">54</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
<target state="needs-translation">Consumption Started</target>
|
<target state="translated">Η Κατανάλωση Ξεκίνησε</target>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="7858311467093621703" datatype="html">
|
<trans-unit id="7858311467093621703" datatype="html">
|
||||||
<source>Document Added</source>
|
<source>Document Added</source>
|
||||||
@ -4307,7 +4307,7 @@
|
|||||||
<context context-type="sourcefile">src/app/components/common/page-header/page-header.component.html</context>
|
<context context-type="sourcefile">src/app/components/common/page-header/page-header.component.html</context>
|
||||||
<context context-type="linenumber">9</context>
|
<context context-type="linenumber">9</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
<target state="needs-translation">What's this?</target>
|
<target state="translated">Τι είναι αυτό;</target>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="2827984212740060090" datatype="html">
|
<trans-unit id="2827984212740060090" datatype="html">
|
||||||
<source>Read more</source>
|
<source>Read more</source>
|
||||||
@ -4419,7 +4419,7 @@
|
|||||||
<context context-type="sourcefile">src/app/components/common/profile-edit-dialog/profile-edit-dialog.component.html</context>
|
<context context-type="sourcefile">src/app/components/common/profile-edit-dialog/profile-edit-dialog.component.html</context>
|
||||||
<context context-type="linenumber">3</context>
|
<context context-type="linenumber">3</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
<target state="needs-translation">Edit Profile</target>
|
<target state="translated">Επεξεργασία Προφίλ</target>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="8214169742072920158" datatype="html">
|
<trans-unit id="8214169742072920158" datatype="html">
|
||||||
<source>Confirm Email</source>
|
<source>Confirm Email</source>
|
||||||
@ -4427,7 +4427,7 @@
|
|||||||
<context context-type="sourcefile">src/app/components/common/profile-edit-dialog/profile-edit-dialog.component.html</context>
|
<context context-type="sourcefile">src/app/components/common/profile-edit-dialog/profile-edit-dialog.component.html</context>
|
||||||
<context context-type="linenumber">13</context>
|
<context context-type="linenumber">13</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
<target state="needs-translation">Confirm Email</target>
|
<target state="translated">Επιβεβαίωση Email</target>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="3241357959735682038" datatype="html">
|
<trans-unit id="3241357959735682038" datatype="html">
|
||||||
<source>Confirm Password</source>
|
<source>Confirm Password</source>
|
||||||
@ -4435,7 +4435,7 @@
|
|||||||
<context context-type="sourcefile">src/app/components/common/profile-edit-dialog/profile-edit-dialog.component.html</context>
|
<context context-type="sourcefile">src/app/components/common/profile-edit-dialog/profile-edit-dialog.component.html</context>
|
||||||
<context context-type="linenumber">23</context>
|
<context context-type="linenumber">23</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
<target state="needs-translation">Confirm Password</target>
|
<target state="translated">Επιβεβαίωση Κωδικού</target>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="7554924397178347823" datatype="html">
|
<trans-unit id="7554924397178347823" datatype="html">
|
||||||
<source>API Auth Token</source>
|
<source>API Auth Token</source>
|
||||||
@ -4443,7 +4443,7 @@
|
|||||||
<context context-type="sourcefile">src/app/components/common/profile-edit-dialog/profile-edit-dialog.component.html</context>
|
<context context-type="sourcefile">src/app/components/common/profile-edit-dialog/profile-edit-dialog.component.html</context>
|
||||||
<context context-type="linenumber">31</context>
|
<context context-type="linenumber">31</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
<target state="needs-translation">API Auth Token</target>
|
<target state="translated">API Auth Token</target>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="4323470180912194028" datatype="html">
|
<trans-unit id="4323470180912194028" datatype="html">
|
||||||
<source>Copy</source>
|
<source>Copy</source>
|
||||||
@ -4843,7 +4843,7 @@
|
|||||||
<context context-type="sourcefile">src/app/components/dashboard/widgets/saved-view-widget/saved-view-widget.component.html</context>
|
<context context-type="sourcefile">src/app/components/dashboard/widgets/saved-view-widget/saved-view-widget.component.html</context>
|
||||||
<context context-type="linenumber">57</context>
|
<context context-type="linenumber">57</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
<target state="needs-translation">No documents</target>
|
<target state="translated">Δεν υπάρχουν έγγραφα</target>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="1069523139277190436" datatype="html">
|
<trans-unit id="1069523139277190436" datatype="html">
|
||||||
<source>Statistics</source>
|
<source>Statistics</source>
|
||||||
@ -5054,7 +5054,7 @@
|
|||||||
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.html</context>
|
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.html</context>
|
||||||
<context context-type="linenumber">9</context>
|
<context context-type="linenumber">9</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
<target state="needs-translation">-</target>
|
<target state="translated">-</target>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="8479257185772414452" datatype="html">
|
<trans-unit id="8479257185772414452" datatype="html">
|
||||||
<source>+</source>
|
<source>+</source>
|
||||||
@ -5062,7 +5062,7 @@
|
|||||||
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.html</context>
|
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.html</context>
|
||||||
<context context-type="linenumber">17</context>
|
<context context-type="linenumber">17</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
<target state="needs-translation">+</target>
|
<target state="translated">+</target>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="8659635229098859487" datatype="html">
|
<trans-unit id="8659635229098859487" datatype="html">
|
||||||
<source>Download original</source>
|
<source>Download original</source>
|
||||||
@ -5378,7 +5378,7 @@
|
|||||||
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
|
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
|
||||||
<context context-type="linenumber">325</context>
|
<context context-type="linenumber">325</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
<target state="needs-translation">Ok</target>
|
<target state="translated">Οκ</target>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="5758784066858623886" datatype="html">
|
<trans-unit id="5758784066858623886" datatype="html">
|
||||||
<source>Error retrieving metadata</source>
|
<source>Error retrieving metadata</source>
|
||||||
@ -7096,7 +7096,7 @@
|
|||||||
<context context-type="sourcefile">src/app/data/custom-field.ts</context>
|
<context context-type="sourcefile">src/app/data/custom-field.ts</context>
|
||||||
<context context-type="linenumber">17</context>
|
<context context-type="linenumber">17</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
<target state="needs-translation">Boolean</target>
|
<target state="translated">Δυαδικό</target>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="3973931101896534797" datatype="html">
|
<trans-unit id="3973931101896534797" datatype="html">
|
||||||
<source>Date</source>
|
<source>Date</source>
|
||||||
@ -7104,7 +7104,7 @@
|
|||||||
<context context-type="sourcefile">src/app/data/custom-field.ts</context>
|
<context context-type="sourcefile">src/app/data/custom-field.ts</context>
|
||||||
<context context-type="linenumber">21</context>
|
<context context-type="linenumber">21</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
<target state="needs-translation">Date</target>
|
<target state="translated">Ημερομηνία</target>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="362956598863566327" datatype="html">
|
<trans-unit id="362956598863566327" datatype="html">
|
||||||
<source>Integer</source>
|
<source>Integer</source>
|
||||||
@ -7112,7 +7112,7 @@
|
|||||||
<context context-type="sourcefile">src/app/data/custom-field.ts</context>
|
<context context-type="sourcefile">src/app/data/custom-field.ts</context>
|
||||||
<context context-type="linenumber">25</context>
|
<context context-type="linenumber">25</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
<target state="needs-translation">Integer</target>
|
<target state="translated">Ακέραιος</target>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="6370642728789544052" datatype="html">
|
<trans-unit id="6370642728789544052" datatype="html">
|
||||||
<source>Number</source>
|
<source>Number</source>
|
||||||
@ -7120,7 +7120,7 @@
|
|||||||
<context context-type="sourcefile">src/app/data/custom-field.ts</context>
|
<context context-type="sourcefile">src/app/data/custom-field.ts</context>
|
||||||
<context context-type="linenumber">29</context>
|
<context context-type="linenumber">29</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
<target state="needs-translation">Number</target>
|
<target state="translated">Αριθμός</target>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="6430409302408843009" datatype="html">
|
<trans-unit id="6430409302408843009" datatype="html">
|
||||||
<source>Monetary</source>
|
<source>Monetary</source>
|
||||||
@ -7128,7 +7128,7 @@
|
|||||||
<context context-type="sourcefile">src/app/data/custom-field.ts</context>
|
<context context-type="sourcefile">src/app/data/custom-field.ts</context>
|
||||||
<context context-type="linenumber">33</context>
|
<context context-type="linenumber">33</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
<target state="needs-translation">Monetary</target>
|
<target state="translated">Νομισματικό</target>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="6162693758764653365" datatype="html">
|
<trans-unit id="6162693758764653365" datatype="html">
|
||||||
<source>Text</source>
|
<source>Text</source>
|
||||||
@ -7136,7 +7136,7 @@
|
|||||||
<context context-type="sourcefile">src/app/data/custom-field.ts</context>
|
<context context-type="sourcefile">src/app/data/custom-field.ts</context>
|
||||||
<context context-type="linenumber">37</context>
|
<context context-type="linenumber">37</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
<target state="needs-translation">Text</target>
|
<target state="translated">Κείμενο</target>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="8308045076391224954" datatype="html">
|
<trans-unit id="8308045076391224954" datatype="html">
|
||||||
<source>Url</source>
|
<source>Url</source>
|
||||||
|
@ -449,21 +449,21 @@
|
|||||||
</context-group>
|
</context-group>
|
||||||
<target state="final">Enfin, au nom de chaque contributeur à ce projet soutenu par la communauté, merci d'utiliser Paperless-ngx !</target>
|
<target state="final">Enfin, au nom de chaque contributeur à ce projet soutenu par la communauté, merci d'utiliser Paperless-ngx !</target>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="9063918187161876141" datatype="html">
|
<trans-unit id="9063918187161876141" datatype="html" approved="yes">
|
||||||
<source>Application Configuration</source>
|
<source>Application Configuration</source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/admin/config/config.component.html</context>
|
<context context-type="sourcefile">src/app/components/admin/config/config.component.html</context>
|
||||||
<context context-type="linenumber">2</context>
|
<context context-type="linenumber">2</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
<target state="translated">Configuration de l'application</target>
|
<target state="final">Configuration de l'application</target>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="8528041182664173532" datatype="html">
|
<trans-unit id="8528041182664173532" datatype="html" approved="yes">
|
||||||
<source>Global app configuration options which apply to <strong>every</strong> user of this install of Paperless-ngx. Options can also be set using environment variables or the configuration file but the value here will always take precedence.</source>
|
<source>Global app configuration options which apply to <strong>every</strong> user of this install of Paperless-ngx. Options can also be set using environment variables or the configuration file but the value here will always take precedence.</source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/admin/config/config.component.html</context>
|
<context context-type="sourcefile">src/app/components/admin/config/config.component.html</context>
|
||||||
<context context-type="linenumber">4</context>
|
<context context-type="linenumber">4</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
<target state="translated">Les options de configuration de l'application globaux s'appliquent à <strong>tous</strong> les utilisateurs de cette installation de Paperless-ngx. Les options peuvent aussi être définies en utilisant les variables d'environnement ou le fichier de configuration mais la valeur ici sera toujours prioritaire.</target>
|
<target state="final">Les options de configuration de l'application globaux s'appliquent à <strong>tous</strong> les utilisateurs de cette installation de Paperless-ngx. Les options peuvent aussi être définies en utilisant les variables d'environnement ou le fichier de configuration mais la valeur ici sera toujours prioritaire.</target>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="187187500641108332" datatype="html">
|
<trans-unit id="187187500641108332" datatype="html">
|
||||||
<source>
|
<source>
|
||||||
@ -483,13 +483,13 @@
|
|||||||
</context-group>
|
</context-group>
|
||||||
<target state="translated"><x id="INTERPOLATION" equiv-text="ategory}}"/></target>
|
<target state="translated"><x id="INTERPOLATION" equiv-text="ategory}}"/></target>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="7991430199894172363" datatype="html">
|
<trans-unit id="7991430199894172363" datatype="html" approved="yes">
|
||||||
<source>Read the documentation about this setting</source>
|
<source>Read the documentation about this setting</source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/admin/config/config.component.html</context>
|
<context context-type="sourcefile">src/app/components/admin/config/config.component.html</context>
|
||||||
<context context-type="linenumber">25</context>
|
<context context-type="linenumber">25</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
<target state="translated">Lire la documentation à propos de ce paramètre</target>
|
<target state="final">Lire la documentation à propos de ce paramètre</target>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="2180291763949669799" datatype="html">
|
<trans-unit id="2180291763949669799" datatype="html">
|
||||||
<source>Enable</source>
|
<source>Enable</source>
|
||||||
@ -591,13 +591,13 @@
|
|||||||
</context-group>
|
</context-group>
|
||||||
<target state="translated">JSON non valide</target>
|
<target state="translated">JSON non valide</target>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="5103146006962696736" datatype="html">
|
<trans-unit id="5103146006962696736" datatype="html" approved="yes">
|
||||||
<source>Configuration updated</source>
|
<source>Configuration updated</source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/admin/config/config.component.ts</context>
|
<context context-type="sourcefile">src/app/components/admin/config/config.component.ts</context>
|
||||||
<context context-type="linenumber">151</context>
|
<context context-type="linenumber">151</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
<target state="translated">Configuration mise a jour</target>
|
<target state="final">Configuration mise à jour</target>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="1664963291286452273" datatype="html">
|
<trans-unit id="1664963291286452273" datatype="html">
|
||||||
<source>An error occurred updating configuration</source>
|
<source>An error occurred updating configuration</source>
|
||||||
@ -607,21 +607,21 @@
|
|||||||
</context-group>
|
</context-group>
|
||||||
<target state="translated">Une erreur s'est produite lors de la mise à jour de la configuration</target>
|
<target state="translated">Une erreur s'est produite lors de la mise à jour de la configuration</target>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="2653081282186526824" datatype="html">
|
<trans-unit id="2653081282186526824" datatype="html" approved="yes">
|
||||||
<source>File successfully updated</source>
|
<source>File successfully updated</source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/admin/config/config.component.ts</context>
|
<context context-type="sourcefile">src/app/components/admin/config/config.component.ts</context>
|
||||||
<context context-type="linenumber">178</context>
|
<context context-type="linenumber">178</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
<target state="translated">Fichier téléversé avec succès</target>
|
<target state="final">Fichier téléversé avec succès</target>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="5902783625859504265" datatype="html">
|
<trans-unit id="5902783625859504265" datatype="html" approved="yes">
|
||||||
<source>An error occurred uploading file</source>
|
<source>An error occurred uploading file</source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/admin/config/config.component.ts</context>
|
<context context-type="sourcefile">src/app/components/admin/config/config.component.ts</context>
|
||||||
<context context-type="linenumber">183</context>
|
<context context-type="linenumber">183</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
<target state="translated">Une erreur s'est produite lors du téléversement du fichier</target>
|
<target state="final">Une erreur s'est produite lors du téléversement du fichier</target>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="4804785061014590286" datatype="html" approved="yes">
|
<trans-unit id="4804785061014590286" datatype="html" approved="yes">
|
||||||
<source>Logs</source>
|
<source>Logs</source>
|
||||||
@ -639,13 +639,13 @@
|
|||||||
</context-group>
|
</context-group>
|
||||||
<target state="final">Journaux</target>
|
<target state="final">Journaux</target>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="2272120016352772836" datatype="html">
|
<trans-unit id="2272120016352772836" datatype="html" approved="yes">
|
||||||
<source>Review the log files for the application and for email checking.</source>
|
<source>Review the log files for the application and for email checking.</source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/admin/logs/logs.component.html</context>
|
<context context-type="sourcefile">src/app/components/admin/logs/logs.component.html</context>
|
||||||
<context context-type="linenumber">4</context>
|
<context context-type="linenumber">4</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
<target state="translated">Consultez les fichiers journaux pour l'application et pour la vérification des courriels.</target>
|
<target state="final">Consultez les journaux de l'application ainsi que ceux des courriels.</target>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="8838884664569764142" datatype="html" approved="yes">
|
<trans-unit id="8838884664569764142" datatype="html" approved="yes">
|
||||||
<source>Auto refresh</source>
|
<source>Auto refresh</source>
|
||||||
@ -755,13 +755,13 @@
|
|||||||
</context-group>
|
</context-group>
|
||||||
<target state="final">Paramètres</target>
|
<target state="final">Paramètres</target>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="4999473193657330663" datatype="html">
|
<trans-unit id="4999473193657330663" datatype="html" approved="yes">
|
||||||
<source>Options to customize appearance, notifications, saved views and more. Settings apply to the <strong>current user only</strong>.</source>
|
<source>Options to customize appearance, notifications, saved views and more. Settings apply to the <strong>current user only</strong>.</source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/admin/settings/settings.component.html</context>
|
<context context-type="sourcefile">src/app/components/admin/settings/settings.component.html</context>
|
||||||
<context context-type="linenumber">4</context>
|
<context context-type="linenumber">4</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
<target state="translated">Options pour personnaliser l'apparence, les notifrications, les vues sauvegardées et bien plus. Les paramètres s'appliquent à l'<strong>utilisateur actuel uniquement</strong>.</target>
|
<target state="final">Options pour personnaliser l'apparence, les notifications, les vues sauvegardées et bien plus. Les paramètres s'appliquent à l'<strong>utilisateur actuel uniquement</strong>.</target>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="1685061484835793745" datatype="html" approved="yes">
|
<trans-unit id="1685061484835793745" datatype="html" approved="yes">
|
||||||
<source>Start tour</source>
|
<source>Start tour</source>
|
||||||
@ -1825,39 +1825,39 @@
|
|||||||
<context context-type="sourcefile">src/app/components/admin/tasks/tasks.component.html</context>
|
<context context-type="sourcefile">src/app/components/admin/tasks/tasks.component.html</context>
|
||||||
<context context-type="linenumber">111</context>
|
<context context-type="linenumber">111</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
<target state="translated">(<x id="INTERPOLATION" equiv-text="{{selectedTasks.size}}"/> sélectionné(s))</target>
|
<target state="translated"> (<x id="INTERPOLATION" equiv-text="{{selectedTasks.size}}"/> sélectionné(s))</target>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="5639839509673911668" datatype="html">
|
<trans-unit id="5639839509673911668" datatype="html" approved="yes">
|
||||||
<source>Failed<x id="START_BLOCK_IF" equiv-text="@if (tasksService.failedFileTasks.length > 0) {"/><x id="START_TAG_SPAN" ctype="x-span" equiv-text="<span class="badge bg-danger ms-2">"/><x id="INTERPOLATION" equiv-text="{{tasksService.failedFileTasks.length}}"/><x id="CLOSE_TAG_SPAN" ctype="x-span" equiv-text="</span>"/><x id="CLOSE_BLOCK_IF" equiv-text="}"/></source>
|
<source>Failed<x id="START_BLOCK_IF" equiv-text="@if (tasksService.failedFileTasks.length > 0) {"/><x id="START_TAG_SPAN" ctype="x-span" equiv-text="<span class="badge bg-danger ms-2">"/><x id="INTERPOLATION" equiv-text="{{tasksService.failedFileTasks.length}}"/><x id="CLOSE_TAG_SPAN" ctype="x-span" equiv-text="</span>"/><x id="CLOSE_BLOCK_IF" equiv-text="}"/></source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/admin/tasks/tasks.component.html</context>
|
<context context-type="sourcefile">src/app/components/admin/tasks/tasks.component.html</context>
|
||||||
<context context-type="linenumber">123,125</context>
|
<context context-type="linenumber">123,125</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
<target state="translated"><x id="START_BLOCK_IF" equiv-text="@if (tasksService.failedFileTasks.length > 0) {"/><x id="START_TAG_SPAN" ctype="x-span" equiv-text="<span class="badge bg-danger ms-2">"/><x id="INTERPOLATION" equiv-text="{{tasksService.failedFileTasks.length}}"/><x id="CLOSE_TAG_SPAN" ctype="x-span" equiv-text="</span>"/><x id="CLOSE_BLOCK_IF" equiv-text="}"/> Échoué(s)</target>
|
<target state="final"><x id="START_BLOCK_IF" equiv-text="@if (tasksService.failedFileTasks.length > 0) {"/><x id="START_TAG_SPAN" ctype="x-span" equiv-text="<span class="badge bg-danger ms-2">"/><x id="INTERPOLATION" equiv-text="{{tasksService.failedFileTasks.length}}"/><x id="CLOSE_TAG_SPAN" ctype="x-span" equiv-text="</span>"/><x id="CLOSE_BLOCK_IF" equiv-text="}"/> Échoué(s)</target>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="8210778930307085868" datatype="html">
|
<trans-unit id="8210778930307085868" datatype="html" approved="yes">
|
||||||
<source>Complete<x id="START_BLOCK_IF" equiv-text="@if (tasksService.completedFileTasks.length > 0) {"/><x id="START_TAG_SPAN" ctype="x-span" equiv-text="<span class="badge bg-secondary ms-2">"/><x id="INTERPOLATION" equiv-text="{{tasksService.completedFileTasks.length}}"/><x id="CLOSE_TAG_SPAN" ctype="x-span" equiv-text="</span>"/><x id="CLOSE_BLOCK_IF" equiv-text="}"/></source>
|
<source>Complete<x id="START_BLOCK_IF" equiv-text="@if (tasksService.completedFileTasks.length > 0) {"/><x id="START_TAG_SPAN" ctype="x-span" equiv-text="<span class="badge bg-secondary ms-2">"/><x id="INTERPOLATION" equiv-text="{{tasksService.completedFileTasks.length}}"/><x id="CLOSE_TAG_SPAN" ctype="x-span" equiv-text="</span>"/><x id="CLOSE_BLOCK_IF" equiv-text="}"/></source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/admin/tasks/tasks.component.html</context>
|
<context context-type="sourcefile">src/app/components/admin/tasks/tasks.component.html</context>
|
||||||
<context context-type="linenumber">131,133</context>
|
<context context-type="linenumber">131,133</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
<target state="translated"><x id="START_BLOCK_IF" equiv-text="@if (tasksService.completedFileTasks.length > 0) {"/><x id="START_TAG_SPAN" ctype="x-span" equiv-text="<span class="badge bg-secondary ms-2">"/><x id="INTERPOLATION" equiv-text="{{tasksService.completedFileTasks.length}}"/><x id="CLOSE_TAG_SPAN" ctype="x-span" equiv-text="</span>"/><x id="CLOSE_BLOCK_IF" equiv-text="}"/> Terminé(s)</target>
|
<target state="final"><x id="START_BLOCK_IF" equiv-text="@if (tasksService.completedFileTasks.length > 0) {"/><x id="START_TAG_SPAN" ctype="x-span" equiv-text="<span class="badge bg-secondary ms-2">"/><x id="INTERPOLATION" equiv-text="{{tasksService.completedFileTasks.length}}"/><x id="CLOSE_TAG_SPAN" ctype="x-span" equiv-text="</span>"/><x id="CLOSE_BLOCK_IF" equiv-text="}"/> Terminé(s)</target>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="3522801015717851360" datatype="html">
|
<trans-unit id="3522801015717851360" datatype="html" approved="yes">
|
||||||
<source>Started<x id="START_BLOCK_IF" equiv-text="@if (tasksService.startedFileTasks.length > 0) {"/><x id="START_TAG_SPAN" ctype="x-span" equiv-text="<span class="badge bg-secondary ms-2">"/><x id="INTERPOLATION" equiv-text="{{tasksService.startedFileTasks.length}}"/><x id="CLOSE_TAG_SPAN" ctype="x-span" equiv-text="</span>"/><x id="CLOSE_BLOCK_IF" equiv-text="}"/></source>
|
<source>Started<x id="START_BLOCK_IF" equiv-text="@if (tasksService.startedFileTasks.length > 0) {"/><x id="START_TAG_SPAN" ctype="x-span" equiv-text="<span class="badge bg-secondary ms-2">"/><x id="INTERPOLATION" equiv-text="{{tasksService.startedFileTasks.length}}"/><x id="CLOSE_TAG_SPAN" ctype="x-span" equiv-text="</span>"/><x id="CLOSE_BLOCK_IF" equiv-text="}"/></source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/admin/tasks/tasks.component.html</context>
|
<context context-type="sourcefile">src/app/components/admin/tasks/tasks.component.html</context>
|
||||||
<context context-type="linenumber">139,141</context>
|
<context context-type="linenumber">139,141</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
<target state="translated"><x id="START_BLOCK_IF" equiv-text="@if (tasksService.startedFileTasks.length > 0) {"/><x id="START_TAG_SPAN" ctype="x-span" equiv-text="<span class="badge bg-secondary ms-2">"/><x id="INTERPOLATION" equiv-text="{{tasksService.startedFileTasks.length}}"/><x id="CLOSE_TAG_SPAN" ctype="x-span" equiv-text="</span>"/><x id="CLOSE_BLOCK_IF" equiv-text="}"/> Commencé(s)</target>
|
<target state="final"><x id="START_BLOCK_IF" equiv-text="@if (tasksService.startedFileTasks.length > 0) {"/><x id="START_TAG_SPAN" ctype="x-span" equiv-text="<span class="badge bg-secondary ms-2">"/><x id="INTERPOLATION" equiv-text="{{tasksService.startedFileTasks.length}}"/><x id="CLOSE_TAG_SPAN" ctype="x-span" equiv-text="</span>"/><x id="CLOSE_BLOCK_IF" equiv-text="}"/> Commencé(s)</target>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="2341807459308874922" datatype="html">
|
<trans-unit id="2341807459308874922" datatype="html" approved="yes">
|
||||||
<source>Queued<x id="START_BLOCK_IF" equiv-text="@if (tasksService.queuedFileTasks.length > 0) {"/><x id="START_TAG_SPAN" ctype="x-span" equiv-text="<span class="badge bg-secondary ms-2">"/><x id="INTERPOLATION" equiv-text="{{tasksService.queuedFileTasks.length}}"/><x id="CLOSE_TAG_SPAN" ctype="x-span" equiv-text="</span>"/><x id="CLOSE_BLOCK_IF" equiv-text="}"/></source>
|
<source>Queued<x id="START_BLOCK_IF" equiv-text="@if (tasksService.queuedFileTasks.length > 0) {"/><x id="START_TAG_SPAN" ctype="x-span" equiv-text="<span class="badge bg-secondary ms-2">"/><x id="INTERPOLATION" equiv-text="{{tasksService.queuedFileTasks.length}}"/><x id="CLOSE_TAG_SPAN" ctype="x-span" equiv-text="</span>"/><x id="CLOSE_BLOCK_IF" equiv-text="}"/></source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/admin/tasks/tasks.component.html</context>
|
<context context-type="sourcefile">src/app/components/admin/tasks/tasks.component.html</context>
|
||||||
<context context-type="linenumber">147,149</context>
|
<context context-type="linenumber">147,149</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
<target state="translated">En attente <x id="START_BLOCK_IF" equiv-text="@if (tasksService.queuedFileTasks.length > 0) {"/><x id="START_TAG_SPAN" ctype="x-span" equiv-text="<span class="badge bg-secondary ms-2">"/><x id="INTERPOLATION" equiv-text="{{tasksService.queuedFileTasks.length}}"/><x id="CLOSE_TAG_SPAN" ctype="x-span" equiv-text="</span>"/><x id="CLOSE_BLOCK_IF" equiv-text="}"/></target>
|
<target state="final"><x id="START_BLOCK_IF" equiv-text="@if (tasksService.queuedFileTasks.length > 0) {"/><x id="START_TAG_SPAN" ctype="x-span" equiv-text="<span class="badge bg-secondary ms-2">"/><x id="INTERPOLATION" equiv-text="{{tasksService.queuedFileTasks.length}}"/><x id="CLOSE_TAG_SPAN" ctype="x-span" equiv-text="</span>"/><x id="CLOSE_BLOCK_IF" equiv-text="}"/> En attente</target>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="5404910960991552159" datatype="html" approved="yes">
|
<trans-unit id="5404910960991552159" datatype="html" approved="yes">
|
||||||
<source>Dismiss selected</source>
|
<source>Dismiss selected</source>
|
||||||
@ -1939,13 +1939,13 @@
|
|||||||
</context-group>
|
</context-group>
|
||||||
<target state="final">Utilisateurs & Groupes</target>
|
<target state="final">Utilisateurs & Groupes</target>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="4569276013106377105" datatype="html">
|
<trans-unit id="4569276013106377105" datatype="html" approved="yes">
|
||||||
<source>Create, delete and edit users and groups.</source>
|
<source>Create, delete and edit users and groups.</source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/admin/users-groups/users-groups.component.html</context>
|
<context context-type="sourcefile">src/app/components/admin/users-groups/users-groups.component.html</context>
|
||||||
<context context-type="linenumber">4</context>
|
<context context-type="linenumber">4</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
<target state="translated">Créez, supprimez et modifiez les utilisateurs et les groupes.</target>
|
<target state="final">Créez, supprimez et modifiez les utilisateurs et les groupes.</target>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="4555457172864212828" datatype="html" approved="yes">
|
<trans-unit id="4555457172864212828" datatype="html" approved="yes">
|
||||||
<source>Users</source>
|
<source>Users</source>
|
||||||
@ -2275,13 +2275,13 @@
|
|||||||
</context-group>
|
</context-group>
|
||||||
<target state="final">Erreur lors de la supression du groupe.</target>
|
<target state="final">Erreur lors de la supression du groupe.</target>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="7931334600001636863" datatype="html">
|
<trans-unit id="7931334600001636863" datatype="html" approved="yes">
|
||||||
<source>by Paperless-ngx</source>
|
<source>by Paperless-ngx</source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context>
|
<context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context>
|
||||||
<context context-type="linenumber">20</context>
|
<context context-type="linenumber">20</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
<target state="translated">par Paperless-ngx</target>
|
<target state="final">par Paperless-ngx</target>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="7100953725264790651" datatype="html" approved="yes">
|
<trans-unit id="7100953725264790651" datatype="html" approved="yes">
|
||||||
<source>Search documents</source>
|
<source>Search documents</source>
|
||||||
@ -2543,7 +2543,7 @@
|
|||||||
</context-group>
|
</context-group>
|
||||||
<target state="final">Administration</target>
|
<target state="final">Administration</target>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="3008420115644088420" datatype="html">
|
<trans-unit id="3008420115644088420" datatype="html" approved="yes">
|
||||||
<source>Configuration</source>
|
<source>Configuration</source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context>
|
<context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context>
|
||||||
@ -2553,7 +2553,7 @@
|
|||||||
<context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context>
|
<context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context>
|
||||||
<context context-type="linenumber">242</context>
|
<context context-type="linenumber">242</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
<target state="translated">Configuration</target>
|
<target state="final">Configuration</target>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="1534029177398918729" datatype="html" approved="yes">
|
<trans-unit id="1534029177398918729" datatype="html" approved="yes">
|
||||||
<source>GitHub</source>
|
<source>GitHub</source>
|
||||||
@ -4184,13 +4184,13 @@
|
|||||||
</context-group>
|
</context-group>
|
||||||
<target state="translated">Aucun document trouvé</target>
|
<target state="translated">Aucun document trouvé</target>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="6932865105766151309" datatype="html">
|
<trans-unit id="6932865105766151309" datatype="html" approved="yes">
|
||||||
<source>Upload</source>
|
<source>Upload</source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/common/input/file/file.component.html</context>
|
<context context-type="sourcefile">src/app/components/common/input/file/file.component.html</context>
|
||||||
<context context-type="linenumber">15</context>
|
<context context-type="linenumber">15</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
<target state="translated">Téléverser</target>
|
<target state="final">Téléverser</target>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="5554528553553249088" datatype="html">
|
<trans-unit id="5554528553553249088" datatype="html">
|
||||||
<source>Show password</source>
|
<source>Show password</source>
|
||||||
@ -4269,13 +4269,13 @@
|
|||||||
</context-group>
|
</context-group>
|
||||||
<target state="translated">Aucun élément trouvé</target>
|
<target state="translated">Aucun élément trouvé</target>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="6541407358060244620" datatype="html">
|
<trans-unit id="6541407358060244620" datatype="html" approved="yes">
|
||||||
<source>Note: value has not yet been set and will not apply until explicitly changed</source>
|
<source>Note: value has not yet been set and will not apply until explicitly changed</source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/common/input/switch/switch.component.html</context>
|
<context context-type="sourcefile">src/app/components/common/input/switch/switch.component.html</context>
|
||||||
<context context-type="linenumber">39</context>
|
<context context-type="linenumber">39</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
<target state="translated">Note : la valeur n'a pas encore été définie et ne sera pas appliquée jusqu'à ce que ce soit changé explicitement</target>
|
<target state="final">Note : la valeur n'a pas encore été définie et ne sera pas appliquée jusqu'à ce que ce soit changé explicitement</target>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="6560126119609945418" datatype="html" approved="yes">
|
<trans-unit id="6560126119609945418" datatype="html" approved="yes">
|
||||||
<source>Add tag</source>
|
<source>Add tag</source>
|
||||||
@ -4301,21 +4301,21 @@
|
|||||||
</context-group>
|
</context-group>
|
||||||
<target state="final">Ouvrir le lien</target>
|
<target state="final">Ouvrir le lien</target>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="5752465522295465624" datatype="html">
|
<trans-unit id="5752465522295465624" datatype="html" approved="yes">
|
||||||
<source>What's this?</source>
|
<source>What's this?</source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/common/page-header/page-header.component.html</context>
|
<context context-type="sourcefile">src/app/components/common/page-header/page-header.component.html</context>
|
||||||
<context context-type="linenumber">9</context>
|
<context context-type="linenumber">9</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
<target state="translated">Qu'est-ce ?</target>
|
<target state="final">Qu'est-ce ?</target>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="2827984212740060090" datatype="html">
|
<trans-unit id="2827984212740060090" datatype="html" approved="yes">
|
||||||
<source>Read more</source>
|
<source>Read more</source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/common/page-header/page-header.component.html</context>
|
<context context-type="sourcefile">src/app/components/common/page-header/page-header.component.html</context>
|
||||||
<context context-type="linenumber">15</context>
|
<context context-type="linenumber">15</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
<target state="translated">En savoir plus</target>
|
<target state="final">En savoir plus</target>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="7062872617520618723" datatype="html">
|
<trans-unit id="7062872617520618723" datatype="html">
|
||||||
<source>Set permissions</source>
|
<source>Set permissions</source>
|
||||||
@ -4709,21 +4709,21 @@
|
|||||||
</context-group>
|
</context-group>
|
||||||
<target state="final">Copier l'erreur brute</target>
|
<target state="final">Copier l'erreur brute</target>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="6581372518205328477" datatype="html">
|
<trans-unit id="6581372518205328477" datatype="html" approved="yes">
|
||||||
<source>Hello <x id="PH" equiv-text="this.settingsService.displayName"/>, welcome to <x id="PH_1" equiv-text="environment.appTitle"/></source>
|
<source>Hello <x id="PH" equiv-text="this.settingsService.displayName"/>, welcome to <x id="PH_1" equiv-text="environment.appTitle"/></source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/dashboard/dashboard.component.ts</context>
|
<context context-type="sourcefile">src/app/components/dashboard/dashboard.component.ts</context>
|
||||||
<context context-type="linenumber">38</context>
|
<context context-type="linenumber">38</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
<target state="translated">Bonjour <x id="PH" equiv-text="this.settingsService.displayName"/>, bienvenue sur <x id="PH_1" equiv-text="environment.appTitle"/></target>
|
<target state="final">Bonjour <x id="PH" equiv-text="this.settingsService.displayName"/>, bienvenue sur <x id="PH_1" equiv-text="environment.appTitle"/></target>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="2901300640157872718" datatype="html">
|
<trans-unit id="2901300640157872718" datatype="html" approved="yes">
|
||||||
<source>Welcome to <x id="PH" equiv-text="environment.appTitle"/></source>
|
<source>Welcome to <x id="PH" equiv-text="environment.appTitle"/></source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/dashboard/dashboard.component.ts</context>
|
<context context-type="sourcefile">src/app/components/dashboard/dashboard.component.ts</context>
|
||||||
<context context-type="linenumber">40</context>
|
<context context-type="linenumber">40</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
<target state="translated">Bienvenue sur <x id="PH" equiv-text="environment.appTitle"/></target>
|
<target state="final">Bienvenue sur <x id="PH" equiv-text="environment.appTitle"/></target>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="1325877348738783391" datatype="html">
|
<trans-unit id="1325877348738783391" datatype="html">
|
||||||
<source>Dashboard updated</source>
|
<source>Dashboard updated</source>
|
||||||
@ -5308,13 +5308,13 @@
|
|||||||
</context-group>
|
</context-group>
|
||||||
<target state="translated">Prévisualisation</target>
|
<target state="translated">Prévisualisation</target>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="7206723502037428235" datatype="html">
|
<trans-unit id="7206723502037428235" datatype="html" approved="yes">
|
||||||
<source>Notes <x id="START_BLOCK_IF" equiv-text="@if (document?.notes.length) {"/><x id="START_TAG_SPAN" ctype="x-span" equiv-text="<span class="badge text-bg-secondary ms-1">"/><x id="INTERPOLATION" equiv-text="ngth}}"/><x id="CLOSE_TAG_SPAN" ctype="x-span"/><x id="CLOSE_BLOCK_IF" equiv-text="}"/></source>
|
<source>Notes <x id="START_BLOCK_IF" equiv-text="@if (document?.notes.length) {"/><x id="START_TAG_SPAN" ctype="x-span" equiv-text="<span class="badge text-bg-secondary ms-1">"/><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-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.html</context>
|
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.html</context>
|
||||||
<context context-type="linenumber">272,275</context>
|
<context context-type="linenumber">272,275</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
<target state="translated">Notes <x id="START_BLOCK_IF" equiv-text="@if (document?.notes.length) {"/><x id="START_TAG_SPAN" ctype="x-span" equiv-text="<span class="badge text-bg-secondary ms-1">"/><x id="INTERPOLATION" equiv-text="ngth}}"/><x id="CLOSE_TAG_SPAN" ctype="x-span"/><x id="CLOSE_BLOCK_IF" equiv-text="}"/></target>
|
<target state="final">Notes <x id="START_BLOCK_IF" equiv-text="@if (document?.notes.length) {"/><x id="START_TAG_SPAN" ctype="x-span" equiv-text="<span class="badge text-bg-secondary ms-1">"/><x id="INTERPOLATION" equiv-text="ngth}}"/><x id="CLOSE_TAG_SPAN" ctype="x-span"/><x id="CLOSE_BLOCK_IF" equiv-text="}"/></target>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="5129524307369213584" datatype="html" approved="yes">
|
<trans-unit id="5129524307369213584" datatype="html" approved="yes">
|
||||||
<source>Save & next</source>
|
<source>Save & next</source>
|
||||||
@ -5362,7 +5362,7 @@
|
|||||||
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
|
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
|
||||||
<context context-type="linenumber">322</context>
|
<context context-type="linenumber">322</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
<target state="translated">La version de ce docuemnt dans dans la session de votre navigateur semble plus ancien que la version existante.</target>
|
<target state="translated">La version de ce document dans la session de votre navigateur semble plus ancien que la version existante.</target>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="237142428785956348" datatype="html">
|
<trans-unit id="237142428785956348" datatype="html">
|
||||||
<source>Saving the document here may overwrite other changes that were made. To restore the existing version, discard your changes or close the document.</source>
|
<source>Saving the document here may overwrite other changes that were made. To restore the existing version, discard your changes or close the document.</source>
|
||||||
@ -5370,15 +5370,15 @@
|
|||||||
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
|
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
|
||||||
<context context-type="linenumber">323</context>
|
<context context-type="linenumber">323</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
<target state="translated">Sauvegarder le document ici peut écraser les autres modifications qui ont été faites. Pour restaurer la version existante, annulez vos modificatons ou fermez le document.</target>
|
<target state="translated">Sauvegarder le document ici peut écraser les autres modifications qui ont été faites. Pour restaurer la version existante, annulez vos modifications ou fermez le document.</target>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="8720977247725652816" datatype="html">
|
<trans-unit id="8720977247725652816" datatype="html" approved="yes">
|
||||||
<source>Ok</source>
|
<source>Ok</source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
|
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
|
||||||
<context context-type="linenumber">325</context>
|
<context context-type="linenumber">325</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
<target state="translated">OK</target>
|
<target state="final">OK</target>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="5758784066858623886" datatype="html" approved="yes">
|
<trans-unit id="5758784066858623886" datatype="html" approved="yes">
|
||||||
<source>Error retrieving metadata</source>
|
<source>Error retrieving metadata</source>
|
||||||
@ -6482,13 +6482,13 @@
|
|||||||
</context-group>
|
</context-group>
|
||||||
<target state="final">Voulez-vous vraiment supprimer le correspondant "<x id="PH" equiv-text="object.name"/>" ?</target>
|
<target state="final">Voulez-vous vraiment supprimer le correspondant "<x id="PH" equiv-text="object.name"/>" ?</target>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="8384138406252790442" datatype="html">
|
<trans-unit id="8384138406252790442" datatype="html" approved="yes">
|
||||||
<source>Customize the data fields that can be attached to documents.</source>
|
<source>Customize the data fields that can be attached to documents.</source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/manage/custom-fields/custom-fields.component.html</context>
|
<context context-type="sourcefile">src/app/components/manage/custom-fields/custom-fields.component.html</context>
|
||||||
<context context-type="linenumber">4</context>
|
<context context-type="linenumber">4</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
<target state="translated">Personnalisez les champs de données qui peuvent être joints aux documents.</target>
|
<target state="final">Personnalisez les champs de données qui peuvent être joints aux documents.</target>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="8019331026479399960" datatype="html" approved="yes">
|
<trans-unit id="8019331026479399960" datatype="html" approved="yes">
|
||||||
<source>Add Field</source>
|
<source>Add Field</source>
|
||||||
@ -6994,13 +6994,13 @@
|
|||||||
</context-group>
|
</context-group>
|
||||||
<target state="final">Voulez-vous vraiment supprimer l'étiquette "<x id="PH" equiv-text="object.name"/>" ?</target>
|
<target state="final">Voulez-vous vraiment supprimer l'étiquette "<x id="PH" equiv-text="object.name"/>" ?</target>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="1229748338333965418" datatype="html">
|
<trans-unit id="1229748338333965418" datatype="html" approved="yes">
|
||||||
<source>Use workflows to customize the behavior of Paperless-ngx when events 'trigger' a workflow.</source>
|
<source>Use workflows to customize the behavior of Paperless-ngx when events 'trigger' a workflow.</source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/manage/workflows/workflows.component.html</context>
|
<context context-type="sourcefile">src/app/components/manage/workflows/workflows.component.html</context>
|
||||||
<context context-type="linenumber">4</context>
|
<context context-type="linenumber">4</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
<target state="translated">Utilisez les workflows pour personnaliser le comportement de Paperless-ngx lorsque des événements « déclenchent » un workflow.</target>
|
<target state="final">Utilisez les workflows pour personnaliser le comportement de Paperless-ngx lorsque des événements « déclenchent » un workflow.</target>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="2437630016855517844" datatype="html">
|
<trans-unit id="2437630016855517844" datatype="html">
|
||||||
<source>Add Workflow</source>
|
<source>Add Workflow</source>
|
||||||
@ -7250,13 +7250,13 @@
|
|||||||
</context-group>
|
</context-group>
|
||||||
<target state="final">Aucun : désactiver le rapprochement</target>
|
<target state="final">Aucun : désactiver le rapprochement</target>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="432834967329800065" datatype="html">
|
<trans-unit id="432834967329800065" datatype="html" approved="yes">
|
||||||
<source>General Settings</source>
|
<source>General Settings</source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/data/paperless-config.ts</context>
|
<context context-type="sourcefile">src/app/data/paperless-config.ts</context>
|
||||||
<context context-type="linenumber">50</context>
|
<context context-type="linenumber">50</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
<target state="translated">Paramètres généraux</target>
|
<target state="final">Paramètres généraux</target>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="2762851116637676072" datatype="html">
|
<trans-unit id="2762851116637676072" datatype="html">
|
||||||
<source>OCR Settings</source>
|
<source>OCR Settings</source>
|
||||||
@ -7370,21 +7370,21 @@
|
|||||||
</context-group>
|
</context-group>
|
||||||
<target state="translated">Arguments OCR</target>
|
<target state="translated">Arguments OCR</target>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="7106327322456204362" datatype="html">
|
<trans-unit id="7106327322456204362" datatype="html" approved="yes">
|
||||||
<source>Application Logo</source>
|
<source>Application Logo</source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/data/paperless-config.ts</context>
|
<context context-type="sourcefile">src/app/data/paperless-config.ts</context>
|
||||||
<context context-type="linenumber">171</context>
|
<context context-type="linenumber">171</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
<target state="translated">Logo de l'application</target>
|
<target state="final">Logo de l'application</target>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="2684743776608068095" datatype="html">
|
<trans-unit id="2684743776608068095" datatype="html" approved="yes">
|
||||||
<source>Application Title</source>
|
<source>Application Title</source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/data/paperless-config.ts</context>
|
<context context-type="sourcefile">src/app/data/paperless-config.ts</context>
|
||||||
<context context-type="linenumber">178</context>
|
<context context-type="linenumber">178</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
<target state="translated">Titre de l'application</target>
|
<target state="final">Nom de l'application</target>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="5948496158474272829" datatype="html" approved="yes">
|
<trans-unit id="5948496158474272829" datatype="html" approved="yes">
|
||||||
<source>Warning: You have unsaved changes to your document(s).</source>
|
<source>Warning: You have unsaved changes to your document(s).</source>
|
||||||
|
@ -129,13 +129,17 @@ def redo_ocr(doc_ids):
|
|||||||
return "OK"
|
return "OK"
|
||||||
|
|
||||||
|
|
||||||
def set_permissions(doc_ids, set_permissions, owner=None):
|
def set_permissions(doc_ids, set_permissions, owner=None, merge=False):
|
||||||
qs = Document.objects.filter(id__in=doc_ids)
|
qs = Document.objects.filter(id__in=doc_ids)
|
||||||
|
|
||||||
qs.update(owner=owner)
|
if merge:
|
||||||
|
# If merging, only set owner for documents that don't have an owner
|
||||||
|
qs.filter(owner__isnull=True).update(owner=owner)
|
||||||
|
else:
|
||||||
|
qs.update(owner=owner)
|
||||||
|
|
||||||
for doc in qs:
|
for doc in qs:
|
||||||
set_permissions_for_object(set_permissions, doc)
|
set_permissions_for_object(permissions=set_permissions, object=doc, merge=merge)
|
||||||
|
|
||||||
affected_docs = [doc.id for doc in qs]
|
affected_docs = [doc.id for doc in qs]
|
||||||
|
|
||||||
|
197
src/documents/caching.py
Normal file
197
src/documents/caching.py
Normal file
@ -0,0 +1,197 @@
|
|||||||
|
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
|
||||||
|
|
||||||
|
from documents.models import Document
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from documents.classifier import DocumentClassifier
|
||||||
|
|
||||||
|
logger = logging.getLogger("paperless.caching")
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class MetadataCacheData:
|
||||||
|
original_checksum: str
|
||||||
|
original_metadata: list
|
||||||
|
archive_checksum: Optional[str]
|
||||||
|
archive_metadata: Optional[list]
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class SuggestionCacheData:
|
||||||
|
classifier_version: int
|
||||||
|
classifier_hash: str
|
||||||
|
suggestions: dict
|
||||||
|
|
||||||
|
|
||||||
|
CLASSIFIER_VERSION_KEY: Final[str] = "classifier_version"
|
||||||
|
CLASSIFIER_HASH_KEY: Final[str] = "classifier_hash"
|
||||||
|
CLASSIFIER_MODIFIED_KEY: Final[str] = "classifier_modified"
|
||||||
|
|
||||||
|
CACHE_1_MINUTE: Final[int] = 60
|
||||||
|
CACHE_5_MINUTES: Final[int] = 5 * CACHE_1_MINUTE
|
||||||
|
CACHE_50_MINUTES: Final[int] = 50 * CACHE_1_MINUTE
|
||||||
|
|
||||||
|
|
||||||
|
def get_suggestion_cache_key(document_id: int) -> str:
|
||||||
|
"""
|
||||||
|
Returns the basic key for a document's suggestions
|
||||||
|
"""
|
||||||
|
return f"doc_{document_id}_suggest"
|
||||||
|
|
||||||
|
|
||||||
|
def get_suggestion_cache(document_id: int) -> Optional[SuggestionCacheData]:
|
||||||
|
"""
|
||||||
|
If possible, return the cached suggestions for the given document ID.
|
||||||
|
The classifier needs to be matching in format and hash and the suggestions need to
|
||||||
|
have been cached once.
|
||||||
|
"""
|
||||||
|
from documents.classifier import DocumentClassifier
|
||||||
|
|
||||||
|
doc_key = get_suggestion_cache_key(document_id)
|
||||||
|
cache_hits = cache.get_many([CLASSIFIER_VERSION_KEY, CLASSIFIER_HASH_KEY, doc_key])
|
||||||
|
# The document suggestions are in the cache
|
||||||
|
if doc_key in cache_hits:
|
||||||
|
doc_suggestions: SuggestionCacheData = cache_hits[doc_key]
|
||||||
|
# The classifier format is the same
|
||||||
|
# The classifier hash is the same
|
||||||
|
# Then the suggestions can be used
|
||||||
|
if (
|
||||||
|
CLASSIFIER_VERSION_KEY in cache_hits
|
||||||
|
and cache_hits[CLASSIFIER_VERSION_KEY] == DocumentClassifier.FORMAT_VERSION
|
||||||
|
and cache_hits[CLASSIFIER_VERSION_KEY] == doc_suggestions.classifier_version
|
||||||
|
) and (
|
||||||
|
CLASSIFIER_HASH_KEY in cache_hits
|
||||||
|
and cache_hits[CLASSIFIER_HASH_KEY] == doc_suggestions.classifier_hash
|
||||||
|
):
|
||||||
|
return doc_suggestions
|
||||||
|
else: # pragma: no cover
|
||||||
|
# Remove the key because something didn't match
|
||||||
|
cache.delete(doc_key)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def set_suggestions_cache(
|
||||||
|
document_id: int,
|
||||||
|
suggestions: dict,
|
||||||
|
classifier: Optional["DocumentClassifier"],
|
||||||
|
*,
|
||||||
|
timeout=CACHE_50_MINUTES,
|
||||||
|
) -> None:
|
||||||
|
"""
|
||||||
|
Caches the given suggestions, which were generated by the given classifier. If there is no classifier,
|
||||||
|
this function is a no-op (there won't be suggestions then anyway)
|
||||||
|
"""
|
||||||
|
if classifier is not None:
|
||||||
|
doc_key = get_suggestion_cache_key(document_id)
|
||||||
|
print(classifier.last_auto_type_hash)
|
||||||
|
cache.set(
|
||||||
|
doc_key,
|
||||||
|
SuggestionCacheData(
|
||||||
|
classifier.FORMAT_VERSION,
|
||||||
|
hexlify(classifier.last_auto_type_hash).decode(),
|
||||||
|
suggestions,
|
||||||
|
),
|
||||||
|
timeout,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def refresh_suggestions_cache(
|
||||||
|
document_id: int,
|
||||||
|
*,
|
||||||
|
timeout: int = CACHE_50_MINUTES,
|
||||||
|
) -> None:
|
||||||
|
"""
|
||||||
|
Refreshes the expiration of the suggestions for the given document ID
|
||||||
|
to the given timeout
|
||||||
|
"""
|
||||||
|
doc_key = get_suggestion_cache_key(document_id)
|
||||||
|
cache.touch(doc_key, timeout)
|
||||||
|
|
||||||
|
|
||||||
|
def get_metadata_cache_key(document_id: int) -> str:
|
||||||
|
"""
|
||||||
|
Returns the basic key for a document's metadata
|
||||||
|
"""
|
||||||
|
return f"doc_{document_id}_metadata"
|
||||||
|
|
||||||
|
|
||||||
|
def get_metadata_cache(document_id: int) -> Optional[MetadataCacheData]:
|
||||||
|
"""
|
||||||
|
Returns the cached document metadata for the given document ID, as long as the metadata
|
||||||
|
was cached once and the checksums have not changed
|
||||||
|
"""
|
||||||
|
doc_key = get_metadata_cache_key(document_id)
|
||||||
|
doc_metadata: Optional[MetadataCacheData] = cache.get(doc_key)
|
||||||
|
# The metadata exists in the cache
|
||||||
|
if doc_metadata is not None:
|
||||||
|
try:
|
||||||
|
doc = Document.objects.get(pk=document_id)
|
||||||
|
# The original checksums match
|
||||||
|
# If it has one, the archive checksums match
|
||||||
|
# Then, we can use the metadata
|
||||||
|
if (
|
||||||
|
doc_metadata.original_checksum == doc.checksum
|
||||||
|
and doc.has_archive_version
|
||||||
|
and doc_metadata.archive_checksum is not None
|
||||||
|
and doc_metadata.archive_checksum == doc.archive_checksum
|
||||||
|
):
|
||||||
|
# Refresh cache
|
||||||
|
cache.touch(doc_key, CACHE_50_MINUTES)
|
||||||
|
return doc_metadata
|
||||||
|
else: # pragma: no cover
|
||||||
|
# Something didn't match, delete the key
|
||||||
|
cache.delete(doc_key)
|
||||||
|
except Document.DoesNotExist: # pragma: no cover
|
||||||
|
# Basically impossible, but the key existed, but the Document didn't
|
||||||
|
cache.delete(doc_key)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def set_metadata_cache(
|
||||||
|
document: Document,
|
||||||
|
original_metadata: list,
|
||||||
|
archive_metadata: Optional[list],
|
||||||
|
*,
|
||||||
|
timeout=CACHE_50_MINUTES,
|
||||||
|
) -> None:
|
||||||
|
"""
|
||||||
|
Sets the metadata into cache for the given Document
|
||||||
|
"""
|
||||||
|
doc_key = get_metadata_cache_key(document.pk)
|
||||||
|
cache.set(
|
||||||
|
doc_key,
|
||||||
|
MetadataCacheData(
|
||||||
|
document.checksum,
|
||||||
|
original_metadata,
|
||||||
|
document.archive_checksum,
|
||||||
|
archive_metadata,
|
||||||
|
),
|
||||||
|
timeout,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def refresh_metadata_cache(
|
||||||
|
document_id: int,
|
||||||
|
*,
|
||||||
|
timeout: int = CACHE_50_MINUTES,
|
||||||
|
) -> None:
|
||||||
|
"""
|
||||||
|
Refreshes the expiration of the metadata for the given document ID
|
||||||
|
to the given timeout
|
||||||
|
"""
|
||||||
|
doc_key = get_metadata_cache_key(document_id)
|
||||||
|
cache.touch(doc_key, timeout)
|
||||||
|
|
||||||
|
|
||||||
|
def get_thumbnail_modified_key(document_id: int) -> str:
|
||||||
|
"""
|
||||||
|
Builds the key to store a thumbnail's timestamp
|
||||||
|
"""
|
||||||
|
return f"doc_{document_id}_thumbnail_modified"
|
@ -10,8 +10,13 @@ from pathlib import Path
|
|||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
|
from django.core.cache import cache
|
||||||
from sklearn.exceptions import InconsistentVersionWarning
|
from sklearn.exceptions import InconsistentVersionWarning
|
||||||
|
|
||||||
|
from documents.caching import CACHE_50_MINUTES
|
||||||
|
from documents.caching import CLASSIFIER_HASH_KEY
|
||||||
|
from documents.caching import CLASSIFIER_MODIFIED_KEY
|
||||||
|
from documents.caching import CLASSIFIER_VERSION_KEY
|
||||||
from documents.models import Document
|
from documents.models import Document
|
||||||
from documents.models import MatchingModel
|
from documents.models import MatchingModel
|
||||||
|
|
||||||
@ -208,6 +213,15 @@ class DocumentClassifier:
|
|||||||
and self.last_doc_change_time >= latest_doc_change
|
and self.last_doc_change_time >= latest_doc_change
|
||||||
) and self.last_auto_type_hash == hasher.digest():
|
) and self.last_auto_type_hash == hasher.digest():
|
||||||
logger.info("No updates since last training")
|
logger.info("No updates since last training")
|
||||||
|
# Set the classifier information into the cache
|
||||||
|
# Caching for 50 minutes, so slightly less than the normal retrain time
|
||||||
|
cache.set(
|
||||||
|
CLASSIFIER_MODIFIED_KEY,
|
||||||
|
self.last_doc_change_time,
|
||||||
|
CACHE_50_MINUTES,
|
||||||
|
)
|
||||||
|
cache.set(CLASSIFIER_HASH_KEY, hasher.hexdigest(), CACHE_50_MINUTES)
|
||||||
|
cache.set(CLASSIFIER_VERSION_KEY, self.FORMAT_VERSION, CACHE_50_MINUTES)
|
||||||
return False
|
return False
|
||||||
|
|
||||||
# subtract 1 since -1 (null) is also part of the classes.
|
# subtract 1 since -1 (null) is also part of the classes.
|
||||||
@ -322,6 +336,12 @@ class DocumentClassifier:
|
|||||||
self.last_doc_change_time = latest_doc_change
|
self.last_doc_change_time = latest_doc_change
|
||||||
self.last_auto_type_hash = hasher.digest()
|
self.last_auto_type_hash = hasher.digest()
|
||||||
|
|
||||||
|
# Set the classifier information into the cache
|
||||||
|
# Caching for 50 minutes, so slightly less than the normal retrain time
|
||||||
|
cache.set(CLASSIFIER_MODIFIED_KEY, self.last_doc_change_time, CACHE_50_MINUTES)
|
||||||
|
cache.set(CLASSIFIER_HASH_KEY, hasher.hexdigest(), CACHE_50_MINUTES)
|
||||||
|
cache.set(CLASSIFIER_VERSION_KEY, self.FORMAT_VERSION, CACHE_50_MINUTES)
|
||||||
|
|
||||||
return True
|
return True
|
||||||
|
|
||||||
def preprocess_content(self, content: str) -> str: # pragma: no cover
|
def preprocess_content(self, content: str) -> str: # pragma: no cover
|
||||||
|
@ -1,9 +1,16 @@
|
|||||||
import pickle
|
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
from datetime import timezone
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
|
from django.core.cache import cache
|
||||||
|
|
||||||
|
from documents.caching import CACHE_5_MINUTES
|
||||||
|
from documents.caching import CACHE_50_MINUTES
|
||||||
|
from documents.caching import CLASSIFIER_HASH_KEY
|
||||||
|
from documents.caching import CLASSIFIER_MODIFIED_KEY
|
||||||
|
from documents.caching import CLASSIFIER_VERSION_KEY
|
||||||
|
from documents.caching import get_thumbnail_modified_key
|
||||||
from documents.classifier import DocumentClassifier
|
from documents.classifier import DocumentClassifier
|
||||||
from documents.models import Document
|
from documents.models import Document
|
||||||
|
|
||||||
@ -14,18 +21,25 @@ def suggestions_etag(request, pk: int) -> Optional[str]:
|
|||||||
suggestions if the classifier has not been changed and the suggested dates
|
suggestions if the classifier has not been changed and the suggested dates
|
||||||
setting is also unchanged
|
setting is also unchanged
|
||||||
|
|
||||||
TODO: It would be nice to not duplicate the partial loading and the loading
|
|
||||||
between here and the actual classifier
|
|
||||||
"""
|
"""
|
||||||
|
# If no model file, no etag at all
|
||||||
if not settings.MODEL_FILE.exists():
|
if not settings.MODEL_FILE.exists():
|
||||||
return None
|
return None
|
||||||
with open(settings.MODEL_FILE, "rb") as f:
|
# Check cache information
|
||||||
schema_version = pickle.load(f)
|
cache_hits = cache.get_many(
|
||||||
if schema_version != DocumentClassifier.FORMAT_VERSION:
|
[CLASSIFIER_VERSION_KEY, CLASSIFIER_HASH_KEY],
|
||||||
return None
|
)
|
||||||
_ = pickle.load(f)
|
# If the version differs somehow, no etag
|
||||||
last_auto_type_hash: bytes = pickle.load(f)
|
if (
|
||||||
return f"{last_auto_type_hash}:{settings.NUMBER_OF_SUGGESTED_DATES}"
|
CLASSIFIER_VERSION_KEY in cache_hits
|
||||||
|
and cache_hits[CLASSIFIER_VERSION_KEY] != DocumentClassifier.FORMAT_VERSION
|
||||||
|
):
|
||||||
|
return None
|
||||||
|
elif CLASSIFIER_HASH_KEY in cache_hits:
|
||||||
|
# Refresh the cache and return the hash digest and the dates setting
|
||||||
|
cache.touch(CLASSIFIER_HASH_KEY, CACHE_5_MINUTES)
|
||||||
|
return f"{cache_hits[CLASSIFIER_HASH_KEY]}:{settings.NUMBER_OF_SUGGESTED_DATES}"
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
def suggestions_last_modified(request, pk: int) -> Optional[datetime]:
|
def suggestions_last_modified(request, pk: int) -> Optional[datetime]:
|
||||||
@ -34,14 +48,23 @@ def suggestions_last_modified(request, pk: int) -> Optional[datetime]:
|
|||||||
as there is not way to track the suggested date setting modification, but it seems
|
as there is not way to track the suggested date setting modification, but it seems
|
||||||
unlikely that changes too often
|
unlikely that changes too often
|
||||||
"""
|
"""
|
||||||
|
# No file, no last modified
|
||||||
if not settings.MODEL_FILE.exists():
|
if not settings.MODEL_FILE.exists():
|
||||||
return None
|
return None
|
||||||
with open(settings.MODEL_FILE, "rb") as f:
|
cache_hits = cache.get_many(
|
||||||
schema_version = pickle.load(f)
|
[CLASSIFIER_VERSION_KEY, CLASSIFIER_MODIFIED_KEY],
|
||||||
if schema_version != DocumentClassifier.FORMAT_VERSION:
|
)
|
||||||
return None
|
# If the version differs somehow, no last modified
|
||||||
last_doc_change_time = pickle.load(f)
|
if (
|
||||||
return last_doc_change_time
|
CLASSIFIER_VERSION_KEY in cache_hits
|
||||||
|
and cache_hits[CLASSIFIER_VERSION_KEY] != DocumentClassifier.FORMAT_VERSION
|
||||||
|
):
|
||||||
|
return None
|
||||||
|
elif CLASSIFIER_MODIFIED_KEY in cache_hits:
|
||||||
|
# Refresh the cache and return the last modified
|
||||||
|
cache.touch(CLASSIFIER_MODIFIED_KEY, CACHE_5_MINUTES)
|
||||||
|
return cache_hits[CLASSIFIER_MODIFIED_KEY]
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
def metadata_etag(request, pk: int) -> Optional[str]:
|
def metadata_etag(request, pk: int) -> Optional[str]:
|
||||||
@ -52,7 +75,7 @@ def metadata_etag(request, pk: int) -> Optional[str]:
|
|||||||
try:
|
try:
|
||||||
doc = Document.objects.get(pk=pk)
|
doc = Document.objects.get(pk=pk)
|
||||||
return doc.checksum
|
return doc.checksum
|
||||||
except Document.DoesNotExist:
|
except Document.DoesNotExist: # pragma: no cover
|
||||||
return None
|
return None
|
||||||
return None
|
return None
|
||||||
|
|
||||||
@ -66,7 +89,7 @@ def metadata_last_modified(request, pk: int) -> Optional[datetime]:
|
|||||||
try:
|
try:
|
||||||
doc = Document.objects.get(pk=pk)
|
doc = Document.objects.get(pk=pk)
|
||||||
return doc.modified
|
return doc.modified
|
||||||
except Document.DoesNotExist:
|
except Document.DoesNotExist: # pragma: no cover
|
||||||
return None
|
return None
|
||||||
return None
|
return None
|
||||||
|
|
||||||
@ -82,6 +105,46 @@ def preview_etag(request, pk: int) -> Optional[str]:
|
|||||||
and request.query_params["original"] == "true"
|
and request.query_params["original"] == "true"
|
||||||
)
|
)
|
||||||
return doc.checksum if use_original else doc.archive_checksum
|
return doc.checksum if use_original else doc.archive_checksum
|
||||||
except Document.DoesNotExist:
|
except Document.DoesNotExist: # pragma: no cover
|
||||||
return None
|
return None
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def preview_last_modified(request, pk: int) -> Optional[datetime]:
|
||||||
|
"""
|
||||||
|
Uses the documents modified time to set the Last-Modified header. Not strictly
|
||||||
|
speaking correct, but close enough and quick
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
doc = Document.objects.get(pk=pk)
|
||||||
|
return doc.modified
|
||||||
|
except Document.DoesNotExist: # pragma: no cover
|
||||||
|
return None
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def thumbnail_last_modified(request, pk: int) -> Optional[datetime]:
|
||||||
|
"""
|
||||||
|
Returns the filesystem last modified either from cache or from filesystem.
|
||||||
|
Cache should be (slightly?) faster than filesystem
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
doc = Document.objects.get(pk=pk)
|
||||||
|
if not doc.thumbnail_path.exists():
|
||||||
|
return None
|
||||||
|
doc_key = get_thumbnail_modified_key(pk)
|
||||||
|
|
||||||
|
cache_hit = cache.get(doc_key)
|
||||||
|
if cache_hit is not None:
|
||||||
|
cache.touch(doc_key, CACHE_50_MINUTES)
|
||||||
|
return cache_hit
|
||||||
|
|
||||||
|
# No cache, get the timestamp and cache the datetime
|
||||||
|
last_modified = datetime.fromtimestamp(
|
||||||
|
doc.thumbnail_path.stat().st_mtime,
|
||||||
|
tz=timezone.utc,
|
||||||
|
)
|
||||||
|
cache.set(doc_key, last_modified, CACHE_50_MINUTES)
|
||||||
|
return last_modified
|
||||||
|
except Document.DoesNotExist: # pragma: no cover
|
||||||
|
return None
|
||||||
|
@ -232,6 +232,9 @@ class Command(BaseCommand):
|
|||||||
if not os.path.isdir(directory):
|
if not os.path.isdir(directory):
|
||||||
raise CommandError(f"Consumption directory {directory} does not exist")
|
raise CommandError(f"Consumption directory {directory} does not exist")
|
||||||
|
|
||||||
|
# Consumer will need this
|
||||||
|
settings.SCRATCH_DIR.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
if recursive:
|
if recursive:
|
||||||
for dirpath, _, filenames in os.walk(directory):
|
for dirpath, _, filenames in os.walk(directory):
|
||||||
for filename in filenames:
|
for filename in filenames:
|
||||||
|
@ -140,6 +140,7 @@ def run_convert(
|
|||||||
type=None,
|
type=None,
|
||||||
depth=None,
|
depth=None,
|
||||||
auto_orient=False,
|
auto_orient=False,
|
||||||
|
use_cropbox=False,
|
||||||
extra=None,
|
extra=None,
|
||||||
logging_group=None,
|
logging_group=None,
|
||||||
) -> None:
|
) -> None:
|
||||||
@ -158,6 +159,7 @@ def run_convert(
|
|||||||
args += ["-type", str(type)] if type else []
|
args += ["-type", str(type)] if type else []
|
||||||
args += ["-depth", str(depth)] if depth else []
|
args += ["-depth", str(depth)] if depth else []
|
||||||
args += ["-auto-orient"] if auto_orient else []
|
args += ["-auto-orient"] if auto_orient else []
|
||||||
|
args += ["-define", "pdf:use-cropbox=true"] if use_cropbox else []
|
||||||
args += [input_file, output_file]
|
args += [input_file, output_file]
|
||||||
|
|
||||||
logger.debug("Execute: " + " ".join(args), extra={"group": logging_group})
|
logger.debug("Execute: " + " ".join(args), extra={"group": logging_group})
|
||||||
@ -229,6 +231,7 @@ def make_thumbnail_from_pdf(in_path, temp_dir, logging_group=None) -> str:
|
|||||||
strip=True,
|
strip=True,
|
||||||
trim=False,
|
trim=False,
|
||||||
auto_orient=True,
|
auto_orient=True,
|
||||||
|
use_cropbox=True,
|
||||||
input_file=f"{in_path}[0]",
|
input_file=f"{in_path}[0]",
|
||||||
output_file=out_path,
|
output_file=out_path,
|
||||||
logging_group=logging_group,
|
logging_group=logging_group,
|
||||||
|
@ -81,7 +81,7 @@ class MatchingModelSerializer(serializers.ModelSerializer):
|
|||||||
slug = SerializerMethodField()
|
slug = SerializerMethodField()
|
||||||
|
|
||||||
def validate(self, data):
|
def validate(self, data):
|
||||||
# see https://github.com/encode/django-rest-framework/issues/7173
|
# TODO: remove pending https://github.com/encode/django-rest-framework/issues/7173
|
||||||
name = data["name"] if "name" in data else self.instance.name
|
name = data["name"] if "name" in data else self.instance.name
|
||||||
owner = (
|
owner = (
|
||||||
data["owner"]
|
data["owner"]
|
||||||
@ -441,6 +441,17 @@ class CustomFieldSerializer(serializers.ModelSerializer):
|
|||||||
"data_type",
|
"data_type",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
def validate(self, attrs):
|
||||||
|
# TODO: remove pending https://github.com/encode/django-rest-framework/issues/7173
|
||||||
|
name = attrs["name"] if "name" in attrs else self.instance.name
|
||||||
|
if ("name" in attrs) and self.Meta.model.objects.filter(
|
||||||
|
name=name,
|
||||||
|
).exists():
|
||||||
|
raise serializers.ValidationError(
|
||||||
|
{"error": "Object violates name unique constraint"},
|
||||||
|
)
|
||||||
|
return super().validate(attrs)
|
||||||
|
|
||||||
|
|
||||||
class ReadWriteSerializerMethodField(serializers.SerializerMethodField):
|
class ReadWriteSerializerMethodField(serializers.SerializerMethodField):
|
||||||
"""
|
"""
|
||||||
@ -638,6 +649,11 @@ class DocumentSerializer(
|
|||||||
allow_null=True,
|
allow_null=True,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
remove_inbox_tags = serializers.BooleanField(
|
||||||
|
default=False,
|
||||||
|
write_only=True,
|
||||||
|
)
|
||||||
|
|
||||||
def get_original_file_name(self, obj):
|
def get_original_file_name(self, obj):
|
||||||
return obj.original_filename
|
return obj.original_filename
|
||||||
|
|
||||||
@ -681,12 +697,48 @@ class DocumentSerializer(
|
|||||||
custom_field_instance.field,
|
custom_field_instance.field,
|
||||||
doc_id,
|
doc_id,
|
||||||
)
|
)
|
||||||
|
if (
|
||||||
|
"remove_inbox_tags" in validated_data
|
||||||
|
and validated_data["remove_inbox_tags"]
|
||||||
|
):
|
||||||
|
tag_ids_being_added = (
|
||||||
|
[
|
||||||
|
tag.id
|
||||||
|
for tag in validated_data["tags"]
|
||||||
|
if tag not in instance.tags.all()
|
||||||
|
]
|
||||||
|
if "tags" in validated_data
|
||||||
|
else []
|
||||||
|
)
|
||||||
|
inbox_tags_not_being_added = Tag.objects.filter(is_inbox_tag=True).exclude(
|
||||||
|
id__in=tag_ids_being_added,
|
||||||
|
)
|
||||||
|
if "tags" in validated_data:
|
||||||
|
validated_data["tags"] = [
|
||||||
|
tag
|
||||||
|
for tag in validated_data["tags"]
|
||||||
|
if tag not in inbox_tags_not_being_added
|
||||||
|
]
|
||||||
|
else:
|
||||||
|
validated_data["tags"] = [
|
||||||
|
tag
|
||||||
|
for tag in instance.tags.all()
|
||||||
|
if tag not in inbox_tags_not_being_added
|
||||||
|
]
|
||||||
super().update(instance, validated_data)
|
super().update(instance, validated_data)
|
||||||
return instance
|
return instance
|
||||||
|
|
||||||
def __init__(self, *args, **kwargs):
|
def __init__(self, *args, **kwargs):
|
||||||
self.truncate_content = kwargs.pop("truncate_content", False)
|
self.truncate_content = kwargs.pop("truncate_content", False)
|
||||||
|
|
||||||
|
# return full permissions if we're doing a PATCH or PUT
|
||||||
|
context = kwargs.get("context")
|
||||||
|
if (
|
||||||
|
context.get("request").method == "PATCH"
|
||||||
|
or context.get("request").method == "PUT"
|
||||||
|
):
|
||||||
|
kwargs["full_perms"] = True
|
||||||
|
|
||||||
super().__init__(*args, **kwargs)
|
super().__init__(*args, **kwargs)
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
@ -714,6 +766,7 @@ class DocumentSerializer(
|
|||||||
"set_permissions",
|
"set_permissions",
|
||||||
"notes",
|
"notes",
|
||||||
"custom_fields",
|
"custom_fields",
|
||||||
|
"remove_inbox_tags",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@ -916,6 +969,8 @@ class BulkEditSerializer(DocumentListSerializer, SetPermissionsMixin):
|
|||||||
)
|
)
|
||||||
if "owner" in parameters and parameters["owner"] is not None:
|
if "owner" in parameters and parameters["owner"] is not None:
|
||||||
self._validate_owner(parameters["owner"])
|
self._validate_owner(parameters["owner"])
|
||||||
|
if "merge" not in parameters:
|
||||||
|
parameters["merge"] = False
|
||||||
|
|
||||||
def validate(self, attrs):
|
def validate(self, attrs):
|
||||||
method = attrs["method"]
|
method = attrs["method"]
|
||||||
@ -1258,6 +1313,12 @@ class BulkEditObjectPermissionsSerializer(serializers.Serializer, SetPermissions
|
|||||||
write_only=True,
|
write_only=True,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
merge = serializers.BooleanField(
|
||||||
|
default=False,
|
||||||
|
write_only=True,
|
||||||
|
required=False,
|
||||||
|
)
|
||||||
|
|
||||||
def get_object_class(self, object_type):
|
def get_object_class(self, object_type):
|
||||||
object_class = None
|
object_class = None
|
||||||
if object_type == "tags":
|
if object_type == "tags":
|
||||||
|
@ -765,6 +765,58 @@ class TestBulkEdit(DirectoriesMixin, APITestCase):
|
|||||||
self.assertCountEqual(args[0], [self.doc2.id, self.doc3.id])
|
self.assertCountEqual(args[0], [self.doc2.id, self.doc3.id])
|
||||||
self.assertEqual(len(kwargs["set_permissions"]["view"]["users"]), 2)
|
self.assertEqual(len(kwargs["set_permissions"]["view"]["users"]), 2)
|
||||||
|
|
||||||
|
@mock.patch("documents.serialisers.bulk_edit.set_permissions")
|
||||||
|
def test_set_permissions_merge(self, m):
|
||||||
|
m.return_value = "OK"
|
||||||
|
user1 = User.objects.create(username="user1")
|
||||||
|
user2 = User.objects.create(username="user2")
|
||||||
|
permissions = {
|
||||||
|
"view": {
|
||||||
|
"users": [user1.id, user2.id],
|
||||||
|
"groups": None,
|
||||||
|
},
|
||||||
|
"change": {
|
||||||
|
"users": [user1.id],
|
||||||
|
"groups": None,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
response = self.client.post(
|
||||||
|
"/api/documents/bulk_edit/",
|
||||||
|
json.dumps(
|
||||||
|
{
|
||||||
|
"documents": [self.doc2.id, self.doc3.id],
|
||||||
|
"method": "set_permissions",
|
||||||
|
"parameters": {"set_permissions": permissions},
|
||||||
|
},
|
||||||
|
),
|
||||||
|
content_type="application/json",
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||||
|
|
||||||
|
m.assert_called()
|
||||||
|
args, kwargs = m.call_args
|
||||||
|
self.assertEqual(kwargs["merge"], False)
|
||||||
|
|
||||||
|
response = self.client.post(
|
||||||
|
"/api/documents/bulk_edit/",
|
||||||
|
json.dumps(
|
||||||
|
{
|
||||||
|
"documents": [self.doc2.id, self.doc3.id],
|
||||||
|
"method": "set_permissions",
|
||||||
|
"parameters": {"set_permissions": permissions, "merge": True},
|
||||||
|
},
|
||||||
|
),
|
||||||
|
content_type="application/json",
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||||
|
|
||||||
|
m.assert_called()
|
||||||
|
args, kwargs = m.call_args
|
||||||
|
self.assertEqual(kwargs["merge"], True)
|
||||||
|
|
||||||
@mock.patch("documents.serialisers.bulk_edit.set_permissions")
|
@mock.patch("documents.serialisers.bulk_edit.set_permissions")
|
||||||
def test_insufficient_permissions_ownership(self, m):
|
def test_insufficient_permissions_ownership(self, m):
|
||||||
"""
|
"""
|
||||||
|
@ -53,6 +53,29 @@ class TestCustomField(DirectoriesMixin, APITestCase):
|
|||||||
self.assertEqual(data["name"], name)
|
self.assertEqual(data["name"], name)
|
||||||
self.assertEqual(data["data_type"], field_type)
|
self.assertEqual(data["data_type"], field_type)
|
||||||
|
|
||||||
|
def test_create_custom_field_nonunique_name(self):
|
||||||
|
"""
|
||||||
|
GIVEN:
|
||||||
|
- Custom field exists
|
||||||
|
WHEN:
|
||||||
|
- API request to create custom field with the same name
|
||||||
|
THEN:
|
||||||
|
- HTTP 400 is returned
|
||||||
|
"""
|
||||||
|
CustomField.objects.create(
|
||||||
|
name="Test Custom Field",
|
||||||
|
data_type=CustomField.FieldDataType.STRING,
|
||||||
|
)
|
||||||
|
|
||||||
|
resp = self.client.post(
|
||||||
|
self.ENDPOINT,
|
||||||
|
data={
|
||||||
|
"data_type": "string",
|
||||||
|
"name": "Test Custom Field",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
self.assertEqual(resp.status_code, status.HTTP_400_BAD_REQUEST)
|
||||||
|
|
||||||
def test_create_custom_field_instance(self):
|
def test_create_custom_field_instance(self):
|
||||||
"""
|
"""
|
||||||
GIVEN:
|
GIVEN:
|
||||||
|
@ -4,6 +4,7 @@ import shutil
|
|||||||
import tempfile
|
import tempfile
|
||||||
import uuid
|
import uuid
|
||||||
import zoneinfo
|
import zoneinfo
|
||||||
|
from binascii import hexlify
|
||||||
from datetime import timedelta
|
from datetime import timedelta
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from unittest import mock
|
from unittest import mock
|
||||||
@ -13,12 +14,17 @@ from dateutil import parser
|
|||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.contrib.auth.models import Permission
|
from django.contrib.auth.models import Permission
|
||||||
from django.contrib.auth.models import User
|
from django.contrib.auth.models import User
|
||||||
|
from django.core.cache import cache
|
||||||
from django.test import override_settings
|
from django.test import override_settings
|
||||||
from django.utils import timezone
|
from django.utils import timezone
|
||||||
from guardian.shortcuts import assign_perm
|
from guardian.shortcuts import assign_perm
|
||||||
from rest_framework import status
|
from rest_framework import status
|
||||||
from rest_framework.test import APITestCase
|
from rest_framework.test import APITestCase
|
||||||
|
|
||||||
|
from documents.caching import CACHE_50_MINUTES
|
||||||
|
from documents.caching import CLASSIFIER_HASH_KEY
|
||||||
|
from documents.caching import CLASSIFIER_MODIFIED_KEY
|
||||||
|
from documents.caching import CLASSIFIER_VERSION_KEY
|
||||||
from documents.models import Correspondent
|
from documents.models import Correspondent
|
||||||
from documents.models import CustomField
|
from documents.models import CustomField
|
||||||
from documents.models import CustomFieldInstance
|
from documents.models import CustomFieldInstance
|
||||||
@ -40,6 +46,7 @@ class TestDocumentApi(DirectoriesMixin, DocumentConsumeDelayMixin, APITestCase):
|
|||||||
|
|
||||||
self.user = User.objects.create_superuser(username="temp_admin")
|
self.user = User.objects.create_superuser(username="temp_admin")
|
||||||
self.client.force_authenticate(user=self.user)
|
self.client.force_authenticate(user=self.user)
|
||||||
|
cache.clear()
|
||||||
|
|
||||||
def testDocuments(self):
|
def testDocuments(self):
|
||||||
response = self.client.get("/api/documents/").data
|
response = self.client.get("/api/documents/").data
|
||||||
@ -1162,6 +1169,9 @@ class TestDocumentApi(DirectoriesMixin, DocumentConsumeDelayMixin, APITestCase):
|
|||||||
self.assertEqual(meta["original_size"], os.stat(source_file).st_size)
|
self.assertEqual(meta["original_size"], os.stat(source_file).st_size)
|
||||||
self.assertEqual(meta["archive_size"], os.stat(archive_file).st_size)
|
self.assertEqual(meta["archive_size"], os.stat(archive_file).st_size)
|
||||||
|
|
||||||
|
response = self.client.get(f"/api/documents/{doc.pk}/metadata/")
|
||||||
|
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||||
|
|
||||||
def test_get_metadata_invalid_doc(self):
|
def test_get_metadata_invalid_doc(self):
|
||||||
response = self.client.get("/api/documents/34576/metadata/")
|
response = self.client.get("/api/documents/34576/metadata/")
|
||||||
self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND)
|
self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND)
|
||||||
@ -1266,7 +1276,7 @@ class TestDocumentApi(DirectoriesMixin, DocumentConsumeDelayMixin, APITestCase):
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
@mock.patch("documents.conditionals.pickle.load")
|
@mock.patch("documents.views.load_classifier")
|
||||||
@mock.patch("documents.views.match_storage_paths")
|
@mock.patch("documents.views.match_storage_paths")
|
||||||
@mock.patch("documents.views.match_document_types")
|
@mock.patch("documents.views.match_document_types")
|
||||||
@mock.patch("documents.views.match_tags")
|
@mock.patch("documents.views.match_tags")
|
||||||
@ -1278,7 +1288,7 @@ class TestDocumentApi(DirectoriesMixin, DocumentConsumeDelayMixin, APITestCase):
|
|||||||
match_tags,
|
match_tags,
|
||||||
match_document_types,
|
match_document_types,
|
||||||
match_storage_paths,
|
match_storage_paths,
|
||||||
mocked_pickle_load,
|
mocked_load,
|
||||||
):
|
):
|
||||||
"""
|
"""
|
||||||
GIVEN:
|
GIVEN:
|
||||||
@ -1287,23 +1297,43 @@ class TestDocumentApi(DirectoriesMixin, DocumentConsumeDelayMixin, APITestCase):
|
|||||||
- Classifier has not been modified
|
- Classifier has not been modified
|
||||||
THEN:
|
THEN:
|
||||||
- Subsequent requests are returned alright
|
- Subsequent requests are returned alright
|
||||||
- ETag and last modified are called
|
- ETag and last modified headers are set
|
||||||
"""
|
"""
|
||||||
settings.MODEL_FILE.touch()
|
|
||||||
|
|
||||||
|
# setup the cache how the classifier does it
|
||||||
from documents.classifier import DocumentClassifier
|
from documents.classifier import DocumentClassifier
|
||||||
|
|
||||||
last_modified = timezone.now()
|
settings.MODEL_FILE.touch()
|
||||||
|
|
||||||
# ETag first, then modified
|
classifier_checksum_bytes = b"thisisachecksum"
|
||||||
mock_effect = [
|
classifier_checksum_hex = hexlify(classifier_checksum_bytes).decode()
|
||||||
DocumentClassifier.FORMAT_VERSION,
|
|
||||||
"dont care",
|
# Two loads, so two side effects
|
||||||
b"thisisachecksum",
|
mocked_load.side_effect = [
|
||||||
DocumentClassifier.FORMAT_VERSION,
|
mock.Mock(
|
||||||
last_modified,
|
last_auto_type_hash=classifier_checksum_bytes,
|
||||||
|
FORMAT_VERSION=DocumentClassifier.FORMAT_VERSION,
|
||||||
|
),
|
||||||
|
mock.Mock(
|
||||||
|
last_auto_type_hash=classifier_checksum_bytes,
|
||||||
|
FORMAT_VERSION=DocumentClassifier.FORMAT_VERSION,
|
||||||
|
),
|
||||||
]
|
]
|
||||||
mocked_pickle_load.side_effect = mock_effect
|
|
||||||
|
last_modified = timezone.now()
|
||||||
|
cache.set(CLASSIFIER_MODIFIED_KEY, last_modified, CACHE_50_MINUTES)
|
||||||
|
cache.set(CLASSIFIER_HASH_KEY, classifier_checksum_hex, CACHE_50_MINUTES)
|
||||||
|
cache.set(
|
||||||
|
CLASSIFIER_VERSION_KEY,
|
||||||
|
DocumentClassifier.FORMAT_VERSION,
|
||||||
|
CACHE_50_MINUTES,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Mock the matching
|
||||||
|
match_correspondents.return_value = [Correspondent(id=88), Correspondent(id=2)]
|
||||||
|
match_tags.return_value = [Tag(id=56), Tag(id=123)]
|
||||||
|
match_document_types.return_value = [DocumentType(id=23)]
|
||||||
|
match_storage_paths.return_value = [StoragePath(id=99), StoragePath(id=77)]
|
||||||
|
|
||||||
doc = Document.objects.create(
|
doc = Document.objects.create(
|
||||||
title="test",
|
title="test",
|
||||||
@ -1311,12 +1341,8 @@ class TestDocumentApi(DirectoriesMixin, DocumentConsumeDelayMixin, APITestCase):
|
|||||||
content="this is an invoice from 12.04.2022!",
|
content="this is an invoice from 12.04.2022!",
|
||||||
)
|
)
|
||||||
|
|
||||||
match_correspondents.return_value = [Correspondent(id=88), Correspondent(id=2)]
|
|
||||||
match_tags.return_value = [Tag(id=56), Tag(id=123)]
|
|
||||||
match_document_types.return_value = [DocumentType(id=23)]
|
|
||||||
match_storage_paths.return_value = [StoragePath(id=99), StoragePath(id=77)]
|
|
||||||
|
|
||||||
response = self.client.get(f"/api/documents/{doc.pk}/suggestions/")
|
response = self.client.get(f"/api/documents/{doc.pk}/suggestions/")
|
||||||
|
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||||
self.assertEqual(
|
self.assertEqual(
|
||||||
response.data,
|
response.data,
|
||||||
{
|
{
|
||||||
@ -1327,7 +1353,6 @@ class TestDocumentApi(DirectoriesMixin, DocumentConsumeDelayMixin, APITestCase):
|
|||||||
"dates": ["2022-04-12"],
|
"dates": ["2022-04-12"],
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
mocked_pickle_load.assert_called()
|
|
||||||
self.assertIn("Last-Modified", response.headers)
|
self.assertIn("Last-Modified", response.headers)
|
||||||
self.assertEqual(
|
self.assertEqual(
|
||||||
response.headers["Last-Modified"],
|
response.headers["Last-Modified"],
|
||||||
@ -1336,15 +1361,11 @@ class TestDocumentApi(DirectoriesMixin, DocumentConsumeDelayMixin, APITestCase):
|
|||||||
self.assertIn("ETag", response.headers)
|
self.assertIn("ETag", response.headers)
|
||||||
self.assertEqual(
|
self.assertEqual(
|
||||||
response.headers["ETag"],
|
response.headers["ETag"],
|
||||||
f"\"b'thisisachecksum':{settings.NUMBER_OF_SUGGESTED_DATES}\"",
|
f'"{classifier_checksum_hex}:{settings.NUMBER_OF_SUGGESTED_DATES}"',
|
||||||
)
|
)
|
||||||
|
|
||||||
mocked_pickle_load.rest_mock()
|
|
||||||
mocked_pickle_load.side_effect = mock_effect
|
|
||||||
|
|
||||||
response = self.client.get(f"/api/documents/{doc.pk}/suggestions/")
|
response = self.client.get(f"/api/documents/{doc.pk}/suggestions/")
|
||||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||||
mocked_pickle_load.assert_called()
|
|
||||||
|
|
||||||
@mock.patch("documents.parsers.parse_date_generator")
|
@mock.patch("documents.parsers.parse_date_generator")
|
||||||
@override_settings(NUMBER_OF_SUGGESTED_DATES=0)
|
@override_settings(NUMBER_OF_SUGGESTED_DATES=0)
|
||||||
@ -2080,6 +2101,72 @@ class TestDocumentApi(DirectoriesMixin, DocumentConsumeDelayMixin, APITestCase):
|
|||||||
self.assertEqual(resp.status_code, status.HTTP_200_OK)
|
self.assertEqual(resp.status_code, status.HTTP_200_OK)
|
||||||
self.assertEqual(resp.content, b"1")
|
self.assertEqual(resp.content, b"1")
|
||||||
|
|
||||||
|
def test_remove_inbox_tags(self):
|
||||||
|
"""
|
||||||
|
GIVEN:
|
||||||
|
- Existing document with or without inbox tags
|
||||||
|
WHEN:
|
||||||
|
- API request to update document, with or without `remove_inbox_tags` flag
|
||||||
|
THEN:
|
||||||
|
- Inbox tags are removed as long as they are not being added
|
||||||
|
"""
|
||||||
|
tag1 = Tag.objects.create(name="tag1", color="#abcdef")
|
||||||
|
inbox_tag1 = Tag.objects.create(
|
||||||
|
name="inbox1",
|
||||||
|
color="#abcdef",
|
||||||
|
is_inbox_tag=True,
|
||||||
|
)
|
||||||
|
inbox_tag2 = Tag.objects.create(
|
||||||
|
name="inbox2",
|
||||||
|
color="#abcdef",
|
||||||
|
is_inbox_tag=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
doc1 = Document.objects.create(
|
||||||
|
title="test",
|
||||||
|
mime_type="application/pdf",
|
||||||
|
content="this is a document 1",
|
||||||
|
checksum="1",
|
||||||
|
)
|
||||||
|
doc1.tags.add(tag1)
|
||||||
|
doc1.tags.add(inbox_tag1)
|
||||||
|
doc1.tags.add(inbox_tag2)
|
||||||
|
doc1.save()
|
||||||
|
|
||||||
|
# Remove inbox tags defaults to false
|
||||||
|
resp = self.client.patch(
|
||||||
|
f"/api/documents/{doc1.pk}/",
|
||||||
|
{
|
||||||
|
"title": "New title",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
doc1.refresh_from_db()
|
||||||
|
self.assertEqual(resp.status_code, status.HTTP_200_OK)
|
||||||
|
self.assertEqual(doc1.tags.count(), 3)
|
||||||
|
|
||||||
|
# Remove inbox tags set to true
|
||||||
|
resp = self.client.patch(
|
||||||
|
f"/api/documents/{doc1.pk}/",
|
||||||
|
{
|
||||||
|
"remove_inbox_tags": True,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
doc1.refresh_from_db()
|
||||||
|
self.assertEqual(resp.status_code, status.HTTP_200_OK)
|
||||||
|
self.assertEqual(doc1.tags.count(), 1)
|
||||||
|
|
||||||
|
# Remove inbox tags set to true but adding a new inbox tag
|
||||||
|
resp = self.client.patch(
|
||||||
|
f"/api/documents/{doc1.pk}/",
|
||||||
|
{
|
||||||
|
"remove_inbox_tags": True,
|
||||||
|
"tags": [inbox_tag1.pk, tag1.pk],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
doc1.refresh_from_db()
|
||||||
|
self.assertEqual(resp.status_code, status.HTTP_200_OK)
|
||||||
|
self.assertEqual(doc1.tags.count(), 2)
|
||||||
|
|
||||||
|
|
||||||
class TestDocumentApiV2(DirectoriesMixin, APITestCase):
|
class TestDocumentApiV2(DirectoriesMixin, APITestCase):
|
||||||
def setUp(self):
|
def setUp(self):
|
||||||
|
@ -700,8 +700,8 @@ class TestBulkEditObjectPermissions(APITestCase):
|
|||||||
def setUp(self):
|
def setUp(self):
|
||||||
super().setUp()
|
super().setUp()
|
||||||
|
|
||||||
user = User.objects.create_superuser(username="temp_admin")
|
self.temp_admin = User.objects.create_superuser(username="temp_admin")
|
||||||
self.client.force_authenticate(user=user)
|
self.client.force_authenticate(user=self.temp_admin)
|
||||||
|
|
||||||
self.t1 = Tag.objects.create(name="t1")
|
self.t1 = Tag.objects.create(name="t1")
|
||||||
self.t2 = Tag.objects.create(name="t2")
|
self.t2 = Tag.objects.create(name="t2")
|
||||||
@ -822,6 +822,79 @@ class TestBulkEditObjectPermissions(APITestCase):
|
|||||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||||
self.assertEqual(StoragePath.objects.get(pk=self.sp1.id).owner, self.user3)
|
self.assertEqual(StoragePath.objects.get(pk=self.sp1.id).owner, self.user3)
|
||||||
|
|
||||||
|
def test_bulk_object_set_permissions_merge(self):
|
||||||
|
"""
|
||||||
|
GIVEN:
|
||||||
|
- Existing objects
|
||||||
|
WHEN:
|
||||||
|
- bulk_edit_object_perms API endpoint is called with merge=True or merge=False (default)
|
||||||
|
THEN:
|
||||||
|
- Permissions and / or owner are replaced or merged, depending on the merge flag
|
||||||
|
"""
|
||||||
|
permissions = {
|
||||||
|
"view": {
|
||||||
|
"users": [self.user1.id, self.user2.id],
|
||||||
|
"groups": [],
|
||||||
|
},
|
||||||
|
"change": {
|
||||||
|
"users": [self.user1.id],
|
||||||
|
"groups": [],
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
assign_perm("view_tag", self.user3, self.t1)
|
||||||
|
self.t1.owner = self.user3
|
||||||
|
self.t1.save()
|
||||||
|
|
||||||
|
# merge=True
|
||||||
|
response = self.client.post(
|
||||||
|
"/api/bulk_edit_object_perms/",
|
||||||
|
json.dumps(
|
||||||
|
{
|
||||||
|
"objects": [self.t1.id, self.t2.id],
|
||||||
|
"object_type": "tags",
|
||||||
|
"owner": self.user1.id,
|
||||||
|
"permissions": permissions,
|
||||||
|
"merge": True,
|
||||||
|
},
|
||||||
|
),
|
||||||
|
content_type="application/json",
|
||||||
|
)
|
||||||
|
|
||||||
|
self.t1.refresh_from_db()
|
||||||
|
self.t2.refresh_from_db()
|
||||||
|
|
||||||
|
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||||
|
# user3 should still be owner of t1 since was set prior
|
||||||
|
self.assertEqual(self.t1.owner, self.user3)
|
||||||
|
# user1 should now be owner of t2 since it didn't have an owner
|
||||||
|
self.assertEqual(self.t2.owner, self.user1)
|
||||||
|
|
||||||
|
# user1 should be added
|
||||||
|
self.assertIn(self.user1, get_users_with_perms(self.t1))
|
||||||
|
# user3 should be preserved
|
||||||
|
self.assertIn(self.user3, get_users_with_perms(self.t1))
|
||||||
|
|
||||||
|
# merge=False (default)
|
||||||
|
response = self.client.post(
|
||||||
|
"/api/bulk_edit_object_perms/",
|
||||||
|
json.dumps(
|
||||||
|
{
|
||||||
|
"objects": [self.t1.id, self.t2.id],
|
||||||
|
"object_type": "tags",
|
||||||
|
"permissions": permissions,
|
||||||
|
"merge": False,
|
||||||
|
},
|
||||||
|
),
|
||||||
|
content_type="application/json",
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||||
|
# user1 should be added
|
||||||
|
self.assertIn(self.user1, get_users_with_perms(self.t1))
|
||||||
|
# user3 should be removed
|
||||||
|
self.assertNotIn(self.user3, get_users_with_perms(self.t1))
|
||||||
|
|
||||||
def test_bulk_edit_object_permissions_insufficient_perms(self):
|
def test_bulk_edit_object_permissions_insufficient_perms(self):
|
||||||
"""
|
"""
|
||||||
GIVEN:
|
GIVEN:
|
||||||
|
110
src/documents/tests/test_bulk_edit.py
Normal file
110
src/documents/tests/test_bulk_edit.py
Normal file
@ -0,0 +1,110 @@
|
|||||||
|
from unittest import mock
|
||||||
|
|
||||||
|
from django.contrib.auth.models import Group
|
||||||
|
from django.contrib.auth.models import User
|
||||||
|
from django.test import TestCase
|
||||||
|
from guardian.shortcuts import assign_perm
|
||||||
|
from guardian.shortcuts import get_groups_with_perms
|
||||||
|
from guardian.shortcuts import get_users_with_perms
|
||||||
|
|
||||||
|
from documents.bulk_edit import set_permissions
|
||||||
|
from documents.models import Document
|
||||||
|
from documents.tests.utils import DirectoriesMixin
|
||||||
|
|
||||||
|
|
||||||
|
class TestBulkEditPermissions(DirectoriesMixin, TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
super().setUp()
|
||||||
|
|
||||||
|
self.doc1 = Document.objects.create(checksum="A", title="A")
|
||||||
|
self.doc2 = Document.objects.create(checksum="B", title="B")
|
||||||
|
self.doc3 = Document.objects.create(checksum="C", title="C")
|
||||||
|
|
||||||
|
self.owner = User.objects.create(username="test_owner")
|
||||||
|
self.user1 = User.objects.create(username="user1")
|
||||||
|
self.user2 = User.objects.create(username="user2")
|
||||||
|
self.group1 = Group.objects.create(name="group1")
|
||||||
|
self.group2 = Group.objects.create(name="group2")
|
||||||
|
|
||||||
|
@mock.patch("documents.tasks.bulk_update_documents.delay")
|
||||||
|
def test_set_permissions(self, m):
|
||||||
|
doc_ids = [self.doc1.id, self.doc2.id, self.doc3.id]
|
||||||
|
|
||||||
|
assign_perm("view_document", self.group1, self.doc1)
|
||||||
|
|
||||||
|
permissions = {
|
||||||
|
"view": {
|
||||||
|
"users": [self.user1.id, self.user2.id],
|
||||||
|
"groups": [self.group2.id],
|
||||||
|
},
|
||||||
|
"change": {
|
||||||
|
"users": [self.user1.id],
|
||||||
|
"groups": [self.group2.id],
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
set_permissions(
|
||||||
|
doc_ids,
|
||||||
|
set_permissions=permissions,
|
||||||
|
owner=self.owner,
|
||||||
|
merge=False,
|
||||||
|
)
|
||||||
|
m.assert_called_once()
|
||||||
|
|
||||||
|
self.assertEqual(Document.objects.filter(owner=self.owner).count(), 3)
|
||||||
|
self.assertEqual(Document.objects.filter(id__in=doc_ids).count(), 3)
|
||||||
|
|
||||||
|
users_with_perms = get_users_with_perms(
|
||||||
|
self.doc1,
|
||||||
|
)
|
||||||
|
self.assertEqual(users_with_perms.count(), 2)
|
||||||
|
|
||||||
|
# group1 should be replaced by group2
|
||||||
|
groups_with_perms = get_groups_with_perms(
|
||||||
|
self.doc1,
|
||||||
|
)
|
||||||
|
self.assertEqual(groups_with_perms.count(), 1)
|
||||||
|
|
||||||
|
@mock.patch("documents.tasks.bulk_update_documents.delay")
|
||||||
|
def test_set_permissions_merge(self, m):
|
||||||
|
doc_ids = [self.doc1.id, self.doc2.id, self.doc3.id]
|
||||||
|
|
||||||
|
self.doc1.owner = self.user1
|
||||||
|
self.doc1.save()
|
||||||
|
|
||||||
|
assign_perm("view_document", self.user1, self.doc1)
|
||||||
|
assign_perm("view_document", self.group1, self.doc1)
|
||||||
|
|
||||||
|
permissions = {
|
||||||
|
"view": {
|
||||||
|
"users": [self.user2.id],
|
||||||
|
"groups": [self.group2.id],
|
||||||
|
},
|
||||||
|
"change": {
|
||||||
|
"users": [self.user2.id],
|
||||||
|
"groups": [self.group2.id],
|
||||||
|
},
|
||||||
|
}
|
||||||
|
set_permissions(
|
||||||
|
doc_ids,
|
||||||
|
set_permissions=permissions,
|
||||||
|
owner=self.owner,
|
||||||
|
merge=True,
|
||||||
|
)
|
||||||
|
m.assert_called_once()
|
||||||
|
|
||||||
|
# when merge is true owner doesn't get replaced if its not empty
|
||||||
|
self.assertEqual(Document.objects.filter(owner=self.owner).count(), 2)
|
||||||
|
self.assertEqual(Document.objects.filter(id__in=doc_ids).count(), 3)
|
||||||
|
|
||||||
|
# merge of user1 which was pre-existing and user2
|
||||||
|
users_with_perms = get_users_with_perms(
|
||||||
|
self.doc1,
|
||||||
|
)
|
||||||
|
self.assertEqual(users_with_perms.count(), 2)
|
||||||
|
|
||||||
|
# group1 should be merged by group2
|
||||||
|
groups_with_perms = get_groups_with_perms(
|
||||||
|
self.doc1,
|
||||||
|
)
|
||||||
|
self.assertEqual(groups_with_perms.count(), 2)
|
@ -35,6 +35,7 @@ from django.utils.translation import get_language
|
|||||||
from django.views import View
|
from django.views import View
|
||||||
from django.views.decorators.cache import cache_control
|
from django.views.decorators.cache import cache_control
|
||||||
from django.views.decorators.http import condition
|
from django.views.decorators.http import condition
|
||||||
|
from django.views.decorators.http import last_modified
|
||||||
from django.views.generic import TemplateView
|
from django.views.generic import TemplateView
|
||||||
from django_filters.rest_framework import DjangoFilterBackend
|
from django_filters.rest_framework import DjangoFilterBackend
|
||||||
from langdetect import detect
|
from langdetect import detect
|
||||||
@ -62,12 +63,21 @@ from documents import bulk_edit
|
|||||||
from documents.bulk_download import ArchiveOnlyStrategy
|
from documents.bulk_download import ArchiveOnlyStrategy
|
||||||
from documents.bulk_download import OriginalAndArchiveStrategy
|
from documents.bulk_download import OriginalAndArchiveStrategy
|
||||||
from documents.bulk_download import OriginalsOnlyStrategy
|
from documents.bulk_download import OriginalsOnlyStrategy
|
||||||
|
from documents.caching import CACHE_50_MINUTES
|
||||||
|
from documents.caching import get_metadata_cache
|
||||||
|
from documents.caching import get_suggestion_cache
|
||||||
|
from documents.caching import refresh_metadata_cache
|
||||||
|
from documents.caching import refresh_suggestions_cache
|
||||||
|
from documents.caching import set_metadata_cache
|
||||||
|
from documents.caching import set_suggestions_cache
|
||||||
from documents.classifier import load_classifier
|
from documents.classifier import load_classifier
|
||||||
from documents.conditionals import metadata_etag
|
from documents.conditionals import metadata_etag
|
||||||
from documents.conditionals import metadata_last_modified
|
from documents.conditionals import metadata_last_modified
|
||||||
from documents.conditionals import preview_etag
|
from documents.conditionals import preview_etag
|
||||||
|
from documents.conditionals import preview_last_modified
|
||||||
from documents.conditionals import suggestions_etag
|
from documents.conditionals import suggestions_etag
|
||||||
from documents.conditionals import suggestions_last_modified
|
from documents.conditionals import suggestions_last_modified
|
||||||
|
from documents.conditionals import thumbnail_last_modified
|
||||||
from documents.data_models import ConsumableDocument
|
from documents.data_models import ConsumableDocument
|
||||||
from documents.data_models import DocumentMetadataOverrides
|
from documents.data_models import DocumentMetadataOverrides
|
||||||
from documents.data_models import DocumentSource
|
from documents.data_models import DocumentSource
|
||||||
@ -379,10 +389,12 @@ class DocumentViewSet(
|
|||||||
|
|
||||||
try:
|
try:
|
||||||
return parser.extract_metadata(file, mime_type)
|
return parser.extract_metadata(file, mime_type)
|
||||||
except Exception:
|
except Exception: # pragma: no cover
|
||||||
|
logger.exception(f"Issue getting metadata for {file}")
|
||||||
# TODO: cover GPG errors, remove later.
|
# TODO: cover GPG errors, remove later.
|
||||||
return []
|
return []
|
||||||
else:
|
else: # pragma: no cover
|
||||||
|
logger.warning(f"No parser for {mime_type}")
|
||||||
return []
|
return []
|
||||||
|
|
||||||
def get_filesize(self, filename):
|
def get_filesize(self, filename):
|
||||||
@ -407,16 +419,37 @@ class DocumentViewSet(
|
|||||||
except Document.DoesNotExist:
|
except Document.DoesNotExist:
|
||||||
raise Http404
|
raise Http404
|
||||||
|
|
||||||
|
document_cached_metadata = get_metadata_cache(doc.pk)
|
||||||
|
|
||||||
|
archive_metadata = None
|
||||||
|
archive_filesize = None
|
||||||
|
if document_cached_metadata is not None:
|
||||||
|
original_metadata = document_cached_metadata.original_metadata
|
||||||
|
archive_metadata = document_cached_metadata.archive_metadata
|
||||||
|
refresh_metadata_cache(doc.pk)
|
||||||
|
else:
|
||||||
|
original_metadata = self.get_metadata(doc.source_path, doc.mime_type)
|
||||||
|
|
||||||
|
if doc.has_archive_version:
|
||||||
|
archive_filesize = self.get_filesize(doc.archive_path)
|
||||||
|
archive_metadata = self.get_metadata(
|
||||||
|
doc.archive_path,
|
||||||
|
"application/pdf",
|
||||||
|
)
|
||||||
|
set_metadata_cache(doc, original_metadata, archive_metadata)
|
||||||
|
|
||||||
meta = {
|
meta = {
|
||||||
"original_checksum": doc.checksum,
|
"original_checksum": doc.checksum,
|
||||||
"original_size": self.get_filesize(doc.source_path),
|
"original_size": self.get_filesize(doc.source_path),
|
||||||
"original_mime_type": doc.mime_type,
|
"original_mime_type": doc.mime_type,
|
||||||
"media_filename": doc.filename,
|
"media_filename": doc.filename,
|
||||||
"has_archive_version": doc.has_archive_version,
|
"has_archive_version": doc.has_archive_version,
|
||||||
"original_metadata": self.get_metadata(doc.source_path, doc.mime_type),
|
"original_metadata": original_metadata,
|
||||||
"archive_checksum": doc.archive_checksum,
|
"archive_checksum": doc.archive_checksum,
|
||||||
"archive_media_filename": doc.archive_filename,
|
"archive_media_filename": doc.archive_filename,
|
||||||
"original_filename": doc.original_filename,
|
"original_filename": doc.original_filename,
|
||||||
|
"archive_size": archive_filesize,
|
||||||
|
"archive_metadata": archive_metadata,
|
||||||
}
|
}
|
||||||
|
|
||||||
lang = "en"
|
lang = "en"
|
||||||
@ -426,16 +459,6 @@ class DocumentViewSet(
|
|||||||
pass
|
pass
|
||||||
meta["lang"] = lang
|
meta["lang"] = lang
|
||||||
|
|
||||||
if doc.has_archive_version:
|
|
||||||
meta["archive_size"] = self.get_filesize(doc.archive_path)
|
|
||||||
meta["archive_metadata"] = self.get_metadata(
|
|
||||||
doc.archive_path,
|
|
||||||
"application/pdf",
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
meta["archive_size"] = None
|
|
||||||
meta["archive_metadata"] = None
|
|
||||||
|
|
||||||
return Response(meta)
|
return Response(meta)
|
||||||
|
|
||||||
@action(methods=["get"], detail=True)
|
@action(methods=["get"], detail=True)
|
||||||
@ -454,6 +477,12 @@ class DocumentViewSet(
|
|||||||
):
|
):
|
||||||
return HttpResponseForbidden("Insufficient permissions")
|
return HttpResponseForbidden("Insufficient permissions")
|
||||||
|
|
||||||
|
document_suggestions = get_suggestion_cache(doc.pk)
|
||||||
|
|
||||||
|
if document_suggestions is not None:
|
||||||
|
refresh_suggestions_cache(doc.pk)
|
||||||
|
return Response(document_suggestions.suggestions)
|
||||||
|
|
||||||
classifier = load_classifier()
|
classifier = load_classifier()
|
||||||
|
|
||||||
dates = []
|
dates = []
|
||||||
@ -463,27 +492,30 @@ class DocumentViewSet(
|
|||||||
{i for i in itertools.islice(gen, settings.NUMBER_OF_SUGGESTED_DATES)},
|
{i for i in itertools.islice(gen, settings.NUMBER_OF_SUGGESTED_DATES)},
|
||||||
)
|
)
|
||||||
|
|
||||||
return Response(
|
resp_data = {
|
||||||
{
|
"correspondents": [
|
||||||
"correspondents": [
|
c.id for c in match_correspondents(doc, classifier, request.user)
|
||||||
c.id for c in match_correspondents(doc, classifier, request.user)
|
],
|
||||||
],
|
"tags": [t.id for t in match_tags(doc, classifier, request.user)],
|
||||||
"tags": [t.id for t in match_tags(doc, classifier, request.user)],
|
"document_types": [
|
||||||
"document_types": [
|
dt.id for dt in match_document_types(doc, classifier, request.user)
|
||||||
dt.id for dt in match_document_types(doc, classifier, request.user)
|
],
|
||||||
],
|
"storage_paths": [
|
||||||
"storage_paths": [
|
dt.id for dt in match_storage_paths(doc, classifier, request.user)
|
||||||
dt.id for dt in match_storage_paths(doc, classifier, request.user)
|
],
|
||||||
],
|
"dates": [date.strftime("%Y-%m-%d") for date in dates if date is not None],
|
||||||
"dates": [
|
}
|
||||||
date.strftime("%Y-%m-%d") for date in dates if date is not None
|
|
||||||
],
|
# Cache the suggestions and the classifier hash for later
|
||||||
},
|
set_suggestions_cache(doc.pk, resp_data, classifier)
|
||||||
)
|
|
||||||
|
return Response(resp_data)
|
||||||
|
|
||||||
@action(methods=["get"], detail=True)
|
@action(methods=["get"], detail=True)
|
||||||
@method_decorator(cache_control(public=False, max_age=5 * 60))
|
@method_decorator(cache_control(public=False, max_age=5 * 60))
|
||||||
@method_decorator(condition(etag_func=preview_etag))
|
@method_decorator(
|
||||||
|
condition(etag_func=preview_etag, last_modified_func=preview_last_modified),
|
||||||
|
)
|
||||||
def preview(self, request, pk=None):
|
def preview(self, request, pk=None):
|
||||||
try:
|
try:
|
||||||
response = self.file_response(pk, request, "inline")
|
response = self.file_response(pk, request, "inline")
|
||||||
@ -492,7 +524,8 @@ class DocumentViewSet(
|
|||||||
raise Http404
|
raise Http404
|
||||||
|
|
||||||
@action(methods=["get"], detail=True)
|
@action(methods=["get"], detail=True)
|
||||||
@method_decorator(cache_control(public=False, max_age=315360000))
|
@method_decorator(cache_control(public=False, max_age=CACHE_50_MINUTES))
|
||||||
|
@method_decorator(last_modified(thumbnail_last_modified))
|
||||||
def thumb(self, request, pk=None):
|
def thumb(self, request, pk=None):
|
||||||
try:
|
try:
|
||||||
doc = Document.objects.get(id=pk)
|
doc = Document.objects.get(id=pk)
|
||||||
@ -506,8 +539,6 @@ class DocumentViewSet(
|
|||||||
handle = GnuPG.decrypted(doc.thumbnail_file)
|
handle = GnuPG.decrypted(doc.thumbnail_file)
|
||||||
else:
|
else:
|
||||||
handle = doc.thumbnail_file
|
handle = doc.thumbnail_file
|
||||||
# TODO: Send ETag information and use that to send new thumbnails
|
|
||||||
# if available
|
|
||||||
|
|
||||||
return HttpResponse(handle, content_type="image/webp")
|
return HttpResponse(handle, content_type="image/webp")
|
||||||
except (FileNotFoundError, Document.DoesNotExist):
|
except (FileNotFoundError, Document.DoesNotExist):
|
||||||
@ -1385,6 +1416,7 @@ class BulkEditObjectPermissionsView(GenericAPIView, PassUserMixin):
|
|||||||
object_class = serializer.get_object_class(object_type)
|
object_class = serializer.get_object_class(object_type)
|
||||||
permissions = serializer.validated_data.get("permissions")
|
permissions = serializer.validated_data.get("permissions")
|
||||||
owner = serializer.validated_data.get("owner")
|
owner = serializer.validated_data.get("owner")
|
||||||
|
merge = serializer.validated_data.get("merge")
|
||||||
|
|
||||||
if not user.is_superuser:
|
if not user.is_superuser:
|
||||||
objs = object_class.objects.filter(pk__in=object_ids)
|
objs = object_class.objects.filter(pk__in=object_ids)
|
||||||
@ -1396,12 +1428,21 @@ class BulkEditObjectPermissionsView(GenericAPIView, PassUserMixin):
|
|||||||
try:
|
try:
|
||||||
qs = object_class.objects.filter(id__in=object_ids)
|
qs = object_class.objects.filter(id__in=object_ids)
|
||||||
|
|
||||||
if "owner" in serializer.validated_data:
|
# if merge is true, we dont want to remove the owner
|
||||||
qs.update(owner=owner)
|
if "owner" in serializer.validated_data and (
|
||||||
|
not merge or (merge and owner is not None)
|
||||||
|
):
|
||||||
|
# if merge is true, we dont want to overwrite the owner
|
||||||
|
qs_owner_update = qs.filter(owner__isnull=True) if merge else qs
|
||||||
|
qs_owner_update.update(owner=owner)
|
||||||
|
|
||||||
if "permissions" in serializer.validated_data:
|
if "permissions" in serializer.validated_data:
|
||||||
for obj in qs:
|
for obj in qs:
|
||||||
set_permissions_for_object(permissions, obj)
|
set_permissions_for_object(
|
||||||
|
permissions=permissions,
|
||||||
|
object=obj,
|
||||||
|
merge=merge,
|
||||||
|
)
|
||||||
|
|
||||||
return Response({"result": "OK"})
|
return Response({"result": "OK"})
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
@ -3,7 +3,7 @@ msgstr ""
|
|||||||
"Project-Id-Version: paperless-ngx\n"
|
"Project-Id-Version: paperless-ngx\n"
|
||||||
"Report-Msgid-Bugs-To: \n"
|
"Report-Msgid-Bugs-To: \n"
|
||||||
"POT-Creation-Date: 2024-01-05 21:26-0800\n"
|
"POT-Creation-Date: 2024-01-05 21:26-0800\n"
|
||||||
"PO-Revision-Date: 2024-01-25 22:18\n"
|
"PO-Revision-Date: 2024-01-27 00:22\n"
|
||||||
"Last-Translator: \n"
|
"Last-Translator: \n"
|
||||||
"Language-Team: Arabic\n"
|
"Language-Team: Arabic\n"
|
||||||
"Language: ar_SA\n"
|
"Language: ar_SA\n"
|
||||||
@ -931,7 +931,7 @@ msgstr "لا ورقي"
|
|||||||
|
|
||||||
#: paperless/models.py:25
|
#: paperless/models.py:25
|
||||||
msgid "pdf"
|
msgid "pdf"
|
||||||
msgstr ""
|
msgstr "pdf"
|
||||||
|
|
||||||
#: paperless/models.py:26
|
#: paperless/models.py:26
|
||||||
msgid "pdfa"
|
msgid "pdfa"
|
||||||
@ -951,15 +951,15 @@ msgstr ""
|
|||||||
|
|
||||||
#: paperless/models.py:38
|
#: paperless/models.py:38
|
||||||
msgid "skip"
|
msgid "skip"
|
||||||
msgstr ""
|
msgstr "تخطي"
|
||||||
|
|
||||||
#: paperless/models.py:39
|
#: paperless/models.py:39
|
||||||
msgid "redo"
|
msgid "redo"
|
||||||
msgstr ""
|
msgstr "إعادة"
|
||||||
|
|
||||||
#: paperless/models.py:40
|
#: paperless/models.py:40
|
||||||
msgid "force"
|
msgid "force"
|
||||||
msgstr ""
|
msgstr "إجبار"
|
||||||
|
|
||||||
#: paperless/models.py:41
|
#: paperless/models.py:41
|
||||||
msgid "skip_noarchive"
|
msgid "skip_noarchive"
|
||||||
@ -967,7 +967,7 @@ msgstr ""
|
|||||||
|
|
||||||
#: paperless/models.py:49
|
#: paperless/models.py:49
|
||||||
msgid "never"
|
msgid "never"
|
||||||
msgstr ""
|
msgstr "أبداً"
|
||||||
|
|
||||||
#: paperless/models.py:50
|
#: paperless/models.py:50
|
||||||
msgid "with_text"
|
msgid "with_text"
|
||||||
@ -975,7 +975,7 @@ msgstr ""
|
|||||||
|
|
||||||
#: paperless/models.py:51
|
#: paperless/models.py:51
|
||||||
msgid "always"
|
msgid "always"
|
||||||
msgstr ""
|
msgstr "دائماً"
|
||||||
|
|
||||||
#: paperless/models.py:59
|
#: paperless/models.py:59
|
||||||
msgid "clean"
|
msgid "clean"
|
||||||
@ -987,7 +987,7 @@ msgstr ""
|
|||||||
|
|
||||||
#: paperless/models.py:61
|
#: paperless/models.py:61
|
||||||
msgid "none"
|
msgid "none"
|
||||||
msgstr ""
|
msgstr "لا شيء"
|
||||||
|
|
||||||
#: paperless/models.py:69
|
#: paperless/models.py:69
|
||||||
msgid "LeaveColorUnchanged"
|
msgid "LeaveColorUnchanged"
|
||||||
@ -995,7 +995,7 @@ msgstr ""
|
|||||||
|
|
||||||
#: paperless/models.py:70
|
#: paperless/models.py:70
|
||||||
msgid "RGB"
|
msgid "RGB"
|
||||||
msgstr ""
|
msgstr "RGB"
|
||||||
|
|
||||||
#: paperless/models.py:71
|
#: paperless/models.py:71
|
||||||
msgid "UseDeviceIndependentColor"
|
msgid "UseDeviceIndependentColor"
|
||||||
@ -1003,15 +1003,15 @@ msgstr ""
|
|||||||
|
|
||||||
#: paperless/models.py:72
|
#: paperless/models.py:72
|
||||||
msgid "Gray"
|
msgid "Gray"
|
||||||
msgstr ""
|
msgstr "رمادي"
|
||||||
|
|
||||||
#: paperless/models.py:73
|
#: paperless/models.py:73
|
||||||
msgid "CMYK"
|
msgid "CMYK"
|
||||||
msgstr ""
|
msgstr "CMYK"
|
||||||
|
|
||||||
#: paperless/models.py:82
|
#: paperless/models.py:82
|
||||||
msgid "Sets the output PDF type"
|
msgid "Sets the output PDF type"
|
||||||
msgstr ""
|
msgstr "تعيين نوع إخراج PDF"
|
||||||
|
|
||||||
#: paperless/models.py:94
|
#: paperless/models.py:94
|
||||||
msgid "Do OCR from page 1 to this value"
|
msgid "Do OCR from page 1 to this value"
|
||||||
|
@ -3,7 +3,7 @@ msgstr ""
|
|||||||
"Project-Id-Version: paperless-ngx\n"
|
"Project-Id-Version: paperless-ngx\n"
|
||||||
"Report-Msgid-Bugs-To: \n"
|
"Report-Msgid-Bugs-To: \n"
|
||||||
"POT-Creation-Date: 2024-01-05 21:26-0800\n"
|
"POT-Creation-Date: 2024-01-05 21:26-0800\n"
|
||||||
"PO-Revision-Date: 2024-01-19 12:09\n"
|
"PO-Revision-Date: 2024-01-27 00:22\n"
|
||||||
"Last-Translator: \n"
|
"Last-Translator: \n"
|
||||||
"Language-Team: German\n"
|
"Language-Team: German\n"
|
||||||
"Language: de_DE\n"
|
"Language: de_DE\n"
|
||||||
@ -601,7 +601,7 @@ msgstr "Benutzerdefiniertes Feld"
|
|||||||
|
|
||||||
#: documents/models.py:782
|
#: documents/models.py:782
|
||||||
msgid "custom fields"
|
msgid "custom fields"
|
||||||
msgstr "Benutzerdefinierte Felder"
|
msgstr "Benutzerdef. Felder"
|
||||||
|
|
||||||
#: documents/models.py:844
|
#: documents/models.py:844
|
||||||
msgid "custom field instance"
|
msgid "custom field instance"
|
||||||
|
@ -3,7 +3,7 @@ msgstr ""
|
|||||||
"Project-Id-Version: paperless-ngx\n"
|
"Project-Id-Version: paperless-ngx\n"
|
||||||
"Report-Msgid-Bugs-To: \n"
|
"Report-Msgid-Bugs-To: \n"
|
||||||
"POT-Creation-Date: 2024-01-05 21:26-0800\n"
|
"POT-Creation-Date: 2024-01-05 21:26-0800\n"
|
||||||
"PO-Revision-Date: 2024-01-22 12:09\n"
|
"PO-Revision-Date: 2024-01-27 12:08\n"
|
||||||
"Last-Translator: \n"
|
"Last-Translator: \n"
|
||||||
"Language-Team: Greek\n"
|
"Language-Team: Greek\n"
|
||||||
"Language: el_GR\n"
|
"Language: el_GR\n"
|
||||||
@ -425,11 +425,11 @@ msgstr "δεν έχει ιδιοκτήτη σε"
|
|||||||
|
|
||||||
#: documents/models.py:458
|
#: documents/models.py:458
|
||||||
msgid "has custom field value"
|
msgid "has custom field value"
|
||||||
msgstr ""
|
msgstr "έχει προσαρμοσμένη τιμή πεδίου"
|
||||||
|
|
||||||
#: documents/models.py:459
|
#: documents/models.py:459
|
||||||
msgid "is shared by me"
|
msgid "is shared by me"
|
||||||
msgstr ""
|
msgstr "μοιράζεται από μένα"
|
||||||
|
|
||||||
#: documents/models.py:469
|
#: documents/models.py:469
|
||||||
msgid "rule type"
|
msgid "rule type"
|
||||||
@ -613,7 +613,7 @@ msgstr "στιγμιότυπα προσαρμοσμένων πεδίων"
|
|||||||
|
|
||||||
#: documents/models.py:902
|
#: documents/models.py:902
|
||||||
msgid "Consumption Started"
|
msgid "Consumption Started"
|
||||||
msgstr ""
|
msgstr "Η Κατανάλωση Ξεκίνησε"
|
||||||
|
|
||||||
#: documents/models.py:903
|
#: documents/models.py:903
|
||||||
msgid "Document Added"
|
msgid "Document Added"
|
||||||
@ -753,7 +753,7 @@ msgstr ""
|
|||||||
|
|
||||||
#: documents/models.py:1114
|
#: documents/models.py:1114
|
||||||
msgid "actions"
|
msgid "actions"
|
||||||
msgstr ""
|
msgstr "ενέργειες"
|
||||||
|
|
||||||
#: documents/models.py:1117
|
#: documents/models.py:1117
|
||||||
msgid "enabled"
|
msgid "enabled"
|
||||||
@ -939,15 +939,15 @@ msgstr "pdfa"
|
|||||||
|
|
||||||
#: paperless/models.py:27
|
#: paperless/models.py:27
|
||||||
msgid "pdfa-1"
|
msgid "pdfa-1"
|
||||||
msgstr ""
|
msgstr "pdfa-1"
|
||||||
|
|
||||||
#: paperless/models.py:28
|
#: paperless/models.py:28
|
||||||
msgid "pdfa-2"
|
msgid "pdfa-2"
|
||||||
msgstr ""
|
msgstr "pdfa-2"
|
||||||
|
|
||||||
#: paperless/models.py:29
|
#: paperless/models.py:29
|
||||||
msgid "pdfa-3"
|
msgid "pdfa-3"
|
||||||
msgstr ""
|
msgstr "pdfa-3"
|
||||||
|
|
||||||
#: paperless/models.py:38
|
#: paperless/models.py:38
|
||||||
msgid "skip"
|
msgid "skip"
|
||||||
@ -967,7 +967,7 @@ msgstr ""
|
|||||||
|
|
||||||
#: paperless/models.py:49
|
#: paperless/models.py:49
|
||||||
msgid "never"
|
msgid "never"
|
||||||
msgstr ""
|
msgstr "ποτέ"
|
||||||
|
|
||||||
#: paperless/models.py:50
|
#: paperless/models.py:50
|
||||||
msgid "with_text"
|
msgid "with_text"
|
||||||
|
File diff suppressed because it is too large
Load Diff
@ -3,7 +3,7 @@ msgstr ""
|
|||||||
"Project-Id-Version: paperless-ngx\n"
|
"Project-Id-Version: paperless-ngx\n"
|
||||||
"Report-Msgid-Bugs-To: \n"
|
"Report-Msgid-Bugs-To: \n"
|
||||||
"POT-Creation-Date: 2024-01-05 21:26-0800\n"
|
"POT-Creation-Date: 2024-01-05 21:26-0800\n"
|
||||||
"PO-Revision-Date: 2024-01-25 00:24\n"
|
"PO-Revision-Date: 2024-01-28 00:24\n"
|
||||||
"Last-Translator: \n"
|
"Last-Translator: \n"
|
||||||
"Language-Team: French\n"
|
"Language-Team: French\n"
|
||||||
"Language: fr_FR\n"
|
"Language: fr_FR\n"
|
||||||
|
@ -47,3 +47,11 @@ class HttpRemoteUserMiddleware(PersistentRemoteUserMiddleware):
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
header = settings.HTTP_REMOTE_USER_HEADER_NAME
|
header = settings.HTTP_REMOTE_USER_HEADER_NAME
|
||||||
|
|
||||||
|
|
||||||
|
class PaperlessRemoteUserAuthentication(authentication.RemoteUserAuthentication):
|
||||||
|
"""
|
||||||
|
REMOTE_USER authentication for DRF which overrides the default header.
|
||||||
|
"""
|
||||||
|
|
||||||
|
header = settings.HTTP_REMOTE_USER_HEADER_NAME
|
||||||
|
@ -420,19 +420,34 @@ if AUTO_LOGIN_USERNAME:
|
|||||||
# regular login in case the provided user does not exist.
|
# regular login in case the provided user does not exist.
|
||||||
MIDDLEWARE.insert(_index + 1, "paperless.auth.AutoLoginMiddleware")
|
MIDDLEWARE.insert(_index + 1, "paperless.auth.AutoLoginMiddleware")
|
||||||
|
|
||||||
ENABLE_HTTP_REMOTE_USER = __get_boolean("PAPERLESS_ENABLE_HTTP_REMOTE_USER")
|
|
||||||
HTTP_REMOTE_USER_HEADER_NAME = os.getenv(
|
|
||||||
"PAPERLESS_HTTP_REMOTE_USER_HEADER_NAME",
|
|
||||||
"HTTP_REMOTE_USER",
|
|
||||||
)
|
|
||||||
|
|
||||||
if ENABLE_HTTP_REMOTE_USER:
|
def _parse_remote_user_settings() -> str:
|
||||||
MIDDLEWARE.append("paperless.auth.HttpRemoteUserMiddleware")
|
global MIDDLEWARE, AUTHENTICATION_BACKENDS, REST_FRAMEWORK
|
||||||
AUTHENTICATION_BACKENDS.insert(0, "django.contrib.auth.backends.RemoteUserBackend")
|
enable = __get_boolean("PAPERLESS_ENABLE_HTTP_REMOTE_USER")
|
||||||
REST_FRAMEWORK["DEFAULT_AUTHENTICATION_CLASSES"].append(
|
enable_api = __get_boolean("PAPERLESS_ENABLE_HTTP_REMOTE_USER_API")
|
||||||
"rest_framework.authentication.RemoteUserAuthentication",
|
if enable or enable_api:
|
||||||
|
MIDDLEWARE.append("paperless.auth.HttpRemoteUserMiddleware")
|
||||||
|
AUTHENTICATION_BACKENDS.insert(
|
||||||
|
0,
|
||||||
|
"django.contrib.auth.backends.RemoteUserBackend",
|
||||||
|
)
|
||||||
|
|
||||||
|
if enable_api:
|
||||||
|
REST_FRAMEWORK["DEFAULT_AUTHENTICATION_CLASSES"].insert(
|
||||||
|
0,
|
||||||
|
"paperless.auth.PaperlessRemoteUserAuthentication",
|
||||||
|
)
|
||||||
|
|
||||||
|
header_name = os.getenv(
|
||||||
|
"PAPERLESS_HTTP_REMOTE_USER_HEADER_NAME",
|
||||||
|
"HTTP_REMOTE_USER",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
return header_name
|
||||||
|
|
||||||
|
|
||||||
|
HTTP_REMOTE_USER_HEADER_NAME = _parse_remote_user_settings()
|
||||||
|
|
||||||
# X-Frame options for embedded PDF display:
|
# X-Frame options for embedded PDF display:
|
||||||
X_FRAME_OPTIONS = "ANY" if DEBUG else "SAMEORIGIN"
|
X_FRAME_OPTIONS = "ANY" if DEBUG else "SAMEORIGIN"
|
||||||
|
|
||||||
@ -615,6 +630,7 @@ LANGUAGES = [
|
|||||||
("fr-fr", _("French")),
|
("fr-fr", _("French")),
|
||||||
("hu-hu", _("Hungarian")),
|
("hu-hu", _("Hungarian")),
|
||||||
("it-it", _("Italian")),
|
("it-it", _("Italian")),
|
||||||
|
("ja-jp", _("Japanese")),
|
||||||
("lb-lu", _("Luxembourgish")),
|
("lb-lu", _("Luxembourgish")),
|
||||||
("no-no", _("Norwegian")),
|
("no-no", _("Norwegian")),
|
||||||
("nl-nl", _("Dutch")),
|
("nl-nl", _("Dutch")),
|
||||||
@ -746,8 +762,12 @@ CELERY_BEAT_SCHEDULE_FILENAME = os.path.join(DATA_DIR, "celerybeat-schedule.db")
|
|||||||
# django setting.
|
# django setting.
|
||||||
CACHES = {
|
CACHES = {
|
||||||
"default": {
|
"default": {
|
||||||
"BACKEND": "django.core.cache.backends.redis.RedisCache",
|
"BACKEND": os.environ.get(
|
||||||
|
"PAPERLESS_CACHE_BACKEND",
|
||||||
|
"django.core.cache.backends.redis.RedisCache",
|
||||||
|
),
|
||||||
"LOCATION": _CHANNELS_REDIS_URL,
|
"LOCATION": _CHANNELS_REDIS_URL,
|
||||||
|
"KEY_PREFIX": os.getenv("PAPERLESS_REDIS_PREFIX", ""),
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
110
src/paperless/tests/test_remote_user.py
Normal file
110
src/paperless/tests/test_remote_user.py
Normal file
@ -0,0 +1,110 @@
|
|||||||
|
import os
|
||||||
|
from unittest import mock
|
||||||
|
|
||||||
|
from django.contrib.auth.models import User
|
||||||
|
from rest_framework import status
|
||||||
|
from rest_framework.test import APITestCase
|
||||||
|
|
||||||
|
from documents.tests.utils import DirectoriesMixin
|
||||||
|
from paperless.settings import _parse_remote_user_settings
|
||||||
|
|
||||||
|
|
||||||
|
class TestRemoteUser(DirectoriesMixin, APITestCase):
|
||||||
|
def setUp(self):
|
||||||
|
super().setUp()
|
||||||
|
|
||||||
|
self.user = User.objects.create_superuser(
|
||||||
|
username="temp_admin",
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_remote_user(self):
|
||||||
|
"""
|
||||||
|
GIVEN:
|
||||||
|
- Configured user
|
||||||
|
- Remote user auth is enabled
|
||||||
|
WHEN:
|
||||||
|
- Call is made to root
|
||||||
|
THEN:
|
||||||
|
- Call succeeds
|
||||||
|
"""
|
||||||
|
|
||||||
|
with mock.patch.dict(
|
||||||
|
os.environ,
|
||||||
|
{
|
||||||
|
"PAPERLESS_ENABLE_HTTP_REMOTE_USER": "True",
|
||||||
|
},
|
||||||
|
):
|
||||||
|
_parse_remote_user_settings()
|
||||||
|
|
||||||
|
response = self.client.get("/documents/")
|
||||||
|
|
||||||
|
self.assertEqual(
|
||||||
|
response.status_code,
|
||||||
|
status.HTTP_302_FOUND,
|
||||||
|
)
|
||||||
|
|
||||||
|
response = self.client.get(
|
||||||
|
"/documents/",
|
||||||
|
headers={
|
||||||
|
"Remote-User": self.user.username,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||||
|
|
||||||
|
def test_remote_user_api(self):
|
||||||
|
"""
|
||||||
|
GIVEN:
|
||||||
|
- Configured user
|
||||||
|
- Remote user auth is enabled for the API
|
||||||
|
WHEN:
|
||||||
|
- API call is made to get documents
|
||||||
|
THEN:
|
||||||
|
- Call succeeds
|
||||||
|
"""
|
||||||
|
|
||||||
|
with mock.patch.dict(
|
||||||
|
os.environ,
|
||||||
|
{
|
||||||
|
"PAPERLESS_ENABLE_HTTP_REMOTE_USER_API": "True",
|
||||||
|
},
|
||||||
|
):
|
||||||
|
_parse_remote_user_settings()
|
||||||
|
|
||||||
|
response = self.client.get("/api/documents/")
|
||||||
|
|
||||||
|
# 403 testing locally, 401 on ci...
|
||||||
|
self.assertIn(
|
||||||
|
response.status_code,
|
||||||
|
[status.HTTP_401_UNAUTHORIZED, status.HTTP_403_FORBIDDEN],
|
||||||
|
)
|
||||||
|
|
||||||
|
response = self.client.get(
|
||||||
|
"/api/documents/",
|
||||||
|
headers={
|
||||||
|
"Remote-User": self.user.username,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||||
|
|
||||||
|
def test_remote_user_header_setting(self):
|
||||||
|
"""
|
||||||
|
GIVEN:
|
||||||
|
- Remote user header name is set
|
||||||
|
WHEN:
|
||||||
|
- Settings are parsed
|
||||||
|
THEN:
|
||||||
|
- Correct header name is returned
|
||||||
|
"""
|
||||||
|
|
||||||
|
with mock.patch.dict(
|
||||||
|
os.environ,
|
||||||
|
{
|
||||||
|
"PAPERLESS_ENABLE_HTTP_REMOTE_USER": "True",
|
||||||
|
"PAPERLESS_HTTP_REMOTE_USER_HEADER_NAME": "HTTP_FOO",
|
||||||
|
},
|
||||||
|
):
|
||||||
|
header_name = _parse_remote_user_settings()
|
||||||
|
|
||||||
|
self.assertEqual(header_name, "HTTP_FOO")
|
@ -1,6 +1,6 @@
|
|||||||
from typing import Final
|
from typing import Final
|
||||||
|
|
||||||
__version__: Final[tuple[int, int, int]] = (2, 4, 2)
|
__version__: Final[tuple[int, int, int]] = (2, 4, 3)
|
||||||
# Version string like X.Y.Z
|
# Version string like X.Y.Z
|
||||||
__full_version_str__: Final[str] = ".".join(map(str, __version__))
|
__full_version_str__: Final[str] = ".".join(map(str, __version__))
|
||||||
# Version string like X.Y
|
# Version string like X.Y
|
||||||
|
@ -3,6 +3,7 @@ DJANGO_SETTINGS_MODULE = paperless.settings
|
|||||||
addopts = --pythonwarnings=all --cov --cov-report=html --cov-report=xml --numprocesses auto --maxprocesses=16 --quiet --durations=50
|
addopts = --pythonwarnings=all --cov --cov-report=html --cov-report=xml --numprocesses auto --maxprocesses=16 --quiet --durations=50
|
||||||
env =
|
env =
|
||||||
PAPERLESS_DISABLE_DBHANDLER=true
|
PAPERLESS_DISABLE_DBHANDLER=true
|
||||||
|
PAPERLESS_CACHE_BACKEND=django.core.cache.backends.locmem.LocMemCache
|
||||||
|
|
||||||
[coverage:run]
|
[coverage:run]
|
||||||
source =
|
source =
|
||||||
|
Loading…
x
Reference in New Issue
Block a user