Merge branch 'dev' into dev

This commit is contained in:
Trenton H 2024-02-05 09:32:30 -08:00 committed by GitHub
commit 3c86ad5140
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
79 changed files with 3967 additions and 3396 deletions

View File

@ -184,7 +184,7 @@ jobs:
cache-dependency-path: 'src-ui/package-lock.json'
- name: Cache frontend dependencies
id: cache-frontend-deps
uses: actions/cache@v3
uses: actions/cache@v4
with:
path: |
~/.npm
@ -221,7 +221,7 @@ jobs:
cache-dependency-path: 'src-ui/package-lock.json'
- name: Cache frontend dependencies
id: cache-frontend-deps
uses: actions/cache@v3
uses: actions/cache@v4
with:
path: |
~/.npm
@ -283,7 +283,7 @@ jobs:
merge-multiple: true
-
name: Upload frontend coverage to Codecov
uses: codecov/codecov-action@v3
uses: codecov/codecov-action@v4
with:
# not required for public repos, but intermittently fails otherwise
token: ${{ secrets.CODECOV_TOKEN }}
@ -299,7 +299,7 @@ jobs:
path: src/
-
name: Upload coverage to Codecov
uses: codecov/codecov-action@v3
uses: codecov/codecov-action@v4
with:
# not required for public repos, but intermittently fails otherwise
token: ${{ secrets.CODECOV_TOKEN }}

View File

@ -3,6 +3,7 @@
[![Documentation Status](https://img.shields.io/github/deployments/paperless-ngx/paperless-ngx/github-pages?label=docs)](https://docs.paperless-ngx.com)
[![codecov](https://codecov.io/gh/paperless-ngx/paperless-ngx/branch/main/graph/badge.svg?token=VK6OUPJ3TY)](https://codecov.io/gh/paperless-ngx/paperless-ngx)
[![Chat on Matrix](https://matrix.to/img/matrix-badge.svg)](https://matrix.to/#/%23paperlessngx%3Amatrix.org)
[![demo](https://cronitor.io/badges/ve7ItY/production/W5E_B9jkelG9ZbDiNHUPQEVH3MY.svg)](https://demo.paperless-ngx.com)
<p align="center">
<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)
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)
- [Getting started](#getting-started)
- [Contributing](#contributing)
@ -30,6 +33,16 @@ Paperless-ngx is the official successor to the original [Paperless](https://gith
- [Affiliated Projects](#affiliated-projects)
- [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
<picture>

View File

@ -1,14 +1,15 @@
#!/usr/bin/env bash
SUPERVISORD_WORKING_DIR="${PAPERLESS_SUPERVISORD_WORKING_DIR:-$PWD}"
rootless_args=()
if [ "$(id -u)" == "$(id -u paperless)" ]; then
rootless_args=(
--user
paperless
--logfile
supervisord.log
"${SUPERVISORD_WORKING_DIR}/supervisord.log"
--pidfile
supervisord.pid
"${SUPERVISORD_WORKING_DIR}/supervisord.pid"
)
fi

View File

@ -517,6 +517,18 @@ existing tables) with:
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).
### 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}
Paperless is able to utilize barcodes for automatically performing some tasks.

View File

@ -139,7 +139,7 @@ document. Paperless only reports PDF metadata at this point.
## Authorization
The REST api provides three different forms of authentication.
The REST api provides four different forms of 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.
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
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
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.
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`
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
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
depending on the brightness of `Tag.color`.
- 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.

View File

@ -1,5 +1,15 @@
# 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
### Bug Fixes

View File

@ -34,6 +34,8 @@ matcher.
`redis://<username>:<password>@<host>:<port>`
- With the requirepass option PAPERLESS_REDIS =
`redis://:<password>@<host>:<port>`
- To include the redis database index PAPERLESS_REDIS =
`redis://<username>:<password>@<host>:<port>/<DBIndex>`
[More information on securing your Redis
Instance](https://redis.io/docs/getting-started/#securing-redis).
@ -462,9 +464,21 @@ applications.
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}
: 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
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).
@ -1378,6 +1392,12 @@ started by the container.
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
#### [`PAPERLESS_APP_TITLE=<bool>`](#PAPERLESS_APP_TITLE) {#PAPERLESS_APP_TITLE}

View File

@ -6,6 +6,14 @@
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 }
[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 class="grid-right" markdown>

View File

@ -73,4 +73,6 @@ extra:
link: https://matrix.to/#/#paperless:matrix.org
plugins:
- search
- glightbox
- glightbox:
skip_classes:
- no-lightbox

View File

@ -31,6 +31,7 @@
"fr-FR": "src/locale/messages.fr_FR.xlf",
"hu-HU": "src/locale/messages.hu_HU.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",
"nl-NL": "src/locale/messages.nl_NL.xlf",
"no-NO": "src/locale/messages.no_NO.xlf",

View File

@ -2700,7 +2700,7 @@
"bodySize": -1
},
"response": {
"status": -1,
"status": 200,
"statusText": "",
"httpVersion": "HTTP/1.1",
"cookies": [],
@ -2734,7 +2734,7 @@
"bodySize": -1
},
"response": {
"status": -1,
"status": 200,
"statusText": "",
"httpVersion": "HTTP/1.1",
"cookies": [],
@ -2768,7 +2768,7 @@
"bodySize": -1
},
"response": {
"status": -1,
"status": 200,
"statusText": "",
"httpVersion": "HTTP/1.1",
"cookies": [],
@ -2802,7 +2802,7 @@
"bodySize": -1
},
"response": {
"status": -1,
"status": 200,
"statusText": "",
"httpVersion": "HTTP/1.1",
"cookies": [],
@ -2836,7 +2836,7 @@
"bodySize": -1
},
"response": {
"status": -1,
"status": 200,
"statusText": "",
"httpVersion": "HTTP/1.1",
"cookies": [],
@ -2870,7 +2870,7 @@
"bodySize": -1
},
"response": {
"status": -1,
"status": 200,
"statusText": "",
"httpVersion": "HTTP/1.1",
"cookies": [],
@ -2904,7 +2904,7 @@
"bodySize": -1
},
"response": {
"status": -1,
"status": 200,
"statusText": "",
"httpVersion": "HTTP/1.1",
"cookies": [],
@ -2938,7 +2938,7 @@
"bodySize": -1
},
"response": {
"status": -1,
"status": 200,
"statusText": "",
"httpVersion": "HTTP/1.1",
"cookies": [],
@ -2972,7 +2972,7 @@
"bodySize": -1
},
"response": {
"status": -1,
"status": 200,
"statusText": "",
"httpVersion": "HTTP/1.1",
"cookies": [],
@ -3006,7 +3006,7 @@
"bodySize": -1
},
"response": {
"status": -1,
"status": 200,
"statusText": "",
"httpVersion": "HTTP/1.1",
"cookies": [],
@ -3040,7 +3040,7 @@
"bodySize": -1
},
"response": {
"status": -1,
"status": 200,
"statusText": "",
"httpVersion": "HTTP/1.1",
"cookies": [],
@ -3074,7 +3074,7 @@
"bodySize": -1
},
"response": {
"status": -1,
"status": 200,
"statusText": "",
"httpVersion": "HTTP/1.1",
"cookies": [],
@ -3108,7 +3108,7 @@
"bodySize": -1
},
"response": {
"status": -1,
"status": 200,
"statusText": "",
"httpVersion": "HTTP/1.1",
"cookies": [],
@ -3142,7 +3142,7 @@
"bodySize": -1
},
"response": {
"status": -1,
"status": 200,
"statusText": "",
"httpVersion": "HTTP/1.1",
"cookies": [],
@ -3176,7 +3176,7 @@
"bodySize": -1
},
"response": {
"status": -1,
"status": 200,
"statusText": "",
"httpVersion": "HTTP/1.1",
"cookies": [],
@ -3210,7 +3210,7 @@
"bodySize": -1
},
"response": {
"status": -1,
"status": 200,
"statusText": "",
"httpVersion": "HTTP/1.1",
"cookies": [],
@ -3244,7 +3244,7 @@
"bodySize": -1
},
"response": {
"status": -1,
"status": 200,
"statusText": "",
"httpVersion": "HTTP/1.1",
"cookies": [],
@ -3278,7 +3278,7 @@
"bodySize": -1
},
"response": {
"status": -1,
"status": 200,
"statusText": "",
"httpVersion": "HTTP/1.1",
"cookies": [],
@ -3312,7 +3312,7 @@
"bodySize": -1
},
"response": {
"status": -1,
"status": 200,
"statusText": "",
"httpVersion": "HTTP/1.1",
"cookies": [],

View File

@ -425,7 +425,7 @@
"bodySize": -1
},
"response": {
"status": -1,
"status": 200,
"statusText": "",
"httpVersion": "HTTP/1.1",
"cookies": [],
@ -470,7 +470,7 @@
"bodySize": -1
},
"response": {
"status": -1,
"status": 200,
"statusText": "",
"httpVersion": "HTTP/1.1",
"cookies": [],
@ -645,7 +645,7 @@
"bodySize": -1
},
"response": {
"status": -1,
"status": 200,
"statusText": "",
"httpVersion": "HTTP/1.1",
"cookies": [],
@ -685,7 +685,7 @@
"bodySize": -1
},
"response": {
"status": -1,
"status": 200,
"statusText": "",
"httpVersion": "HTTP/1.1",
"cookies": [],
@ -729,7 +729,7 @@
"bodySize": -1
},
"response": {
"status": -1,
"status": 200,
"statusText": "",
"httpVersion": "HTTP/1.1",
"cookies": [],

View File

@ -843,7 +843,7 @@
"bodySize": -1
},
"response": {
"status": -1,
"status": 200,
"statusText": "",
"httpVersion": "HTTP/1.1",
"cookies": [],
@ -994,7 +994,7 @@
"bodySize": -1
},
"response": {
"status": -1,
"status": 200,
"statusText": "",
"httpVersion": "HTTP/1.1",
"cookies": [],

View File

@ -996,7 +996,7 @@
"bodySize": -1
},
"response": {
"status": -1,
"status": 200,
"statusText": "",
"httpVersion": "HTTP/1.1",
"cookies": [],
@ -1301,7 +1301,7 @@
"bodySize": -1
},
"response": {
"status": -1,
"status": 200,
"statusText": "",
"httpVersion": "HTTP/1.1",
"cookies": [],
@ -1484,7 +1484,7 @@
"bodySize": -1
},
"response": {
"status": -1,
"status": 200,
"statusText": "",
"httpVersion": "HTTP/1.1",
"cookies": [],
@ -1518,7 +1518,7 @@
"bodySize": -1
},
"response": {
"status": -1,
"status": 200,
"statusText": "",
"httpVersion": "HTTP/1.1",
"cookies": [],
@ -1552,7 +1552,7 @@
"bodySize": -1
},
"response": {
"status": -1,
"status": 200,
"statusText": "",
"httpVersion": "HTTP/1.1",
"cookies": [],
@ -1586,7 +1586,7 @@
"bodySize": -1
},
"response": {
"status": -1,
"status": 200,
"statusText": "",
"httpVersion": "HTTP/1.1",
"cookies": [],
@ -1620,7 +1620,7 @@
"bodySize": -1
},
"response": {
"status": -1,
"status": 200,
"statusText": "",
"httpVersion": "HTTP/1.1",
"cookies": [],
@ -1654,7 +1654,7 @@
"bodySize": -1
},
"response": {
"status": -1,
"status": 200,
"statusText": "",
"httpVersion": "HTTP/1.1",
"cookies": [],
@ -1688,7 +1688,7 @@
"bodySize": -1
},
"response": {
"status": -1,
"status": 200,
"statusText": "",
"httpVersion": "HTTP/1.1",
"cookies": [],
@ -1722,7 +1722,7 @@
"bodySize": -1
},
"response": {
"status": -1,
"status": 200,
"statusText": "",
"httpVersion": "HTTP/1.1",
"cookies": [],
@ -1756,7 +1756,7 @@
"bodySize": -1
},
"response": {
"status": -1,
"status": 200,
"statusText": "",
"httpVersion": "HTTP/1.1",
"cookies": [],
@ -1790,7 +1790,7 @@
"bodySize": -1
},
"response": {
"status": -1,
"status": 200,
"statusText": "",
"httpVersion": "HTTP/1.1",
"cookies": [],
@ -1824,7 +1824,7 @@
"bodySize": -1
},
"response": {
"status": -1,
"status": 200,
"statusText": "",
"httpVersion": "HTTP/1.1",
"cookies": [],
@ -1858,7 +1858,7 @@
"bodySize": -1
},
"response": {
"status": -1,
"status": 200,
"statusText": "",
"httpVersion": "HTTP/1.1",
"cookies": [],
@ -1892,7 +1892,7 @@
"bodySize": -1
},
"response": {
"status": -1,
"status": 200,
"statusText": "",
"httpVersion": "HTTP/1.1",
"cookies": [],
@ -1926,7 +1926,7 @@
"bodySize": -1
},
"response": {
"status": -1,
"status": 200,
"statusText": "",
"httpVersion": "HTTP/1.1",
"cookies": [],
@ -1960,7 +1960,7 @@
"bodySize": -1
},
"response": {
"status": -1,
"status": 200,
"statusText": "",
"httpVersion": "HTTP/1.1",
"cookies": [],
@ -1994,7 +1994,7 @@
"bodySize": -1
},
"response": {
"status": -1,
"status": 200,
"statusText": "",
"httpVersion": "HTTP/1.1",
"cookies": [],
@ -2028,7 +2028,7 @@
"bodySize": -1
},
"response": {
"status": -1,
"status": 200,
"statusText": "",
"httpVersion": "HTTP/1.1",
"cookies": [],
@ -2062,7 +2062,7 @@
"bodySize": -1
},
"response": {
"status": -1,
"status": 200,
"statusText": "",
"httpVersion": "HTTP/1.1",
"cookies": [],
@ -2096,7 +2096,7 @@
"bodySize": -1
},
"response": {
"status": -1,
"status": 200,
"statusText": "",
"httpVersion": "HTTP/1.1",
"cookies": [],
@ -2130,7 +2130,7 @@
"bodySize": -1
},
"response": {
"status": -1,
"status": 200,
"statusText": "",
"httpVersion": "HTTP/1.1",
"cookies": [],
@ -2164,7 +2164,7 @@
"bodySize": -1
},
"response": {
"status": -1,
"status": 200,
"statusText": "",
"httpVersion": "HTTP/1.1",
"cookies": [],
@ -2198,7 +2198,7 @@
"bodySize": -1
},
"response": {
"status": -1,
"status": 200,
"statusText": "",
"httpVersion": "HTTP/1.1",
"cookies": [],
@ -2232,7 +2232,7 @@
"bodySize": -1
},
"response": {
"status": -1,
"status": 200,
"statusText": "",
"httpVersion": "HTTP/1.1",
"cookies": [],
@ -2266,7 +2266,7 @@
"bodySize": -1
},
"response": {
"status": -1,
"status": 200,
"statusText": "",
"httpVersion": "HTTP/1.1",
"cookies": [],
@ -2300,7 +2300,7 @@
"bodySize": -1
},
"response": {
"status": -1,
"status": 200,
"statusText": "",
"httpVersion": "HTTP/1.1",
"cookies": [],
@ -2334,7 +2334,7 @@
"bodySize": -1
},
"response": {
"status": -1,
"status": 200,
"statusText": "",
"httpVersion": "HTTP/1.1",
"cookies": [],
@ -2368,7 +2368,7 @@
"bodySize": -1
},
"response": {
"status": -1,
"status": 200,
"statusText": "",
"httpVersion": "HTTP/1.1",
"cookies": [],
@ -2402,7 +2402,7 @@
"bodySize": -1
},
"response": {
"status": -1,
"status": 200,
"statusText": "",
"httpVersion": "HTTP/1.1",
"cookies": [],
@ -2436,7 +2436,7 @@
"bodySize": -1
},
"response": {
"status": -1,
"status": 200,
"statusText": "",
"httpVersion": "HTTP/1.1",
"cookies": [],
@ -2470,7 +2470,7 @@
"bodySize": -1
},
"response": {
"status": -1,
"status": 200,
"statusText": "",
"httpVersion": "HTTP/1.1",
"cookies": [],

File diff suppressed because it is too large Load Diff

4036
src-ui/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -11,17 +11,17 @@
},
"private": true,
"dependencies": {
"@angular/cdk": "^17.0.4",
"@angular/common": "~17.0.8",
"@angular/compiler": "~17.0.8",
"@angular/core": "~17.0.8",
"@angular/forms": "~17.0.8",
"@angular/localize": "~17.0.8",
"@angular/platform-browser": "~17.0.8",
"@angular/platform-browser-dynamic": "~17.0.8",
"@angular/router": "~17.0.8",
"@angular/cdk": "^17.1.2",
"@angular/common": "~17.1.2",
"@angular/compiler": "~17.1.2",
"@angular/core": "~17.1.2",
"@angular/forms": "~17.1.2",
"@angular/localize": "~17.1.2",
"@angular/platform-browser": "~17.1.2",
"@angular/platform-browser-dynamic": "~17.1.2",
"@angular/router": "~17.1.2",
"@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",
"@popperjs/core": "^2.11.8",
"bootstrap": "^5.3.2",
@ -31,33 +31,33 @@
"ngx-color": "^9.0.0",
"ngx-cookie-service": "^17.0.1",
"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",
"rxjs": "^7.8.1",
"tslib": "^2.6.2",
"uuid": "^9.0.1",
"zone.js": "^0.14.2"
"zone.js": "^0.14.3"
},
"devDependencies": {
"@angular-builders/jest": "17.0.0",
"@angular-devkit/build-angular": "~17.0.8",
"@angular-eslint/builder": "17.1.1",
"@angular-eslint/eslint-plugin": "17.1.1",
"@angular-eslint/eslint-plugin-template": "17.1.1",
"@angular-eslint/schematics": "17.1.1",
"@angular-eslint/template-parser": "17.1.1",
"@angular/cli": "~17.0.8",
"@angular/compiler-cli": "~17.0.7",
"@playwright/test": "^1.40.1",
"@types/jest": "^29.5.10",
"@types/node": "^20.10.6",
"@typescript-eslint/eslint-plugin": "^6.17.0",
"@typescript-eslint/parser": "^6.17.0",
"@angular-devkit/build-angular": "~17.1.2",
"@angular-eslint/builder": "17.2.1",
"@angular-eslint/eslint-plugin": "17.2.1",
"@angular-eslint/eslint-plugin-template": "17.2.1",
"@angular-eslint/schematics": "17.2.1",
"@angular-eslint/template-parser": "17.2.1",
"@angular/cli": "~17.1.2",
"@angular/compiler-cli": "~17.1.2",
"@playwright/test": "^1.41.2",
"@types/jest": "^29.5.12",
"@types/node": "^20.11.16",
"@typescript-eslint/eslint-plugin": "^6.20.0",
"@typescript-eslint/parser": "^6.20.0",
"concurrently": "^8.2.2",
"eslint": "^8.56.0",
"jest": "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",
"patch-package": "^8.0.0",
"ts-node": "~10.9.1",

View File

@ -23,6 +23,7 @@ import localeFi from '@angular/common/locales/fi'
import localeFr from '@angular/common/locales/fr'
import localeHu from '@angular/common/locales/hu'
import localeIt from '@angular/common/locales/it'
import localeJa from '@angular/common/locales/ja'
import localeLb from '@angular/common/locales/lb'
import localeNl from '@angular/common/locales/nl'
import localeNo from '@angular/common/locales/no'
@ -53,6 +54,7 @@ registerLocaleData(localeFi)
registerLocaleData(localeFr)
registerLocaleData(localeHu)
registerLocaleData(localeIt)
registerLocaleData(localeJa)
registerLocaleData(localeLb)
registerLocaleData(localeNl)
registerLocaleData(localeNo)

View File

@ -295,6 +295,7 @@ import localeFi from '@angular/common/locales/fi'
import localeFr from '@angular/common/locales/fr'
import localeHu from '@angular/common/locales/hu'
import localeIt from '@angular/common/locales/it'
import localeJa from '@angular/common/locales/ja'
import localeLb from '@angular/common/locales/lb'
import localeNl from '@angular/common/locales/nl'
import localeNo from '@angular/common/locales/no'
@ -325,6 +326,7 @@ registerLocaleData(localeFi)
registerLocaleData(localeFr)
registerLocaleData(localeHu)
registerLocaleData(localeIt)
registerLocaleData(localeJa)
registerLocaleData(localeLb)
registerLocaleData(localeNl)
registerLocaleData(localeNo)

View File

@ -158,6 +158,14 @@
</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>
<div class="row mb-3">

View File

@ -289,7 +289,7 @@ describe('SettingsComponent', () => {
expect(toastErrorSpy).toHaveBeenCalled()
expect(storeSpy).toHaveBeenCalled()
expect(appearanceSettingsSpy).not.toHaveBeenCalled()
expect(setSpy).toHaveBeenCalledTimes(24)
expect(setSpy).toHaveBeenCalledTimes(25)
// succeed
storeSpy.mockReturnValueOnce(of(true))

View File

@ -88,6 +88,7 @@ export class SettingsComponent
defaultPermsViewGroups: new FormControl(null),
defaultPermsEditUsers: new FormControl(null),
defaultPermsEditGroups: new FormControl(null),
documentEditingRemoveInboxTags: new FormControl(null),
notificationsConsumerNewDocument: new FormControl(null),
notificationsConsumerSuccess: new FormControl(null),
@ -271,6 +272,9 @@ export class SettingsComponent
defaultPermsEditGroups: this.settings.get(
SETTINGS_KEYS.DEFAULT_PERMS_EDIT_GROUPS
),
documentEditingRemoveInboxTags: this.settings.get(
SETTINGS_KEYS.DOCUMENT_EDITING_REMOVE_INBOX_TAGS
),
savedViews: {},
}
}
@ -484,6 +488,10 @@ export class SettingsComponent
SETTINGS_KEYS.DEFAULT_PERMS_EDIT_GROUPS,
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
.storeSettings()

View File

@ -47,22 +47,25 @@ describe('NumberComponent', () => {
expect(component.value).toEqual(1002)
})
it('should support float & monetary values', () => {
component.writeValue(11.13)
expect(component.value).toEqual(11)
it('should support float, monetary values & scientific notation', () => {
const mockFn = jest.fn()
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.writeValue(11.1)
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)
})
})

View File

@ -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 {
if (this.step === 1 && newValue?.toString().indexOf('e') === -1)
newValue = parseInt(newValue, 10)
// Allow monetary values to be displayed with 2 decimals
if (this.step === 0.01) newValue = parseFloat(newValue).toFixed(2)
super.writeValue(newValue)
}

View File

@ -15,7 +15,7 @@
}
</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">
<input #inputField type="checkbox" class="form-check-input" [id]="inputId" [(ngModel)]="value" (change)="onChange(value)" (blur)="onTouched()" [disabled]="disabled">
@if (horizontal) {

View File

@ -5,12 +5,15 @@
</div>
<div class="modal-body">
@if (!object && message) {
<p class="mb-3" [innerHTML]="message | safeHtml"></p>
}
<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>
</div>
@ -20,5 +23,5 @@
<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-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>

View File

@ -11,6 +11,7 @@ import { NgSelectModule } from '@ng-select/ng-select'
import { FormsModule, ReactiveFormsModule } from '@angular/forms'
import { PermissionsUserComponent } from '../input/permissions/permissions-user/permissions-user.component'
import { PermissionsGroupComponent } from '../input/permissions/permissions-group/permissions-group.component'
import { SwitchComponent } from '../input/switch/switch.component'
const set_permissions = {
owner: 10,
@ -37,6 +38,7 @@ describe('PermissionsDialogComponent', () => {
PermissionsDialogComponent,
SafeHtmlPipe,
SelectComponent,
SwitchComponent,
PermissionsFormComponent,
PermissionsUserComponent,
PermissionsGroupComponent,
@ -112,4 +114,23 @@ describe('PermissionsDialogComponent', () => {
expect(component.title).toEqual(`Edit permissions for ${obj.name}`)
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,
})
})
})

View File

@ -32,6 +32,7 @@ export class PermissionsDialogComponent {
this.o = o
this.title = $localize`Edit permissions for ` + o['name']
this.form.patchValue({
merge: true,
permissions_form: {
owner: o.owner,
set_permissions: o.permissions,
@ -43,8 +44,9 @@ export class PermissionsDialogComponent {
return this.o
}
form = new FormGroup({
public form = new FormGroup({
permissions_form: new FormControl(),
merge: new FormControl(true),
})
buttonsEnabled: boolean = true
@ -66,11 +68,21 @@ export class PermissionsDialogComponent {
}
}
@Input()
message =
$localize`Note that permissions set here will override any existing permissions`
get hint(): string {
if (this.object) return null
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() {
this.activeModal.close()
}
confirm() {
this.confirmClicked.emit({
permissions: this.permissions,
merge: this.form.get('merge').value,
})
}
}

View File

@ -62,22 +62,24 @@
<i-bs width="1em" height="1em" name="check"></i-bs>
}
</div>
<div class="me-1 w-100">
<ng-select
name="user"
class="user-select small"
[(ngModel)]="selectionModel.includeUsers"
[disabled]="disabled"
[clearable]="false"
[items]="users"
bindLabel="username"
multiple="true"
bindValue="id"
placeholder="Users"
i18n-placeholder
(change)="onUserSelect()">
</ng-select>
</div>
@if (permissionsService.currentUserCan(PermissionAction.View, PermissionType.User)) {
<div class="me-1 w-100">
<ng-select
name="user"
class="user-select small"
[(ngModel)]="selectionModel.includeUsers"
[disabled]="disabled"
[clearable]="false"
[items]="users"
bindLabel="username"
multiple="true"
bindValue="id"
placeholder="Users"
i18n-placeholder
(change)="onUserSelect()">
</ng-select>
</div>
}
</button>
@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">

View File

@ -67,7 +67,7 @@ export class PermissionsFilterDropdownComponent extends ComponentWithPermissions
}
constructor(
permissionsService: PermissionsService,
public permissionsService: PermissionsService,
userService: UserService,
private settingsService: SettingsService
) {

View File

@ -15,8 +15,14 @@
<tr>
<th scope="col" i18n>Created</th>
<th scope="col" i18n>Title</th>
<th scope="col" class="d-none d-md-table-cell" i18n>Tags</th>
<th scope="col" class="d-none d-md-table-cell" i18n>Correspondent</th>
@if (permissionsService.currentUserCan(PermissionAction.View, PermissionType.Tag)) {
<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>
</thead>
<tbody>
@ -26,13 +32,15 @@
<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>
</td>
<td class="py-2 py-md-3 d-none d-md-table-cell">
@for (t of doc.tags$ | async; track t) {
<pngx-tag [tag]="t" class="ms-1" (click)="clickTag(t, $event)"></pngx-tag>
}
</td>
@if (permissionsService.currentUserCan(PermissionAction.View, PermissionType.Tag)) {
<td class="py-2 py-md-3 d-none d-md-table-cell">
@for (t of doc.tags$ | async; track t) {
<pngx-tag [tag]="t" class="ms-1" (click)="clickTag(t, $event)"></pngx-tag>
}
</td>
}
<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>
}
<div class="btn-group position-absolute top-50 end-0 translate-middle-y">

View File

@ -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 { NgbPopover } from '@ng-bootstrap/ng-bootstrap'
import { queryParamsFromFilterRules } from 'src/app/utils/query-params'
import { PermissionsService } from 'src/app/services/permissions.service'
@Component({
selector: 'pngx-saved-view-widget',
@ -40,7 +41,8 @@ export class SavedViewWidgetComponent
private list: DocumentListViewService,
private consumerStatusService: ConsumerStatusService,
public openDocumentsService: OpenDocumentsService,
public documentListViewService: DocumentListViewService
public documentListViewService: DocumentListViewService,
public permissionsService: PermissionsService
) {
super()
}

View File

@ -1,5 +1,8 @@
import { DatePipe } from '@angular/common'
import { HttpClientTestingModule } from '@angular/common/http/testing'
import {
HttpClientTestingModule,
HttpTestingController,
} from '@angular/common/http/testing'
import {
ComponentFixture,
TestBed,
@ -71,6 +74,7 @@ import { CustomFieldDataType } from 'src/app/data/custom-field'
import { CustomFieldsService } from 'src/app/services/rest/custom-fields.service'
import { PdfViewerComponent } from '../common/pdf-viewer/pdf-viewer.component'
import { NgxBootstrapIconsModule, allIcons } from 'ngx-bootstrap-icons'
import { environment } from 'src/environments/environment'
const doc: Document = {
id: 3,
@ -136,6 +140,7 @@ describe('DocumentDetailComponent', () => {
let documentListViewService: DocumentListViewService
let settingsService: SettingsService
let customFieldsService: CustomFieldsService
let httpTestingController: HttpTestingController
let currentUserCan = true
let currentUserHasObjectPermissions = true
@ -266,6 +271,7 @@ describe('DocumentDetailComponent', () => {
settingsService.currentUser = { id: 1 }
customFieldsService = TestBed.inject(CustomFieldsService)
fixture = TestBed.createComponent(DocumentDetailComponent)
httpTestingController = TestBed.inject(HttpTestingController)
component = fixture.componentInstance
})
@ -350,6 +356,26 @@ describe('DocumentDetailComponent', () => {
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', () => {
initNormally()
let openModal: NgbModalRef

View File

@ -250,25 +250,50 @@ export class DocumentDetailComponent
Object.assign(this.document, docValues)
})
this.correspondentService
.listAll()
.pipe(first(), takeUntil(this.unsubscribeNotifier))
.subscribe((result) => (this.correspondents = result.results))
this.documentTypeService
.listAll()
.pipe(first(), takeUntil(this.unsubscribeNotifier))
.subscribe((result) => (this.documentTypes = result.results))
this.storagePathService
.listAll()
.pipe(first(), takeUntil(this.unsubscribeNotifier))
.subscribe((result) => (this.storagePaths = result.results))
this.userService
.listAll()
.pipe(first(), takeUntil(this.unsubscribeNotifier))
.subscribe((result) => (this.users = result.results))
if (
this.permissionsService.currentUserCan(
PermissionAction.View,
PermissionType.Correspondent
)
) {
this.correspondentService
.listAll()
.pipe(first(), takeUntil(this.unsubscribeNotifier))
.subscribe((result) => (this.correspondents = result.results))
}
if (
this.permissionsService.currentUserCan(
PermissionAction.View,
PermissionType.DocumentType
)
) {
this.documentTypeService
.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()
@ -605,7 +630,9 @@ export class DocumentDetailComponent
.update(this.document)
.pipe(first())
.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.toastService.showInfo($localize`Document saved successfully.`)
close && this.close()

View File

@ -17,51 +17,59 @@
</div>
<div class="d-flex align-items-center gap-2" *pngxIfPermissions="{ action: PermissionAction.Change, type: PermissionType.Document }">
<label class="me-2" i18n>Edit:</label>
<pngx-filterable-dropdown title="Tags" icon="tag-fill" i18n-title
filterPlaceholder="Filter tags" i18n-filterPlaceholder
[items]="tags"
[disabled]="!userCanEditAll"
[editing]="true"
[manyToOne]="true"
[applyOnClose]="applyOnClose"
(opened)="openTagsDropdown()"
[(selectionModel)]="tagSelectionModel"
[documentCounts]="tagDocumentCounts"
(apply)="setTags($event)">
</pngx-filterable-dropdown>
<pngx-filterable-dropdown title="Correspondent" icon="person-fill" i18n-title
filterPlaceholder="Filter correspondents" i18n-filterPlaceholder
[items]="correspondents"
[disabled]="!userCanEditAll"
[editing]="true"
[applyOnClose]="applyOnClose"
(opened)="openCorrespondentDropdown()"
[(selectionModel)]="correspondentSelectionModel"
[documentCounts]="correspondentDocumentCounts"
(apply)="setCorrespondents($event)">
</pngx-filterable-dropdown>
<pngx-filterable-dropdown title="Document type" icon="file-earmark-fill" i18n-title
filterPlaceholder="Filter document types" i18n-filterPlaceholder
[items]="documentTypes"
[disabled]="!userCanEditAll"
[editing]="true"
[applyOnClose]="applyOnClose"
(opened)="openDocumentTypeDropdown()"
[(selectionModel)]="documentTypeSelectionModel"
[documentCounts]="documentTypeDocumentCounts"
(apply)="setDocumentTypes($event)">
</pngx-filterable-dropdown>
<pngx-filterable-dropdown title="Storage path" icon="folder-fill" i18n-title
filterPlaceholder="Filter storage paths" i18n-filterPlaceholder
[items]="storagePaths"
[disabled]="!userCanEditAll"
[editing]="true"
[applyOnClose]="applyOnClose"
(opened)="openStoragePathDropdown()"
[(selectionModel)]="storagePathsSelectionModel"
[documentCounts]="storagePathDocumentCounts"
(apply)="setStoragePaths($event)">
</pngx-filterable-dropdown>
@if (permissionService.currentUserCan(PermissionAction.View, PermissionType.Tag)) {
<pngx-filterable-dropdown title="Tags" icon="tag-fill" i18n-title
filterPlaceholder="Filter tags" i18n-filterPlaceholder
[items]="tags"
[disabled]="!userCanEditAll"
[editing]="true"
[manyToOne]="true"
[applyOnClose]="applyOnClose"
(opened)="openTagsDropdown()"
[(selectionModel)]="tagSelectionModel"
[documentCounts]="tagDocumentCounts"
(apply)="setTags($event)">
</pngx-filterable-dropdown>
}
@if (permissionService.currentUserCan(PermissionAction.View, PermissionType.Correspondent)) {
<pngx-filterable-dropdown title="Correspondent" icon="person-fill" i18n-title
filterPlaceholder="Filter correspondents" i18n-filterPlaceholder
[items]="correspondents"
[disabled]="!userCanEditAll"
[editing]="true"
[applyOnClose]="applyOnClose"
(opened)="openCorrespondentDropdown()"
[(selectionModel)]="correspondentSelectionModel"
[documentCounts]="correspondentDocumentCounts"
(apply)="setCorrespondents($event)">
</pngx-filterable-dropdown>
}
@if (permissionService.currentUserCan(PermissionAction.View, PermissionType.DocumentType)) {
<pngx-filterable-dropdown title="Document type" icon="file-earmark-fill" i18n-title
filterPlaceholder="Filter document types" i18n-filterPlaceholder
[items]="documentTypes"
[disabled]="!userCanEditAll"
[editing]="true"
[applyOnClose]="applyOnClose"
(opened)="openDocumentTypeDropdown()"
[(selectionModel)]="documentTypeSelectionModel"
[documentCounts]="documentTypeDocumentCounts"
(apply)="setDocumentTypes($event)">
</pngx-filterable-dropdown>
}
@if (permissionService.currentUserCan(PermissionAction.View, PermissionType.StoragePath)) {
<pngx-filterable-dropdown title="Storage path" icon="folder-fill" i18n-title
filterPlaceholder="Filter storage paths" i18n-filterPlaceholder
[items]="storagePaths"
[disabled]="!userCanEditAll"
[editing]="true"
[applyOnClose]="applyOnClose"
(opened)="openStoragePathDropdown()"
[(selectionModel)]="storagePathsSelectionModel"
[documentCounts]="storagePathDocumentCounts"
(apply)="setStoragePaths($event)">
</pngx-filterable-dropdown>
}
</div>
<div class="d-flex align-items-center gap-2 ms-auto">
<div class="btn-toolbar">

View File

@ -41,6 +41,7 @@ import { PermissionsUserComponent } from '../../common/input/permissions/permiss
import { NgSelectModule } from '@ng-select/ng-select'
import { GroupService } from 'src/app/services/rest/group.service'
import { NgxBootstrapIconsModule, allIcons } from 'ngx-bootstrap-icons'
import { SwitchComponent } from '../../common/input/switch/switch.component'
const selectionData: SelectionData = {
selected_tags: [
@ -81,6 +82,7 @@ describe('BulkEditorComponent', () => {
SelectComponent,
PermissionsGroupComponent,
PermissionsUserComponent,
SwitchComponent,
],
providers: [
PermissionsService,
@ -851,7 +853,18 @@ describe('BulkEditorComponent', () => {
fixture.detectChanges()
component.setPermissions()
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(
`${environment.apiBaseUrl}documents/bulk_edit/`
)
@ -859,7 +872,10 @@ describe('BulkEditorComponent', () => {
expect(req.request.body).toEqual({
documents: [3, 4],
method: 'set_permissions',
parameters: undefined,
parameters: {
permissions: perms.permissions,
merge: true,
},
})
httpTestingController.match(
`${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`
) // 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/`
)
})
})

View File

@ -115,22 +115,50 @@ export class BulkEditorComponent
}
ngOnInit() {
this.tagService
.listAll()
.pipe(first())
.subscribe((result) => (this.tags = result.results))
this.correspondentService
.listAll()
.pipe(first())
.subscribe((result) => (this.correspondents = result.results))
this.documentTypeService
.listAll()
.pipe(first())
.subscribe((result) => (this.documentTypes = result.results))
this.storagePathService
.listAll()
.pipe(first())
.subscribe((result) => (this.storagePaths = result.results))
if (
this.permissionService.currentUserCan(
PermissionAction.View,
PermissionType.Tag
)
) {
this.tagService
.listAll()
.pipe(first())
.subscribe((result) => (this.tags = result.results))
}
if (
this.permissionService.currentUserCan(
PermissionAction.View,
PermissionType.Correspondent
)
) {
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
.get('downloadFileTypeArchive')
@ -512,9 +540,14 @@ export class BulkEditorComponent
let modal = this.modalService.open(PermissionsDialogComponent, {
backdrop: 'static',
})
modal.componentInstance.confirmClicked.subscribe((permissions) => {
modal.componentInstance.buttonsEnabled = false
this.executeBulkOperation(modal, 'set_permissions', permissions)
})
modal.componentInstance.confirmClicked.subscribe(
({ permissions, merge }) => {
modal.componentInstance.buttonsEnabled = false
this.executeBulkOperation(modal, 'set_permissions', {
...permissions,
merge,
})
}
)
}
}

View File

@ -79,7 +79,7 @@ export class DocumentCardSmallComponent extends ComponentWithPermissions {
getTagsLimited$() {
const limit = this.document.notes.length > 0 ? 6 : 7
return this.document.tags$.pipe(
return this.document.tags$?.pipe(
map((tags) => {
if (tags.length > limit) {
this.moreTags = tags.length - (limit - 1)

View File

@ -18,7 +18,7 @@
</select>
}
@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>
</button>
}
@ -29,7 +29,8 @@
<div class="col-auto">
<div class="d-flex flex-wrap gap-3">
<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
[items]="tags"
[manyToOne]="true"
@ -37,31 +38,38 @@
(selectionModelChange)="updateRules()"
(opened)="onTagsDropdownOpen()"
[documentCounts]="tagDocumentCounts"
[allowSelectNone]="true"></pngx-filterable-dropdown>
<pngx-filterable-dropdown class="flex-fill" title="Correspondent" icon="person-fill" i18n-title
[allowSelectNone]="true"></pngx-filterable-dropdown>
}
@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
[items]="correspondents"
[(selectionModel)]="correspondentSelectionModel"
(selectionModelChange)="updateRules()"
(opened)="onCorrespondentDropdownOpen()"
[documentCounts]="correspondentDocumentCounts"
[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
[items]="documentTypes"
[(selectionModel)]="documentTypeSelectionModel"
(selectionModelChange)="updateRules()"
(opened)="onDocumentTypeDropdownOpen()"
[documentCounts]="documentTypeDocumentCounts"
[allowSelectNone]="true"></pngx-filterable-dropdown>
<pngx-filterable-dropdown class="flex-fill" title="Storage path" icon="folder-fill" i18n-title
[allowSelectNone]="true"></pngx-filterable-dropdown>
}
@if (permissionsService.currentUserCan(PermissionAction.View, PermissionType.DocumentType)) {
<pngx-filterable-dropdown class="flex-fill" title="Document type" icon="file-earmark-fill" i18n-title
filterPlaceholder="Filter document types" i18n-filterPlaceholder
[items]="documentTypes"
[(selectionModel)]="documentTypeSelectionModel"
(selectionModelChange)="updateRules()"
(opened)="onDocumentTypeDropdownOpen()"
[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
[items]="storagePaths"
[(selectionModel)]="storagePathSelectionModel"
(selectionModelChange)="updateRules()"
(opened)="onStoragePathDropdownOpen()"
[documentCounts]="storagePathDocumentCounts"
[allowSelectNone]="true"></pngx-filterable-dropdown>
[allowSelectNone]="true"></pngx-filterable-dropdown>
}
</div>
<div class="d-flex flex-wrap gap-2">
<pngx-date-dropdown

View File

@ -1,5 +1,8 @@
import { DatePipe } from '@angular/common'
import { HttpClientTestingModule } from '@angular/common/http/testing'
import {
HttpClientTestingModule,
HttpTestingController,
} from '@angular/common/http/testing'
import {
ComponentFixture,
fakeAsync,
@ -78,6 +81,11 @@ import {
} from '../../common/permissions-filter-dropdown/permissions-filter-dropdown.component'
import { FilterEditorComponent } from './filter-editor.component'
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[] = [
{
@ -135,6 +143,8 @@ describe('FilterEditorComponent', () => {
let fixture: ComponentFixture<FilterEditorComponent>
let documentService: DocumentService
let settingsService: SettingsService
let permissionsService: PermissionsService
let httpTestingController: HttpTestingController
beforeEach(fakeAsync(() => {
TestBed.configureTestingModule({
@ -199,6 +209,15 @@ describe('FilterEditorComponent', () => {
documentService = TestBed.inject(DocumentService)
settingsService = TestBed.inject(SettingsService)
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)
component = fixture.componentInstance
component.filterRules = []
@ -206,6 +225,24 @@ describe('FilterEditorComponent', () => {
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
it('should ingest text filter rules for doc title', fakeAsync(() => {

View File

@ -70,6 +70,12 @@ import {
OwnerFilterType,
PermissionsSelectionModel,
} 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_CONTENT = 'title-content'
@ -155,7 +161,10 @@ const DEFAULT_TEXT_FILTER_MODIFIER_OPTIONS = [
templateUrl: './filter-editor.component.html',
styleUrls: ['./filter-editor.component.scss'],
})
export class FilterEditorComponent implements OnInit, OnDestroy {
export class FilterEditorComponent
extends ComponentWithPermissions
implements OnInit, OnDestroy
{
generateFilterName() {
if (this.filterRules.length == 1) {
let rule = this.filterRules[0]
@ -224,8 +233,11 @@ export class FilterEditorComponent implements OnInit, OnDestroy {
private tagService: TagService,
private correspondentService: CorrespondentService,
private documentService: DocumentService,
private storagePathService: StoragePathService
) {}
private storagePathService: StoragePathService,
public permissionsService: PermissionsService
) {
super()
}
@ViewChild('textFilterInput')
textFilterInput: ElementRef
@ -872,18 +884,46 @@ export class FilterEditorComponent implements OnInit, OnDestroy {
subscription: Subscription
ngOnInit() {
this.tagService
.listAll()
.subscribe((result) => (this.tags = result.results))
this.correspondentService
.listAll()
.subscribe((result) => (this.correspondents = result.results))
this.documentTypeService
.listAll()
.subscribe((result) => (this.documentTypes = result.results))
this.storagePathService
.listAll()
.subscribe((result) => (this.storagePaths = result.results))
if (
this.permissionsService.currentUserCan(
PermissionAction.View,
PermissionType.Tag
)
) {
this.tagService
.listAll()
.subscribe((result) => (this.tags = result.results))
}
if (
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>()

View File

@ -41,6 +41,7 @@ import { TagsComponent } from '../../common/input/tags/tags.component'
import { FormsModule, ReactiveFormsModule } from '@angular/forms'
import { EditDialogMode } from '../../common/edit-dialog/edit-dialog.component'
import { NgxBootstrapIconsModule, allIcons } from 'ngx-bootstrap-icons'
import { SwitchComponent } from '../../common/input/switch/switch.component'
const mailAccounts = [
{ id: 1, name: 'account1' },
@ -82,6 +83,7 @@ describe('MailComponent', () => {
PermissionsGroupComponent,
PermissionsDialogComponent,
PermissionsFormComponent,
SwitchComponent,
],
providers: [CustomDatePipe, DatePipe, PermissionsGuard],
imports: [
@ -267,11 +269,11 @@ describe('MailComponent', () => {
rulePatchSpy.mockReturnValueOnce(
throwError(() => new Error('error saving perms'))
)
dialog.confirmClicked.emit(perms)
dialog.confirmClicked.emit({ permissions: perms, merge: true })
expect(rulePatchSpy).toHaveBeenCalled()
expect(toastErrorSpy).toHaveBeenCalled()
rulePatchSpy.mockReturnValueOnce(of(mailRules[0] as MailRule))
dialog.confirmClicked.emit(perms)
dialog.confirmClicked.emit({ permissions: perms, merge: true })
expect(toastInfoSpy).toHaveBeenCalledWith('Permissions updated')
modalService.dismissAll()
@ -299,8 +301,7 @@ describe('MailComponent', () => {
expect(modal).not.toBeUndefined()
let dialog = modal.componentInstance as PermissionsDialogComponent
expect(dialog.object).toEqual(mailAccounts[0])
dialog = modal.componentInstance as PermissionsDialogComponent
dialog.confirmClicked.emit(perms)
dialog.confirmClicked.emit({ permissions: perms, merge: true })
expect(accountPatchSpy).toHaveBeenCalled()
})
})

View File

@ -200,22 +200,27 @@ export class MailComponent
const dialog: PermissionsDialogComponent =
modal.componentInstance as PermissionsDialogComponent
dialog.object = object
modal.componentInstance.confirmClicked.subscribe((permissions) => {
modal.componentInstance.buttonsEnabled = false
const service: AbstractPaperlessService<MailRule | MailAccount> =
'account' in object ? this.mailRuleService : this.mailAccountService
object.owner = permissions['owner']
object['set_permissions'] = permissions['set_permissions']
service.patch(object).subscribe({
next: () => {
this.toastService.showInfo($localize`Permissions updated`)
modal.close()
},
error: (e) => {
this.toastService.showError($localize`Error updating permissions`, e)
},
})
})
modal.componentInstance.confirmClicked.subscribe(
({ permissions, merge }) => {
modal.componentInstance.buttonsEnabled = false
const service: AbstractPaperlessService<MailRule | MailAccount> =
'account' in object ? this.mailRuleService : this.mailAccountService
object.owner = permissions['owner']
object['set_permissions'] = permissions['set_permissions']
service.patch(object).subscribe({
next: () => {
this.toastService.showInfo($localize`Permissions updated`)
modal.close()
},
error: (e) => {
this.toastService.showError(
$localize`Error updating permissions`,
e
)
},
})
}
)
}
userCanEdit(obj: ObjectWithPermissions): boolean {

View File

@ -264,13 +264,19 @@ describe('ManagementListComponent', () => {
throwError(() => new Error('error setting permissions'))
)
const errorToastSpy = jest.spyOn(toastService, 'showError')
modal.componentInstance.confirmClicked.emit()
modal.componentInstance.confirmClicked.emit({
permissions: {},
merge: true,
})
expect(bulkEditPermsSpy).toHaveBeenCalled()
expect(errorToastSpy).toHaveBeenCalled()
const successToastSpy = jest.spyOn(toastService, 'showInfo')
bulkEditPermsSpy.mockReturnValueOnce(of('OK'))
modal.componentInstance.confirmClicked.emit()
modal.componentInstance.confirmClicked.emit({
permissions: {},
merge: true,
})
expect(bulkEditPermsSpy).toHaveBeenCalled()
expect(successToastSpy).toHaveBeenCalled()
})

View File

@ -279,12 +279,13 @@ export abstract class ManagementListComponent<T extends ObjectWithId>
backdrop: 'static',
})
modal.componentInstance.confirmClicked.subscribe(
(permissions: { owner: number; set_permissions: PermissionsObject }) => {
({ permissions, merge }) => {
modal.componentInstance.buttonsEnabled = false
this.service
.bulk_update_permissions(
Array.from(this.selectedObjects),
permissions
permissions,
merge
)
.subscribe({
next: () => {

View File

@ -63,4 +63,7 @@ export interface Document extends ObjectWithPermissions {
__search_hit__?: SearchHit
custom_fields?: CustomFieldInstance[]
// write-only field
remove_inbox_tags?: boolean
}

View File

@ -53,6 +53,8 @@ export const SETTINGS_KEYS = {
DEFAULT_PERMS_VIEW_GROUPS: 'general-settings:permissions:default-view-groups',
DEFAULT_PERMS_EDIT_USERS: 'general-settings:permissions:default-edit-users',
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[] = [
@ -206,4 +208,9 @@ export const SETTINGS: UiSetting[] = [
type: 'string',
default: '',
},
{
key: SETTINGS_KEYS.DOCUMENT_EDITING_REMOVE_INBOX_TAGS,
type: 'boolean',
default: false,
},
]

View File

@ -53,10 +53,14 @@ export const commonAbstractNameFilterPaperlessServiceTests = (
},
}
subscription = service
.bulk_update_permissions([1, 2], {
owner,
set_permissions: permissions,
})
.bulk_update_permissions(
[1, 2],
{
owner,
set_permissions: permissions,
},
true
)
.subscribe()
const req = httpTestingController.expectOne(
`${environment.apiBaseUrl}bulk_edit_object_perms/`

View File

@ -26,13 +26,15 @@ export abstract class AbstractNameFilterService<
bulk_update_permissions(
objects: Array<number>,
permissions: { owner: number; set_permissions: PermissionsObject }
permissions: { owner: number; set_permissions: PermissionsObject },
merge: boolean
): Observable<string> {
return this.http.post<string>(`${this.baseUrl}bulk_edit_object_perms/`, {
objects,
object_type: this.resourceName,
owner: permissions.owner,
permissions: permissions.set_permissions,
merge,
})
}
}

View File

@ -7,10 +7,13 @@ import { TestBed } from '@angular/core/testing'
import { environment } from 'src/environments/environment'
import { DocumentService } from './document.service'
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 service: DocumentService
let subscription: Subscription
let settingsService: SettingsService
const endpoint = '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`, () => {
// common tests e.g. commonAbstractPaperlessServiceTests differ slightly
it('should call appropriate api endpoint for list all', () => {
@ -237,16 +251,21 @@ describe(`DocumentService`, () => {
)
expect(req.request.method).toEqual('GET')
})
})
beforeEach(() => {
TestBed.configureTestingModule({
providers: [DocumentService],
imports: [HttpClientTestingModule],
it('should pass remove_inbox_tags setting to update', () => {
subscription = service.update(documents[0]).subscribe()
let req = httpTestingController.expectOne(
`${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(() => {

View File

@ -2,7 +2,7 @@ import { Injectable } from '@angular/core'
import { Document } from 'src/app/data/document'
import { DocumentMetadata } from 'src/app/data/document-metadata'
import { AbstractPaperlessService } from './abstract-paperless-service'
import { HttpClient, HttpParams } from '@angular/common/http'
import { HttpClient } from '@angular/common/http'
import { Observable } from 'rxjs'
import { Results } from 'src/app/data/results'
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 { queryParamsFromFilterRules } from '../../utils/query-params'
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 = [
{ field: 'archive_serial_number', name: $localize`ASN` },
@ -57,21 +64,41 @@ export class DocumentService extends AbstractPaperlessService<Document> {
private correspondentService: CorrespondentService,
private documentTypeService: DocumentTypeService,
private tagService: TagService,
private storagePathService: StoragePathService
private storagePathService: StoragePathService,
private permissionsService: PermissionsService,
private settingsService: SettingsService
) {
super(http, 'documents')
}
addObservablesToDocument(doc: Document) {
if (doc.correspondent) {
if (
doc.correspondent &&
this.permissionsService.currentUserCan(
PermissionAction.View,
PermissionType.Correspondent
)
) {
doc.correspondent$ = this.correspondentService.getCached(
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)
}
if (doc.tags) {
if (
doc.tags &&
this.permissionsService.currentUserCan(
PermissionAction.View,
PermissionType.Tag
)
) {
doc.tags$ = this.tagService
.getCachedMany(doc.tags)
.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)
}
return doc
@ -150,6 +183,9 @@ export class DocumentService extends AbstractPaperlessService<Document> {
update(o: Document): Observable<Document> {
// we want to only set created_date
o.created = undefined
o.remove_inbox_tags = this.settingsService.get(
SETTINGS_KEYS.DOCUMENT_EDITING_REMOVE_INBOX_TAGS
)
return super.update(o)
}

View File

@ -131,6 +131,12 @@ const LANGUAGE_OPTIONS = [
englishName: 'Italian',
dateInputFormat: 'dd/mm/yyyy',
},
{
code: 'ja-jp',
name: $localize`Japanese`,
englishName: 'Japanese',
dateInputFormat: 'yyyy/mm/dd',
},
{
code: 'lb-lu',
name: $localize`Luxembourgish`,

View File

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

View File

@ -589,7 +589,7 @@
<context context-type="sourcefile">src/app/components/admin/config/config.component.ts</context>
<context context-type="linenumber">107</context>
</context-group>
<target state="needs-translation">Invalid JSON</target>
<target state="translated">JSON غير صالح</target>
</trans-unit>
<trans-unit id="5103146006962696736" datatype="html">
<source>Configuration updated</source>
@ -657,7 +657,7 @@
<context context-type="sourcefile">src/app/components/admin/tasks/tasks.component.html</context>
<context context-type="linenumber">16</context>
</context-group>
<target state="needs-translation">Auto refresh</target>
<target state="translated">تحديث تلقائي</target>
</trans-unit>
<trans-unit id="3894950702316166331" datatype="html">
<source>Loading...</source>

View File

@ -497,7 +497,7 @@
<context context-type="sourcefile">src/app/components/admin/config/config.component.html</context>
<context context-type="linenumber">34</context>
</context-group>
<target state="needs-translation">Enable</target>
<target state="translated">Ενεργοποίηση</target>
</trans-unit>
<trans-unit id="3823219296477075982" datatype="html">
<source>Discard</source>
@ -589,7 +589,7 @@
<context context-type="sourcefile">src/app/components/admin/config/config.component.ts</context>
<context context-type="linenumber">107</context>
</context-group>
<target state="needs-translation">Invalid JSON</target>
<target state="translated">Μη έγκυρο JSON</target>
</trans-unit>
<trans-unit id="5103146006962696736" datatype="html">
<source>Configuration updated</source>
@ -657,7 +657,7 @@
<context context-type="sourcefile">src/app/components/admin/tasks/tasks.component.html</context>
<context context-type="linenumber">16</context>
</context-group>
<target state="needs-translation">Auto refresh</target>
<target state="translated">Αυτόματη ανανέωση</target>
</trans-unit>
<trans-unit id="3894950702316166331" datatype="html">
<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="linenumber">74</context>
</context-group>
<target state="needs-translation">Document Types</target>
<target state="translated">Τύποι Εγγράφων</target>
</trans-unit>
<trans-unit id="5421255270838137624" datatype="html">
<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="linenumber">82</context>
</context-group>
<target state="needs-translation">Storage Paths</target>
<target state="translated">Διαδρομές Αποθήκευσης</target>
</trans-unit>
<trans-unit id="3188389494264426470" datatype="html">
<source>Custom Fields</source>
@ -2521,7 +2521,7 @@
<context context-type="sourcefile">src/app/components/manage/workflows/workflows.component.html</context>
<context context-type="linenumber">2</context>
</context-group>
<target state="needs-translation">Workflows</target>
<target state="translated">Ροές εργασίας</target>
</trans-unit>
<trans-unit id="1292737233370901804" datatype="html">
<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="linenumber">54</context>
</context-group>
<target state="needs-translation">Consumption Started</target>
<target state="translated">Η Κατανάλωση Ξεκίνησε</target>
</trans-unit>
<trans-unit id="7858311467093621703" datatype="html">
<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="linenumber">9</context>
</context-group>
<target state="needs-translation">What's this?</target>
<target state="translated">Τι είναι αυτό;</target>
</trans-unit>
<trans-unit id="2827984212740060090" datatype="html">
<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="linenumber">3</context>
</context-group>
<target state="needs-translation">Edit Profile</target>
<target state="translated">Επεξεργασία Προφίλ</target>
</trans-unit>
<trans-unit id="8214169742072920158" datatype="html">
<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="linenumber">13</context>
</context-group>
<target state="needs-translation">Confirm Email</target>
<target state="translated">Επιβεβαίωση Email</target>
</trans-unit>
<trans-unit id="3241357959735682038" datatype="html">
<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="linenumber">23</context>
</context-group>
<target state="needs-translation">Confirm Password</target>
<target state="translated">Επιβεβαίωση Κωδικού</target>
</trans-unit>
<trans-unit id="7554924397178347823" datatype="html">
<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="linenumber">31</context>
</context-group>
<target state="needs-translation">API Auth Token</target>
<target state="translated">API Auth Token</target>
</trans-unit>
<trans-unit id="4323470180912194028" datatype="html">
<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="linenumber">57</context>
</context-group>
<target state="needs-translation">No documents</target>
<target state="translated">Δεν υπάρχουν έγγραφα</target>
</trans-unit>
<trans-unit id="1069523139277190436" datatype="html">
<source>Statistics</source>
@ -5054,7 +5054,7 @@
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.html</context>
<context context-type="linenumber">9</context>
</context-group>
<target state="needs-translation">-</target>
<target state="translated">-</target>
</trans-unit>
<trans-unit id="8479257185772414452" datatype="html">
<source>+</source>
@ -5062,7 +5062,7 @@
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.html</context>
<context context-type="linenumber">17</context>
</context-group>
<target state="needs-translation">+</target>
<target state="translated">+</target>
</trans-unit>
<trans-unit id="8659635229098859487" datatype="html">
<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="linenumber">325</context>
</context-group>
<target state="needs-translation">Ok</target>
<target state="translated">Οκ</target>
</trans-unit>
<trans-unit id="5758784066858623886" datatype="html">
<source>Error retrieving metadata</source>
@ -7096,7 +7096,7 @@
<context context-type="sourcefile">src/app/data/custom-field.ts</context>
<context context-type="linenumber">17</context>
</context-group>
<target state="needs-translation">Boolean</target>
<target state="translated">Δυαδικό</target>
</trans-unit>
<trans-unit id="3973931101896534797" datatype="html">
<source>Date</source>
@ -7104,7 +7104,7 @@
<context context-type="sourcefile">src/app/data/custom-field.ts</context>
<context context-type="linenumber">21</context>
</context-group>
<target state="needs-translation">Date</target>
<target state="translated">Ημερομηνία</target>
</trans-unit>
<trans-unit id="362956598863566327" datatype="html">
<source>Integer</source>
@ -7112,7 +7112,7 @@
<context context-type="sourcefile">src/app/data/custom-field.ts</context>
<context context-type="linenumber">25</context>
</context-group>
<target state="needs-translation">Integer</target>
<target state="translated">Ακέραιος</target>
</trans-unit>
<trans-unit id="6370642728789544052" datatype="html">
<source>Number</source>
@ -7120,7 +7120,7 @@
<context context-type="sourcefile">src/app/data/custom-field.ts</context>
<context context-type="linenumber">29</context>
</context-group>
<target state="needs-translation">Number</target>
<target state="translated">Αριθμός</target>
</trans-unit>
<trans-unit id="6430409302408843009" datatype="html">
<source>Monetary</source>
@ -7128,7 +7128,7 @@
<context context-type="sourcefile">src/app/data/custom-field.ts</context>
<context context-type="linenumber">33</context>
</context-group>
<target state="needs-translation">Monetary</target>
<target state="translated">Νομισματικό</target>
</trans-unit>
<trans-unit id="6162693758764653365" datatype="html">
<source>Text</source>
@ -7136,7 +7136,7 @@
<context context-type="sourcefile">src/app/data/custom-field.ts</context>
<context context-type="linenumber">37</context>
</context-group>
<target state="needs-translation">Text</target>
<target state="translated">Κείμενο</target>
</trans-unit>
<trans-unit id="8308045076391224954" datatype="html">
<source>Url</source>

View File

@ -449,21 +449,21 @@
</context-group>
<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 id="9063918187161876141" datatype="html">
<trans-unit id="9063918187161876141" datatype="html" approved="yes">
<source>Application Configuration</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/admin/config/config.component.html</context>
<context context-type="linenumber">2</context>
</context-group>
<target state="translated">Configuration de l'application</target>
<target state="final">Configuration de l'application</target>
</trans-unit>
<trans-unit id="8528041182664173532" datatype="html">
<trans-unit id="8528041182664173532" datatype="html" approved="yes">
<source>Global app configuration options which apply to &lt;strong&gt;every&lt;/strong&gt; 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 context-type="sourcefile">src/app/components/admin/config/config.component.html</context>
<context context-type="linenumber">4</context>
</context-group>
<target state="translated">Les options de configuration de l'application globaux s'appliquent à &lt;strong&gt;tous&lt;/strong&gt; 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 à &lt;strong&gt;tous&lt;/strong&gt; 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 id="187187500641108332" datatype="html">
<source>
@ -483,13 +483,13 @@
</context-group>
<target state="translated"><x id="INTERPOLATION" equiv-text="ategory}}"/></target>
</trans-unit>
<trans-unit id="7991430199894172363" datatype="html">
<trans-unit id="7991430199894172363" datatype="html" approved="yes">
<source>Read the documentation about this setting</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/admin/config/config.component.html</context>
<context context-type="linenumber">25</context>
</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 id="2180291763949669799" datatype="html">
<source>Enable</source>
@ -591,13 +591,13 @@
</context-group>
<target state="translated">JSON non valide</target>
</trans-unit>
<trans-unit id="5103146006962696736" datatype="html">
<trans-unit id="5103146006962696736" datatype="html" approved="yes">
<source>Configuration updated</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/admin/config/config.component.ts</context>
<context context-type="linenumber">151</context>
</context-group>
<target state="translated">Configuration mise a jour</target>
<target state="final">Configuration mise à jour</target>
</trans-unit>
<trans-unit id="1664963291286452273" datatype="html">
<source>An error occurred updating configuration</source>
@ -607,21 +607,21 @@
</context-group>
<target state="translated">Une erreur s'est produite lors de la mise à jour de la configuration</target>
</trans-unit>
<trans-unit id="2653081282186526824" datatype="html">
<trans-unit id="2653081282186526824" datatype="html" approved="yes">
<source>File successfully updated</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/admin/config/config.component.ts</context>
<context context-type="linenumber">178</context>
</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 id="5902783625859504265" datatype="html">
<trans-unit id="5902783625859504265" datatype="html" approved="yes">
<source>An error occurred uploading file</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/admin/config/config.component.ts</context>
<context context-type="linenumber">183</context>
</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 id="4804785061014590286" datatype="html" approved="yes">
<source>Logs</source>
@ -639,13 +639,13 @@
</context-group>
<target state="final">Journaux</target>
</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>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/admin/logs/logs.component.html</context>
<context context-type="linenumber">4</context>
</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 id="8838884664569764142" datatype="html" approved="yes">
<source>Auto refresh</source>
@ -755,13 +755,13 @@
</context-group>
<target state="final">Paramètres</target>
</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 &lt;strong&gt;current user only&lt;/strong&gt;.</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/admin/settings/settings.component.html</context>
<context context-type="linenumber">4</context>
</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'&lt;strong&gt;utilisateur actuel uniquement&lt;/strong&gt;.</target>
<target state="final">Options pour personnaliser l'apparence, les notifications, les vues sauvegardées et bien plus. Les paramètres s'appliquent à l'&lt;strong&gt;utilisateur actuel uniquement&lt;/strong&gt;.</target>
</trans-unit>
<trans-unit id="1685061484835793745" datatype="html" approved="yes">
<source>Start tour</source>
@ -1825,39 +1825,39 @@
<context context-type="sourcefile">src/app/components/admin/tasks/tasks.component.html</context>
<context context-type="linenumber">111</context>
</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 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 &gt; 0) {"/><x id="START_TAG_SPAN" ctype="x-span" equiv-text="&lt;span class=&quot;badge bg-danger ms-2&quot;&gt;"/><x id="INTERPOLATION" equiv-text="{{tasksService.failedFileTasks.length}}"/><x id="CLOSE_TAG_SPAN" ctype="x-span" equiv-text="&lt;/span&gt;"/><x id="CLOSE_BLOCK_IF" equiv-text="}"/></source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/admin/tasks/tasks.component.html</context>
<context context-type="linenumber">123,125</context>
</context-group>
<target state="translated"><x id="START_BLOCK_IF" equiv-text="@if (tasksService.failedFileTasks.length &gt; 0) {"/><x id="START_TAG_SPAN" ctype="x-span" equiv-text="&lt;span class=&quot;badge bg-danger ms-2&quot;&gt;"/><x id="INTERPOLATION" equiv-text="{{tasksService.failedFileTasks.length}}"/><x id="CLOSE_TAG_SPAN" ctype="x-span" equiv-text="&lt;/span&gt;"/><x id="CLOSE_BLOCK_IF" equiv-text="}"/> Échoué(s)</target>
<target state="final"><x id="START_BLOCK_IF" equiv-text="@if (tasksService.failedFileTasks.length &gt; 0) {"/><x id="START_TAG_SPAN" ctype="x-span" equiv-text="&lt;span class=&quot;badge bg-danger ms-2&quot;&gt;"/><x id="INTERPOLATION" equiv-text="{{tasksService.failedFileTasks.length}}"/><x id="CLOSE_TAG_SPAN" ctype="x-span" equiv-text="&lt;/span&gt;"/><x id="CLOSE_BLOCK_IF" equiv-text="}"/> Échoué(s)</target>
</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 &gt; 0) {"/><x id="START_TAG_SPAN" ctype="x-span" equiv-text="&lt;span class=&quot;badge bg-secondary ms-2&quot;&gt;"/><x id="INTERPOLATION" equiv-text="{{tasksService.completedFileTasks.length}}"/><x id="CLOSE_TAG_SPAN" ctype="x-span" equiv-text="&lt;/span&gt;"/><x id="CLOSE_BLOCK_IF" equiv-text="}"/></source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/admin/tasks/tasks.component.html</context>
<context context-type="linenumber">131,133</context>
</context-group>
<target state="translated"><x id="START_BLOCK_IF" equiv-text="@if (tasksService.completedFileTasks.length &gt; 0) {"/><x id="START_TAG_SPAN" ctype="x-span" equiv-text="&lt;span class=&quot;badge bg-secondary ms-2&quot;&gt;"/><x id="INTERPOLATION" equiv-text="{{tasksService.completedFileTasks.length}}"/><x id="CLOSE_TAG_SPAN" ctype="x-span" equiv-text="&lt;/span&gt;"/><x id="CLOSE_BLOCK_IF" equiv-text="}"/> Terminé(s)</target>
<target state="final"><x id="START_BLOCK_IF" equiv-text="@if (tasksService.completedFileTasks.length &gt; 0) {"/><x id="START_TAG_SPAN" ctype="x-span" equiv-text="&lt;span class=&quot;badge bg-secondary ms-2&quot;&gt;"/><x id="INTERPOLATION" equiv-text="{{tasksService.completedFileTasks.length}}"/><x id="CLOSE_TAG_SPAN" ctype="x-span" equiv-text="&lt;/span&gt;"/><x id="CLOSE_BLOCK_IF" equiv-text="}"/> Terminé(s)</target>
</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 &gt; 0) {"/><x id="START_TAG_SPAN" ctype="x-span" equiv-text="&lt;span class=&quot;badge bg-secondary ms-2&quot;&gt;"/><x id="INTERPOLATION" equiv-text="{{tasksService.startedFileTasks.length}}"/><x id="CLOSE_TAG_SPAN" ctype="x-span" equiv-text="&lt;/span&gt;"/><x id="CLOSE_BLOCK_IF" equiv-text="}"/></source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/admin/tasks/tasks.component.html</context>
<context context-type="linenumber">139,141</context>
</context-group>
<target state="translated"><x id="START_BLOCK_IF" equiv-text="@if (tasksService.startedFileTasks.length &gt; 0) {"/><x id="START_TAG_SPAN" ctype="x-span" equiv-text="&lt;span class=&quot;badge bg-secondary ms-2&quot;&gt;"/><x id="INTERPOLATION" equiv-text="{{tasksService.startedFileTasks.length}}"/><x id="CLOSE_TAG_SPAN" ctype="x-span" equiv-text="&lt;/span&gt;"/><x id="CLOSE_BLOCK_IF" equiv-text="}"/> Commencé(s)</target>
<target state="final"><x id="START_BLOCK_IF" equiv-text="@if (tasksService.startedFileTasks.length &gt; 0) {"/><x id="START_TAG_SPAN" ctype="x-span" equiv-text="&lt;span class=&quot;badge bg-secondary ms-2&quot;&gt;"/><x id="INTERPOLATION" equiv-text="{{tasksService.startedFileTasks.length}}"/><x id="CLOSE_TAG_SPAN" ctype="x-span" equiv-text="&lt;/span&gt;"/><x id="CLOSE_BLOCK_IF" equiv-text="}"/> Commencé(s)</target>
</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 &gt; 0) {"/><x id="START_TAG_SPAN" ctype="x-span" equiv-text="&lt;span class=&quot;badge bg-secondary ms-2&quot;&gt;"/><x id="INTERPOLATION" equiv-text="{{tasksService.queuedFileTasks.length}}"/><x id="CLOSE_TAG_SPAN" ctype="x-span" equiv-text="&lt;/span&gt;"/><x id="CLOSE_BLOCK_IF" equiv-text="}"/></source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/admin/tasks/tasks.component.html</context>
<context context-type="linenumber">147,149</context>
</context-group>
<target state="translated">En attente <x id="START_BLOCK_IF" equiv-text="@if (tasksService.queuedFileTasks.length &gt; 0) {"/><x id="START_TAG_SPAN" ctype="x-span" equiv-text="&lt;span class=&quot;badge bg-secondary ms-2&quot;&gt;"/><x id="INTERPOLATION" equiv-text="{{tasksService.queuedFileTasks.length}}"/><x id="CLOSE_TAG_SPAN" ctype="x-span" equiv-text="&lt;/span&gt;"/><x id="CLOSE_BLOCK_IF" equiv-text="}"/></target>
<target state="final"><x id="START_BLOCK_IF" equiv-text="@if (tasksService.queuedFileTasks.length &gt; 0) {"/><x id="START_TAG_SPAN" ctype="x-span" equiv-text="&lt;span class=&quot;badge bg-secondary ms-2&quot;&gt;"/><x id="INTERPOLATION" equiv-text="{{tasksService.queuedFileTasks.length}}"/><x id="CLOSE_TAG_SPAN" ctype="x-span" equiv-text="&lt;/span&gt;"/><x id="CLOSE_BLOCK_IF" equiv-text="}"/> En attente</target>
</trans-unit>
<trans-unit id="5404910960991552159" datatype="html" approved="yes">
<source>Dismiss selected</source>
@ -1939,13 +1939,13 @@
</context-group>
<target state="final">Utilisateurs &amp; Groupes</target>
</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>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/admin/users-groups/users-groups.component.html</context>
<context context-type="linenumber">4</context>
</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 id="4555457172864212828" datatype="html" approved="yes">
<source>Users</source>
@ -2275,13 +2275,13 @@
</context-group>
<target state="final">Erreur lors de la supression du groupe.</target>
</trans-unit>
<trans-unit id="7931334600001636863" datatype="html">
<trans-unit id="7931334600001636863" datatype="html" approved="yes">
<source>by Paperless-ngx</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context>
<context context-type="linenumber">20</context>
</context-group>
<target state="translated">par Paperless-ngx</target>
<target state="final">par Paperless-ngx</target>
</trans-unit>
<trans-unit id="7100953725264790651" datatype="html" approved="yes">
<source>Search documents</source>
@ -2543,7 +2543,7 @@
</context-group>
<target state="final">Administration</target>
</trans-unit>
<trans-unit id="3008420115644088420" datatype="html">
<trans-unit id="3008420115644088420" datatype="html" approved="yes">
<source>Configuration</source>
<context-group purpose="location">
<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="linenumber">242</context>
</context-group>
<target state="translated">Configuration</target>
<target state="final">Configuration</target>
</trans-unit>
<trans-unit id="1534029177398918729" datatype="html" approved="yes">
<source>GitHub</source>
@ -4184,13 +4184,13 @@
</context-group>
<target state="translated">Aucun document trouvé</target>
</trans-unit>
<trans-unit id="6932865105766151309" datatype="html">
<trans-unit id="6932865105766151309" datatype="html" approved="yes">
<source>Upload</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/common/input/file/file.component.html</context>
<context context-type="linenumber">15</context>
</context-group>
<target state="translated">Téléverser</target>
<target state="final">Téléverser</target>
</trans-unit>
<trans-unit id="5554528553553249088" datatype="html">
<source>Show password</source>
@ -4269,13 +4269,13 @@
</context-group>
<target state="translated">Aucun élément trouvé</target>
</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>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/common/input/switch/switch.component.html</context>
<context context-type="linenumber">39</context>
</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 id="6560126119609945418" datatype="html" approved="yes">
<source>Add tag</source>
@ -4301,21 +4301,21 @@
</context-group>
<target state="final">Ouvrir le lien</target>
</trans-unit>
<trans-unit id="5752465522295465624" datatype="html">
<trans-unit id="5752465522295465624" datatype="html" approved="yes">
<source>What&apos;s this?</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/common/page-header/page-header.component.html</context>
<context context-type="linenumber">9</context>
</context-group>
<target state="translated">Qu'est-ce ?</target>
<target state="final">Qu'est-ce ?</target>
</trans-unit>
<trans-unit id="2827984212740060090" datatype="html">
<trans-unit id="2827984212740060090" datatype="html" approved="yes">
<source>Read more</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/common/page-header/page-header.component.html</context>
<context context-type="linenumber">15</context>
</context-group>
<target state="translated">En savoir plus</target>
<target state="final">En savoir plus</target>
</trans-unit>
<trans-unit id="7062872617520618723" datatype="html">
<source>Set permissions</source>
@ -4709,21 +4709,21 @@
</context-group>
<target state="final">Copier l'erreur brute</target>
</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>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/dashboard/dashboard.component.ts</context>
<context context-type="linenumber">38</context>
</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 id="2901300640157872718" datatype="html">
<trans-unit id="2901300640157872718" datatype="html" approved="yes">
<source>Welcome to <x id="PH" equiv-text="environment.appTitle"/></source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/dashboard/dashboard.component.ts</context>
<context context-type="linenumber">40</context>
</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 id="1325877348738783391" datatype="html">
<source>Dashboard updated</source>
@ -5308,13 +5308,13 @@
</context-group>
<target state="translated">Prévisualisation</target>
</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="&lt;span class=&quot;badge text-bg-secondary ms-1&quot;&gt;"/><x id="INTERPOLATION" equiv-text="ngth}}"/><x id="CLOSE_TAG_SPAN" ctype="x-span"/><x id="CLOSE_BLOCK_IF" equiv-text="}"/></source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.html</context>
<context context-type="linenumber">272,275</context>
</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="&lt;span class=&quot;badge text-bg-secondary ms-1&quot;&gt;"/><x id="INTERPOLATION" equiv-text="ngth}}"/><x id="CLOSE_TAG_SPAN" ctype="x-span"/><x id="CLOSE_BLOCK_IF" equiv-text="}"/></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="&lt;span class=&quot;badge text-bg-secondary ms-1&quot;&gt;"/><x id="INTERPOLATION" equiv-text="ngth}}"/><x id="CLOSE_TAG_SPAN" ctype="x-span"/><x id="CLOSE_BLOCK_IF" equiv-text="}"/></target>
</trans-unit>
<trans-unit id="5129524307369213584" datatype="html" approved="yes">
<source>Save &amp; next</source>
@ -5362,7 +5362,7 @@
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
<context context-type="linenumber">322</context>
</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 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>
@ -5370,15 +5370,15 @@
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
<context context-type="linenumber">323</context>
</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 id="8720977247725652816" datatype="html">
<trans-unit id="8720977247725652816" datatype="html" approved="yes">
<source>Ok</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
<context context-type="linenumber">325</context>
</context-group>
<target state="translated">OK</target>
<target state="final">OK</target>
</trans-unit>
<trans-unit id="5758784066858623886" datatype="html" approved="yes">
<source>Error retrieving metadata</source>
@ -6482,13 +6482,13 @@
</context-group>
<target state="final">Voulez-vous vraiment supprimer le correspondant "<x id="PH" equiv-text="object.name"/>" ?</target>
</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>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/manage/custom-fields/custom-fields.component.html</context>
<context context-type="linenumber">4</context>
</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 id="8019331026479399960" datatype="html" approved="yes">
<source>Add Field</source>
@ -6994,13 +6994,13 @@
</context-group>
<target state="final">Voulez-vous vraiment supprimer l'étiquette "<x id="PH" equiv-text="object.name"/>" ?</target>
</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 &apos;trigger&apos; a workflow.</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/manage/workflows/workflows.component.html</context>
<context context-type="linenumber">4</context>
</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 id="2437630016855517844" datatype="html">
<source>Add Workflow</source>
@ -7250,13 +7250,13 @@
</context-group>
<target state="final">Aucun : désactiver le rapprochement</target>
</trans-unit>
<trans-unit id="432834967329800065" datatype="html">
<trans-unit id="432834967329800065" datatype="html" approved="yes">
<source>General Settings</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/data/paperless-config.ts</context>
<context context-type="linenumber">50</context>
</context-group>
<target state="translated">Paramètres généraux</target>
<target state="final">Paramètres généraux</target>
</trans-unit>
<trans-unit id="2762851116637676072" datatype="html">
<source>OCR Settings</source>
@ -7370,21 +7370,21 @@
</context-group>
<target state="translated">Arguments OCR</target>
</trans-unit>
<trans-unit id="7106327322456204362" datatype="html">
<trans-unit id="7106327322456204362" datatype="html" approved="yes">
<source>Application Logo</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/data/paperless-config.ts</context>
<context context-type="linenumber">171</context>
</context-group>
<target state="translated">Logo de l'application</target>
<target state="final">Logo de l'application</target>
</trans-unit>
<trans-unit id="2684743776608068095" datatype="html">
<trans-unit id="2684743776608068095" datatype="html" approved="yes">
<source>Application Title</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/data/paperless-config.ts</context>
<context context-type="linenumber">178</context>
</context-group>
<target state="translated">Titre de l'application</target>
<target state="final">Nom de l'application</target>
</trans-unit>
<trans-unit id="5948496158474272829" datatype="html" approved="yes">
<source>Warning: You have unsaved changes to your document(s).</source>

View File

@ -129,13 +129,17 @@ def redo_ocr(doc_ids):
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.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:
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]

197
src/documents/caching.py Normal file
View 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"

View File

@ -10,8 +10,13 @@ from pathlib import Path
from typing import Optional
from django.conf import settings
from django.core.cache import cache
from sklearn.exceptions import InconsistentVersionWarning
from documents.caching import CACHE_50_MINUTES
from documents.caching import CLASSIFIER_HASH_KEY
from documents.caching import CLASSIFIER_MODIFIED_KEY
from documents.caching import CLASSIFIER_VERSION_KEY
from documents.models import Document
from documents.models import MatchingModel
@ -208,6 +213,15 @@ class DocumentClassifier:
and self.last_doc_change_time >= latest_doc_change
) and self.last_auto_type_hash == hasher.digest():
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
# 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_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
def preprocess_content(self, content: str) -> str: # pragma: no cover

View File

@ -1,9 +1,16 @@
import pickle
from datetime import datetime
from datetime import timezone
from typing import Optional
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.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
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():
return None
with open(settings.MODEL_FILE, "rb") as f:
schema_version = pickle.load(f)
if schema_version != DocumentClassifier.FORMAT_VERSION:
return None
_ = pickle.load(f)
last_auto_type_hash: bytes = pickle.load(f)
return f"{last_auto_type_hash}:{settings.NUMBER_OF_SUGGESTED_DATES}"
# Check cache information
cache_hits = cache.get_many(
[CLASSIFIER_VERSION_KEY, CLASSIFIER_HASH_KEY],
)
# If the version differs somehow, no etag
if (
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]:
@ -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
unlikely that changes too often
"""
# No file, no last modified
if not settings.MODEL_FILE.exists():
return None
with open(settings.MODEL_FILE, "rb") as f:
schema_version = pickle.load(f)
if schema_version != DocumentClassifier.FORMAT_VERSION:
return None
last_doc_change_time = pickle.load(f)
return last_doc_change_time
cache_hits = cache.get_many(
[CLASSIFIER_VERSION_KEY, CLASSIFIER_MODIFIED_KEY],
)
# If the version differs somehow, no last modified
if (
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]:
@ -52,7 +75,7 @@ def metadata_etag(request, pk: int) -> Optional[str]:
try:
doc = Document.objects.get(pk=pk)
return doc.checksum
except Document.DoesNotExist:
except Document.DoesNotExist: # pragma: no cover
return None
return None
@ -66,7 +89,7 @@ def metadata_last_modified(request, pk: int) -> Optional[datetime]:
try:
doc = Document.objects.get(pk=pk)
return doc.modified
except Document.DoesNotExist:
except Document.DoesNotExist: # pragma: no cover
return None
return None
@ -82,6 +105,46 @@ def preview_etag(request, pk: int) -> Optional[str]:
and request.query_params["original"] == "true"
)
return doc.checksum if use_original else doc.archive_checksum
except Document.DoesNotExist:
except Document.DoesNotExist: # pragma: no cover
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

View File

@ -232,6 +232,9 @@ class Command(BaseCommand):
if not os.path.isdir(directory):
raise CommandError(f"Consumption directory {directory} does not exist")
# Consumer will need this
settings.SCRATCH_DIR.mkdir(parents=True, exist_ok=True)
if recursive:
for dirpath, _, filenames in os.walk(directory):
for filename in filenames:

View File

@ -140,6 +140,7 @@ def run_convert(
type=None,
depth=None,
auto_orient=False,
use_cropbox=False,
extra=None,
logging_group=None,
) -> None:
@ -158,6 +159,7 @@ def run_convert(
args += ["-type", str(type)] if type else []
args += ["-depth", str(depth)] if depth else []
args += ["-auto-orient"] if auto_orient else []
args += ["-define", "pdf:use-cropbox=true"] if use_cropbox else []
args += [input_file, output_file]
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,
trim=False,
auto_orient=True,
use_cropbox=True,
input_file=f"{in_path}[0]",
output_file=out_path,
logging_group=logging_group,

View File

@ -81,7 +81,7 @@ class MatchingModelSerializer(serializers.ModelSerializer):
slug = SerializerMethodField()
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
owner = (
data["owner"]
@ -441,6 +441,17 @@ class CustomFieldSerializer(serializers.ModelSerializer):
"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):
"""
@ -638,6 +649,11 @@ class DocumentSerializer(
allow_null=True,
)
remove_inbox_tags = serializers.BooleanField(
default=False,
write_only=True,
)
def get_original_file_name(self, obj):
return obj.original_filename
@ -681,12 +697,48 @@ class DocumentSerializer(
custom_field_instance.field,
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)
return instance
def __init__(self, *args, **kwargs):
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)
class Meta:
@ -714,6 +766,7 @@ class DocumentSerializer(
"set_permissions",
"notes",
"custom_fields",
"remove_inbox_tags",
)
@ -916,6 +969,8 @@ class BulkEditSerializer(DocumentListSerializer, SetPermissionsMixin):
)
if "owner" in parameters and parameters["owner"] is not None:
self._validate_owner(parameters["owner"])
if "merge" not in parameters:
parameters["merge"] = False
def validate(self, attrs):
method = attrs["method"]
@ -1258,6 +1313,12 @@ class BulkEditObjectPermissionsSerializer(serializers.Serializer, SetPermissions
write_only=True,
)
merge = serializers.BooleanField(
default=False,
write_only=True,
required=False,
)
def get_object_class(self, object_type):
object_class = None
if object_type == "tags":

View File

@ -765,6 +765,58 @@ class TestBulkEdit(DirectoriesMixin, APITestCase):
self.assertCountEqual(args[0], [self.doc2.id, self.doc3.id])
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")
def test_insufficient_permissions_ownership(self, m):
"""

View File

@ -53,6 +53,29 @@ class TestCustomField(DirectoriesMixin, APITestCase):
self.assertEqual(data["name"], name)
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):
"""
GIVEN:

View File

@ -4,6 +4,7 @@ import shutil
import tempfile
import uuid
import zoneinfo
from binascii import hexlify
from datetime import timedelta
from pathlib import Path
from unittest import mock
@ -13,12 +14,17 @@ from dateutil import parser
from django.conf import settings
from django.contrib.auth.models import Permission
from django.contrib.auth.models import User
from django.core.cache import cache
from django.test import override_settings
from django.utils import timezone
from guardian.shortcuts import assign_perm
from rest_framework import status
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 CustomField
from documents.models import CustomFieldInstance
@ -40,6 +46,7 @@ class TestDocumentApi(DirectoriesMixin, DocumentConsumeDelayMixin, APITestCase):
self.user = User.objects.create_superuser(username="temp_admin")
self.client.force_authenticate(user=self.user)
cache.clear()
def testDocuments(self):
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["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):
response = self.client.get("/api/documents/34576/metadata/")
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_document_types")
@mock.patch("documents.views.match_tags")
@ -1278,7 +1288,7 @@ class TestDocumentApi(DirectoriesMixin, DocumentConsumeDelayMixin, APITestCase):
match_tags,
match_document_types,
match_storage_paths,
mocked_pickle_load,
mocked_load,
):
"""
GIVEN:
@ -1287,23 +1297,43 @@ class TestDocumentApi(DirectoriesMixin, DocumentConsumeDelayMixin, APITestCase):
- Classifier has not been modified
THEN:
- 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
last_modified = timezone.now()
settings.MODEL_FILE.touch()
# ETag first, then modified
mock_effect = [
DocumentClassifier.FORMAT_VERSION,
"dont care",
b"thisisachecksum",
DocumentClassifier.FORMAT_VERSION,
last_modified,
classifier_checksum_bytes = b"thisisachecksum"
classifier_checksum_hex = hexlify(classifier_checksum_bytes).decode()
# Two loads, so two side effects
mocked_load.side_effect = [
mock.Mock(
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(
title="test",
@ -1311,12 +1341,8 @@ class TestDocumentApi(DirectoriesMixin, DocumentConsumeDelayMixin, APITestCase):
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/")
self.assertEqual(response.status_code, status.HTTP_200_OK)
self.assertEqual(
response.data,
{
@ -1327,7 +1353,6 @@ class TestDocumentApi(DirectoriesMixin, DocumentConsumeDelayMixin, APITestCase):
"dates": ["2022-04-12"],
},
)
mocked_pickle_load.assert_called()
self.assertIn("Last-Modified", response.headers)
self.assertEqual(
response.headers["Last-Modified"],
@ -1336,15 +1361,11 @@ class TestDocumentApi(DirectoriesMixin, DocumentConsumeDelayMixin, APITestCase):
self.assertIn("ETag", response.headers)
self.assertEqual(
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/")
self.assertEqual(response.status_code, status.HTTP_200_OK)
mocked_pickle_load.assert_called()
@mock.patch("documents.parsers.parse_date_generator")
@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.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):
def setUp(self):

View File

@ -700,8 +700,8 @@ class TestBulkEditObjectPermissions(APITestCase):
def setUp(self):
super().setUp()
user = User.objects.create_superuser(username="temp_admin")
self.client.force_authenticate(user=user)
self.temp_admin = User.objects.create_superuser(username="temp_admin")
self.client.force_authenticate(user=self.temp_admin)
self.t1 = Tag.objects.create(name="t1")
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(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):
"""
GIVEN:

View 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)

View File

@ -35,6 +35,7 @@ from django.utils.translation import get_language
from django.views import View
from django.views.decorators.cache import cache_control
from django.views.decorators.http import condition
from django.views.decorators.http import last_modified
from django.views.generic import TemplateView
from django_filters.rest_framework import DjangoFilterBackend
from langdetect import detect
@ -62,12 +63,21 @@ from documents import bulk_edit
from documents.bulk_download import ArchiveOnlyStrategy
from documents.bulk_download import OriginalAndArchiveStrategy
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.conditionals import metadata_etag
from documents.conditionals import metadata_last_modified
from documents.conditionals import preview_etag
from documents.conditionals import preview_last_modified
from documents.conditionals import suggestions_etag
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 DocumentMetadataOverrides
from documents.data_models import DocumentSource
@ -379,10 +389,12 @@ class DocumentViewSet(
try:
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.
return []
else:
else: # pragma: no cover
logger.warning(f"No parser for {mime_type}")
return []
def get_filesize(self, filename):
@ -407,16 +419,37 @@ class DocumentViewSet(
except Document.DoesNotExist:
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 = {
"original_checksum": doc.checksum,
"original_size": self.get_filesize(doc.source_path),
"original_mime_type": doc.mime_type,
"media_filename": doc.filename,
"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_media_filename": doc.archive_filename,
"original_filename": doc.original_filename,
"archive_size": archive_filesize,
"archive_metadata": archive_metadata,
}
lang = "en"
@ -426,16 +459,6 @@ class DocumentViewSet(
pass
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)
@action(methods=["get"], detail=True)
@ -454,6 +477,12 @@ class DocumentViewSet(
):
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()
dates = []
@ -463,27 +492,30 @@ class DocumentViewSet(
{i for i in itertools.islice(gen, settings.NUMBER_OF_SUGGESTED_DATES)},
)
return Response(
{
"correspondents": [
c.id for c in match_correspondents(doc, classifier, request.user)
],
"tags": [t.id for t in match_tags(doc, classifier, request.user)],
"document_types": [
dt.id for dt in match_document_types(doc, classifier, request.user)
],
"storage_paths": [
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
],
},
)
resp_data = {
"correspondents": [
c.id for c in match_correspondents(doc, classifier, request.user)
],
"tags": [t.id for t in match_tags(doc, classifier, request.user)],
"document_types": [
dt.id for dt in match_document_types(doc, classifier, request.user)
],
"storage_paths": [
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],
}
# 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)
@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):
try:
response = self.file_response(pk, request, "inline")
@ -492,7 +524,8 @@ class DocumentViewSet(
raise Http404
@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):
try:
doc = Document.objects.get(id=pk)
@ -506,8 +539,6 @@ class DocumentViewSet(
handle = GnuPG.decrypted(doc.thumbnail_file)
else:
handle = doc.thumbnail_file
# TODO: Send ETag information and use that to send new thumbnails
# if available
return HttpResponse(handle, content_type="image/webp")
except (FileNotFoundError, Document.DoesNotExist):
@ -1385,6 +1416,7 @@ class BulkEditObjectPermissionsView(GenericAPIView, PassUserMixin):
object_class = serializer.get_object_class(object_type)
permissions = serializer.validated_data.get("permissions")
owner = serializer.validated_data.get("owner")
merge = serializer.validated_data.get("merge")
if not user.is_superuser:
objs = object_class.objects.filter(pk__in=object_ids)
@ -1396,12 +1428,21 @@ class BulkEditObjectPermissionsView(GenericAPIView, PassUserMixin):
try:
qs = object_class.objects.filter(id__in=object_ids)
if "owner" in serializer.validated_data:
qs.update(owner=owner)
# if merge is true, we dont want to remove the 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:
for obj in qs:
set_permissions_for_object(permissions, obj)
set_permissions_for_object(
permissions=permissions,
object=obj,
merge=merge,
)
return Response({"result": "OK"})
except Exception as e:

View File

@ -3,7 +3,7 @@ msgstr ""
"Project-Id-Version: paperless-ngx\n"
"Report-Msgid-Bugs-To: \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"
"Language-Team: Arabic\n"
"Language: ar_SA\n"
@ -931,7 +931,7 @@ msgstr "لا ورقي"
#: paperless/models.py:25
msgid "pdf"
msgstr ""
msgstr "pdf"
#: paperless/models.py:26
msgid "pdfa"
@ -951,15 +951,15 @@ msgstr ""
#: paperless/models.py:38
msgid "skip"
msgstr ""
msgstr "تخطي"
#: paperless/models.py:39
msgid "redo"
msgstr ""
msgstr "إعادة"
#: paperless/models.py:40
msgid "force"
msgstr ""
msgstr "إجبار"
#: paperless/models.py:41
msgid "skip_noarchive"
@ -967,7 +967,7 @@ msgstr ""
#: paperless/models.py:49
msgid "never"
msgstr ""
msgstr "أبداً"
#: paperless/models.py:50
msgid "with_text"
@ -975,7 +975,7 @@ msgstr ""
#: paperless/models.py:51
msgid "always"
msgstr ""
msgstr "دائماً"
#: paperless/models.py:59
msgid "clean"
@ -987,7 +987,7 @@ msgstr ""
#: paperless/models.py:61
msgid "none"
msgstr ""
msgstr "لا شيء"
#: paperless/models.py:69
msgid "LeaveColorUnchanged"
@ -995,7 +995,7 @@ msgstr ""
#: paperless/models.py:70
msgid "RGB"
msgstr ""
msgstr "RGB"
#: paperless/models.py:71
msgid "UseDeviceIndependentColor"
@ -1003,15 +1003,15 @@ msgstr ""
#: paperless/models.py:72
msgid "Gray"
msgstr ""
msgstr "رمادي"
#: paperless/models.py:73
msgid "CMYK"
msgstr ""
msgstr "CMYK"
#: paperless/models.py:82
msgid "Sets the output PDF type"
msgstr ""
msgstr "تعيين نوع إخراج PDF"
#: paperless/models.py:94
msgid "Do OCR from page 1 to this value"

View File

@ -3,7 +3,7 @@ msgstr ""
"Project-Id-Version: paperless-ngx\n"
"Report-Msgid-Bugs-To: \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"
"Language-Team: German\n"
"Language: de_DE\n"
@ -601,7 +601,7 @@ msgstr "Benutzerdefiniertes Feld"
#: documents/models.py:782
msgid "custom fields"
msgstr "Benutzerdefinierte Felder"
msgstr "Benutzerdef. Felder"
#: documents/models.py:844
msgid "custom field instance"

View File

@ -3,7 +3,7 @@ msgstr ""
"Project-Id-Version: paperless-ngx\n"
"Report-Msgid-Bugs-To: \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"
"Language-Team: Greek\n"
"Language: el_GR\n"
@ -425,11 +425,11 @@ msgstr "δεν έχει ιδιοκτήτη σε"
#: documents/models.py:458
msgid "has custom field value"
msgstr ""
msgstr "έχει προσαρμοσμένη τιμή πεδίου"
#: documents/models.py:459
msgid "is shared by me"
msgstr ""
msgstr "μοιράζεται από μένα"
#: documents/models.py:469
msgid "rule type"
@ -613,7 +613,7 @@ msgstr "στιγμιότυπα προσαρμοσμένων πεδίων"
#: documents/models.py:902
msgid "Consumption Started"
msgstr ""
msgstr "Η Κατανάλωση Ξεκίνησε"
#: documents/models.py:903
msgid "Document Added"
@ -753,7 +753,7 @@ msgstr ""
#: documents/models.py:1114
msgid "actions"
msgstr ""
msgstr "ενέργειες"
#: documents/models.py:1117
msgid "enabled"
@ -939,15 +939,15 @@ msgstr "pdfa"
#: paperless/models.py:27
msgid "pdfa-1"
msgstr ""
msgstr "pdfa-1"
#: paperless/models.py:28
msgid "pdfa-2"
msgstr ""
msgstr "pdfa-2"
#: paperless/models.py:29
msgid "pdfa-3"
msgstr ""
msgstr "pdfa-3"
#: paperless/models.py:38
msgid "skip"
@ -967,7 +967,7 @@ msgstr ""
#: paperless/models.py:49
msgid "never"
msgstr ""
msgstr "ποτέ"
#: paperless/models.py:50
msgid "with_text"

File diff suppressed because it is too large Load Diff

View File

@ -3,7 +3,7 @@ msgstr ""
"Project-Id-Version: paperless-ngx\n"
"Report-Msgid-Bugs-To: \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"
"Language-Team: French\n"
"Language: fr_FR\n"

View File

@ -47,3 +47,11 @@ class HttpRemoteUserMiddleware(PersistentRemoteUserMiddleware):
"""
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

View File

@ -420,19 +420,34 @@ if AUTO_LOGIN_USERNAME:
# regular login in case the provided user does not exist.
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:
MIDDLEWARE.append("paperless.auth.HttpRemoteUserMiddleware")
AUTHENTICATION_BACKENDS.insert(0, "django.contrib.auth.backends.RemoteUserBackend")
REST_FRAMEWORK["DEFAULT_AUTHENTICATION_CLASSES"].append(
"rest_framework.authentication.RemoteUserAuthentication",
def _parse_remote_user_settings() -> str:
global MIDDLEWARE, AUTHENTICATION_BACKENDS, REST_FRAMEWORK
enable = __get_boolean("PAPERLESS_ENABLE_HTTP_REMOTE_USER")
enable_api = __get_boolean("PAPERLESS_ENABLE_HTTP_REMOTE_USER_API")
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 = "ANY" if DEBUG else "SAMEORIGIN"
@ -615,6 +630,7 @@ LANGUAGES = [
("fr-fr", _("French")),
("hu-hu", _("Hungarian")),
("it-it", _("Italian")),
("ja-jp", _("Japanese")),
("lb-lu", _("Luxembourgish")),
("no-no", _("Norwegian")),
("nl-nl", _("Dutch")),
@ -746,8 +762,12 @@ CELERY_BEAT_SCHEDULE_FILENAME = os.path.join(DATA_DIR, "celerybeat-schedule.db")
# django setting.
CACHES = {
"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,
"KEY_PREFIX": os.getenv("PAPERLESS_REDIS_PREFIX", ""),
},
}

View 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")

View File

@ -1,6 +1,6 @@
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
__full_version_str__: Final[str] = ".".join(map(str, __version__))
# Version string like X.Y

View File

@ -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
env =
PAPERLESS_DISABLE_DBHANDLER=true
PAPERLESS_CACHE_BACKEND=django.core.cache.backends.locmem.LocMemCache
[coverage:run]
source =