mirror of
https://github.com/paperless-ngx/paperless-ngx.git
synced 2026-03-02 07:16:23 +00:00
Compare commits
21 Commits
feature-li
...
feature-ve
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
23f6a8b708 | ||
|
|
9b469c4f4f | ||
|
|
af0212ff9e | ||
|
|
82d8f48e9b | ||
|
|
a700928dd5 | ||
|
|
709bcfd30d | ||
|
|
dd06627e43 | ||
|
|
f65807b906 | ||
|
|
a6c974589f | ||
|
|
47f9f642a9 | ||
|
|
8bfebc3b9b | ||
|
|
c7f83212a3 | ||
|
|
b010f65ae7 | ||
|
|
1dd3a62bc2 | ||
|
|
0bc032a67d | ||
|
|
8531078a54 | ||
|
|
5988d5896b | ||
|
|
89d3a53603 | ||
|
|
9601b3d597 | ||
|
|
13e07844fe | ||
|
|
be82fcb70a |
@@ -39,3 +39,6 @@ max_line_length = off
|
||||
|
||||
[Dockerfile*]
|
||||
indent_style = space
|
||||
|
||||
[*.toml]
|
||||
indent_style = space
|
||||
|
||||
@@ -440,6 +440,9 @@ src/documents/permissions.py:0: error: Item "list[str]" of "Any | list[str] | Qu
|
||||
src/documents/permissions.py:0: error: Item "list[str]" of "Any | list[str] | QuerySet[User, User]" has no attribute "exists" [union-attr]
|
||||
src/documents/permissions.py:0: error: Missing type parameters for generic type "QuerySet" [type-arg]
|
||||
src/documents/permissions.py:0: error: Missing type parameters for generic type "dict" [type-arg]
|
||||
src/documents/plugins/helpers.py:0: error: "Collection[str]" has no attribute "update" [attr-defined]
|
||||
src/documents/plugins/helpers.py:0: error: Argument 1 to "send" of "BaseStatusManager" has incompatible type "dict[str, Collection[str]]"; expected "dict[str, str | int | None]" [arg-type]
|
||||
src/documents/plugins/helpers.py:0: error: Argument 1 to "send" of "BaseStatusManager" has incompatible type "dict[str, Collection[str]]"; expected "dict[str, str | int | None]" [arg-type]
|
||||
src/documents/plugins/helpers.py:0: error: Function is missing a type annotation [no-untyped-def]
|
||||
src/documents/plugins/helpers.py:0: error: Function is missing a type annotation [no-untyped-def]
|
||||
src/documents/plugins/helpers.py:0: error: Skipping analyzing "channels_redis.pubsub": module is installed, but missing library stubs or py.typed marker [import-untyped]
|
||||
@@ -664,6 +667,7 @@ src/documents/signals/handlers.py:0: error: Argument 3 to "validate_move" has in
|
||||
src/documents/signals/handlers.py:0: error: Argument 5 to "_suggestion_printer" has incompatible type "Any | None"; expected "MatchingModel" [arg-type]
|
||||
src/documents/signals/handlers.py:0: error: Argument 5 to "_suggestion_printer" has incompatible type "Any | None"; expected "MatchingModel" [arg-type]
|
||||
src/documents/signals/handlers.py:0: error: Argument 5 to "_suggestion_printer" has incompatible type "Any | None"; expected "MatchingModel" [arg-type]
|
||||
src/documents/signals/handlers.py:0: error: Function is missing a return type annotation [no-untyped-def]
|
||||
src/documents/signals/handlers.py:0: error: Function is missing a type annotation [no-untyped-def]
|
||||
src/documents/signals/handlers.py:0: error: Function is missing a type annotation [no-untyped-def]
|
||||
src/documents/signals/handlers.py:0: error: Function is missing a type annotation for one or more arguments [no-untyped-def]
|
||||
@@ -1924,7 +1928,6 @@ src/paperless/tests/test_websockets.py:0: error: Item "None" of "BaseChannelLaye
|
||||
src/paperless/tests/test_websockets.py:0: error: Item "None" of "BaseChannelLayer | None" has no attribute "group_send" [union-attr]
|
||||
src/paperless/tests/test_websockets.py:0: error: Item "None" of "BaseChannelLayer | None" has no attribute "group_send" [union-attr]
|
||||
src/paperless/tests/test_websockets.py:0: error: Item "None" of "BaseChannelLayer | None" has no attribute "group_send" [union-attr]
|
||||
src/paperless/tests/test_websockets.py:0: error: Item "None" of "BaseChannelLayer | None" has no attribute "group_send" [union-attr]
|
||||
src/paperless/tests/test_websockets.py:0: error: TypedDict "_WebsocketTestScope" has no key "user" [typeddict-item]
|
||||
src/paperless/tests/test_websockets.py:0: error: TypedDict "_WebsocketTestScope" has no key "user" [typeddict-item]
|
||||
src/paperless/tests/test_websockets.py:0: error: TypedDict "_WebsocketTestScope" has no key "user" [typeddict-item]
|
||||
|
||||
@@ -62,6 +62,10 @@ copies you created in the steps above.
|
||||
|
||||
## Updating Paperless {#updating}
|
||||
|
||||
!!! warning
|
||||
|
||||
Please review the [migration instructions](migration-v3.md) before upgrading Paperless-ngx to v3.0, it includes some breaking changes that require manual intervention before upgrading.
|
||||
|
||||
### Docker Route {#docker-updating}
|
||||
|
||||
If a new release of paperless-ngx is available, upgrading depends on how
|
||||
|
||||
@@ -332,6 +332,7 @@ Paperless provides the following variables for use within filenames:
|
||||
- `{{ owner_username }}`: Username of document owner, if any, or "none"
|
||||
- `{{ original_name }}`: Document original filename, minus the extension, if any, or "none"
|
||||
- `{{ doc_pk }}`: The paperless identifier (primary key) for the document.
|
||||
- `{{ version_label }}`: The document version label or "none" if not explicitly set.
|
||||
|
||||
!!! warning
|
||||
|
||||
|
||||
@@ -1,9 +1,37 @@
|
||||
# Changelog
|
||||
|
||||
## paperless-ngx 2.20.9
|
||||
|
||||
### Security
|
||||
|
||||
- Resolve [GHSA-386h-chg4-cfw9](https://github.com/paperless-ngx/paperless-ngx/security/advisories/GHSA-386h-chg4-cfw9)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- Fixhancement: config option reset [@shamoon](https://github.com/shamoon) ([#12176](https://github.com/paperless-ngx/paperless-ngx/pull/12176))
|
||||
- Fix: correct page count by separating display vs collection sizes for tags [@shamoon](https://github.com/shamoon) ([#12170](https://github.com/paperless-ngx/paperless-ngx/pull/12170))
|
||||
|
||||
### All App Changes
|
||||
|
||||
<details>
|
||||
<summary>2 changes</summary>
|
||||
|
||||
- Fixhancement: config option reset [@shamoon](https://github.com/shamoon) ([#12176](https://github.com/paperless-ngx/paperless-ngx/pull/12176))
|
||||
- Fix: correct page count by separating display vs collection sizes for tags [@shamoon](https://github.com/shamoon) ([#12170](https://github.com/paperless-ngx/paperless-ngx/pull/12170))
|
||||
</details>
|
||||
|
||||
## paperless-ngx 2.20.8
|
||||
|
||||
### Security
|
||||
|
||||
- Resolve [GHSA-7qqc-wrcw-2fj9](https://github.com/paperless-ngx/paperless-ngx/security/advisories/GHSA-7qqc-wrcw-2fj9)
|
||||
|
||||
## paperless-ngx 2.20.7
|
||||
|
||||
### Security
|
||||
|
||||
- Resolve [GHSA-x395-6h48-wr8v](https://github.com/paperless-ngx/paperless-ngx/security/advisories/GHSA-x395-6h48-wr8v)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- Performance fix: use subqueries to improve object retrieval in large installs [@shamoon](https://github.com/shamoon) ([#11950](https://github.com/paperless-ngx/paperless-ngx/pull/11950))
|
||||
@@ -22,6 +50,10 @@
|
||||
|
||||
## paperless-ngx 2.20.6
|
||||
|
||||
### Security
|
||||
|
||||
- Resolve [GHSA-jqwv-hx7q-fxh3](https://github.com/paperless-ngx/paperless-ngx/security/advisories/GHSA-jqwv-hx7q-fxh3) and [GHSA-w47q-3m69-84v8](https://github.com/paperless-ngx/paperless-ngx/security/advisories/GHSA-w47q-3m69-84v8)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- Fix: extract all ids for nested tags [@shamoon](https://github.com/shamoon) ([#11888](https://github.com/paperless-ngx/paperless-ngx/pull/11888))
|
||||
|
||||
@@ -51,137 +51,172 @@ matcher.
|
||||
### Database
|
||||
|
||||
By default, Paperless uses **SQLite** with a database stored at `data/db.sqlite3`.
|
||||
To switch to **PostgreSQL** or **MariaDB**, set [`PAPERLESS_DBHOST`](#PAPERLESS_DBHOST) and optionally configure other
|
||||
database-related environment variables.
|
||||
For multi-user or higher-throughput deployments, **PostgreSQL** (recommended) or
|
||||
**MariaDB** can be used instead by setting [`PAPERLESS_DBENGINE`](#PAPERLESS_DBENGINE)
|
||||
and the relevant connection variables.
|
||||
|
||||
#### [`PAPERLESS_DBENGINE=<engine>`](#PAPERLESS_DBENGINE) {#PAPERLESS_DBENGINE}
|
||||
|
||||
: Specifies the database engine to use. Accepted values are `sqlite`, `postgresql`,
|
||||
and `mariadb`.
|
||||
|
||||
Defaults to `sqlite` if not set.
|
||||
|
||||
PostgreSQL and MariaDB both require [`PAPERLESS_DBHOST`](#PAPERLESS_DBHOST) to be
|
||||
set. SQLite does not use any other connection variables; the database file is always
|
||||
located at `<PAPERLESS_DATA_DIR>/db.sqlite3`.
|
||||
|
||||
!!! warning
|
||||
Using MariaDB comes with some caveats.
|
||||
See [MySQL Caveats](advanced_usage.md#mysql-caveats).
|
||||
|
||||
#### [`PAPERLESS_DBHOST=<hostname>`](#PAPERLESS_DBHOST) {#PAPERLESS_DBHOST}
|
||||
|
||||
: If unset, Paperless uses **SQLite** by default.
|
||||
|
||||
Set `PAPERLESS_DBHOST` to switch to PostgreSQL or MariaDB instead.
|
||||
|
||||
#### [`PAPERLESS_DBENGINE=<engine_name>`](#PAPERLESS_DBENGINE) {#PAPERLESS_DBENGINE}
|
||||
|
||||
: Optional. Specifies the database engine to use when connecting to a remote database.
|
||||
Available options are `postgresql` and `mariadb`.
|
||||
|
||||
Defaults to `postgresql` if `PAPERLESS_DBHOST` is set.
|
||||
|
||||
!!! warning
|
||||
|
||||
Using MariaDB comes with some caveats. See [MySQL Caveats](advanced_usage.md#mysql-caveats).
|
||||
: Hostname of the PostgreSQL or MariaDB database server. Required when
|
||||
`PAPERLESS_DBENGINE` is `postgresql` or `mariadb`.
|
||||
|
||||
#### [`PAPERLESS_DBPORT=<port>`](#PAPERLESS_DBPORT) {#PAPERLESS_DBPORT}
|
||||
|
||||
: Port to use when connecting to PostgreSQL or MariaDB.
|
||||
|
||||
Default is `5432` for PostgreSQL and `3306` for MariaDB.
|
||||
Defaults to `5432` for PostgreSQL and `3306` for MariaDB.
|
||||
|
||||
#### [`PAPERLESS_DBNAME=<name>`](#PAPERLESS_DBNAME) {#PAPERLESS_DBNAME}
|
||||
|
||||
: Name of the database to connect to when using PostgreSQL or MariaDB.
|
||||
: Name of the PostgreSQL or MariaDB database to connect to.
|
||||
|
||||
Defaults to "paperless".
|
||||
Defaults to `paperless`.
|
||||
|
||||
#### [`PAPERLESS_DBUSER=<name>`](#PAPERLESS_DBUSER) {#PAPERLESS_DBUSER}
|
||||
#### [`PAPERLESS_DBUSER=<user>`](#PAPERLESS_DBUSER) {#PAPERLESS_DBUSER}
|
||||
|
||||
: Username for authenticating with the PostgreSQL or MariaDB database.
|
||||
|
||||
Defaults to "paperless".
|
||||
Defaults to `paperless`.
|
||||
|
||||
#### [`PAPERLESS_DBPASS=<password>`](#PAPERLESS_DBPASS) {#PAPERLESS_DBPASS}
|
||||
|
||||
: Password for the PostgreSQL or MariaDB database user.
|
||||
|
||||
Defaults to "paperless".
|
||||
Defaults to `paperless`.
|
||||
|
||||
#### [`PAPERLESS_DBSSLMODE=<mode>`](#PAPERLESS_DBSSLMODE) {#PAPERLESS_DBSSLMODE}
|
||||
#### [`PAPERLESS_DB_OPTIONS=<options>`](#PAPERLESS_DB_OPTIONS) {#PAPERLESS_DB_OPTIONS}
|
||||
|
||||
: SSL mode to use when connecting to PostgreSQL or MariaDB.
|
||||
: Advanced database connection options as a semicolon-delimited key-value string.
|
||||
Keys and values are separated by `=`. Dot-notation produces nested option
|
||||
dictionaries; for example, `pool.max_size=20` sets
|
||||
`OPTIONS["pool"]["max_size"] = 20`.
|
||||
|
||||
See [the official documentation about
|
||||
sslmode for PostgreSQL](https://www.postgresql.org/docs/current/libpq-ssl.html).
|
||||
Options specified here are merged over the engine defaults. Unrecognised keys
|
||||
are passed through to the underlying database driver without validation, so a
|
||||
typo will be silently ignored rather than producing an error.
|
||||
|
||||
See [the official documentation about
|
||||
sslmode for MySQL and MariaDB](https://dev.mysql.com/doc/refman/8.0/en/connection-options.html#option_general_ssl-mode).
|
||||
Refer to your database driver's documentation for the full set of accepted keys:
|
||||
|
||||
*Note*: SSL mode values differ between PostgreSQL and MariaDB.
|
||||
- PostgreSQL: [libpq connection parameters](https://www.postgresql.org/docs/current/libpq-connect.html#LIBPQ-PARAMKEYWORDS)
|
||||
- MariaDB: [MariaDB Connector/Python](https://mariadb.com/kb/en/mariadb-connector-python/)
|
||||
- SQLite: [SQLite PRAGMA statements](https://www.sqlite.org/pragma.html)
|
||||
|
||||
Default is `prefer` for PostgreSQL and `PREFERRED` for MariaDB.
|
||||
!!! note "PostgreSQL connection pooling"
|
||||
|
||||
#### [`PAPERLESS_DBSSLROOTCERT=<ca-path>`](#PAPERLESS_DBSSLROOTCERT) {#PAPERLESS_DBSSLROOTCERT}
|
||||
Pool size is controlled via `pool.min_size` and `pool.max_size`. When
|
||||
configuring pooling, ensure your PostgreSQL `max_connections` is large enough
|
||||
to handle all pool connections across all workers:
|
||||
`(web_workers + celery_workers) * pool.max_size + safety_margin`.
|
||||
|
||||
: Path to the SSL root certificate used to verify the database server.
|
||||
**Examples:**
|
||||
|
||||
See [the official documentation about
|
||||
sslmode for PostgreSQL](https://www.postgresql.org/docs/current/libpq-ssl.html).
|
||||
Changes the location of `root.crt`.
|
||||
```bash title="PostgreSQL: require SSL, set a custom CA certificate, and limit the pool size"
|
||||
PAPERLESS_DB_OPTIONS="sslmode=require;sslrootcert=/certs/ca.pem;pool.max_size=5"
|
||||
```
|
||||
|
||||
See [the official documentation about
|
||||
sslmode for MySQL and MariaDB](https://dev.mysql.com/doc/refman/8.0/en/connection-options.html#option_general_ssl-ca).
|
||||
```bash title="MariaDB: require SSL with a custom CA certificate"
|
||||
PAPERLESS_DB_OPTIONS="ssl_mode=REQUIRED;ssl.ca=/certs/ca.pem"
|
||||
```
|
||||
|
||||
Defaults to unset, using the standard location in the home directory.
|
||||
```bash title="SQLite: set a busy timeout of 30 seconds"
|
||||
# PostgreSQL: set a connection timeout
|
||||
PAPERLESS_DB_OPTIONS="connect_timeout=10"
|
||||
```
|
||||
|
||||
#### [`PAPERLESS_DBSSLCERT=<client-cert-path>`](#PAPERLESS_DBSSLCERT) {#PAPERLESS_DBSSLCERT}
|
||||
#### ~~[`PAPERLESS_DBSSLMODE`](#PAPERLESS_DBSSLMODE)~~ {#PAPERLESS_DBSSLMODE}
|
||||
|
||||
: Path to the client SSL certificate used when connecting securely.
|
||||
!!! failure "Removed in v3"
|
||||
|
||||
See [the official documentation about
|
||||
sslmode for PostgreSQL](https://www.postgresql.org/docs/current/libpq-ssl.html).
|
||||
Use [`PAPERLESS_DB_OPTIONS`](#PAPERLESS_DB_OPTIONS) instead.
|
||||
|
||||
See [the official documentation about
|
||||
sslmode for MySQL and MariaDB](https://dev.mysql.com/doc/refman/8.0/en/connection-options.html#option_general_ssl-cert).
|
||||
```bash title="PostgreSQL"
|
||||
PAPERLESS_DB_OPTIONS="sslmode=require"
|
||||
```
|
||||
|
||||
Changes the location of `postgresql.crt`.
|
||||
```bash title="MariaDB"
|
||||
PAPERLESS_DB_OPTIONS="ssl_mode=REQUIRED"
|
||||
```
|
||||
|
||||
Defaults to unset, using the standard location in the home directory.
|
||||
#### ~~[`PAPERLESS_DBSSLROOTCERT`](#PAPERLESS_DBSSLROOTCERT)~~ {#PAPERLESS_DBSSLROOTCERT}
|
||||
|
||||
#### [`PAPERLESS_DBSSLKEY=<client-cert-key>`](#PAPERLESS_DBSSLKEY) {#PAPERLESS_DBSSLKEY}
|
||||
!!! failure "Removed in v3"
|
||||
|
||||
: Path to the client SSL private key used when connecting securely.
|
||||
Use [`PAPERLESS_DB_OPTIONS`](#PAPERLESS_DB_OPTIONS) instead.
|
||||
|
||||
See [the official documentation about
|
||||
sslmode for PostgreSQL](https://www.postgresql.org/docs/current/libpq-ssl.html).
|
||||
```bash title="PostgreSQL"
|
||||
PAPERLESS_DB_OPTIONS="sslrootcert=/path/to/ca.pem"
|
||||
```
|
||||
|
||||
See [the official documentation about
|
||||
sslmode for MySQL and MariaDB](https://dev.mysql.com/doc/refman/8.0/en/connection-options.html#option_general_ssl-key).
|
||||
```bash title="MariaDB"
|
||||
PAPERLESS_DB_OPTIONS="ssl.ca=/path/to/ca.pem"
|
||||
```
|
||||
|
||||
Changes the location of `postgresql.key`.
|
||||
#### ~~[`PAPERLESS_DBSSLCERT`](#PAPERLESS_DBSSLCERT)~~ {#PAPERLESS_DBSSLCERT}
|
||||
|
||||
Defaults to unset, using the standard location in the home directory.
|
||||
!!! failure "Removed in v3"
|
||||
|
||||
#### [`PAPERLESS_DB_TIMEOUT=<int>`](#PAPERLESS_DB_TIMEOUT) {#PAPERLESS_DB_TIMEOUT}
|
||||
Use [`PAPERLESS_DB_OPTIONS`](#PAPERLESS_DB_OPTIONS) instead.
|
||||
|
||||
: Sets how long a database connection should wait before timing out.
|
||||
```bash title="PostgreSQL"
|
||||
PAPERLESS_DB_OPTIONS="sslcert=/path/to/client.crt"
|
||||
```
|
||||
|
||||
For SQLite, this sets how long to wait if the database is locked.
|
||||
For PostgreSQL or MariaDB, this sets the connection timeout.
|
||||
```bash title="MariaDB"
|
||||
PAPERLESS_DB_OPTIONS="ssl.cert=/path/to/client.crt"
|
||||
```
|
||||
|
||||
Defaults to unset, which uses Django’s built-in defaults.
|
||||
#### ~~[`PAPERLESS_DBSSLKEY`](#PAPERLESS_DBSSLKEY)~~ {#PAPERLESS_DBSSLKEY}
|
||||
|
||||
#### [`PAPERLESS_DB_POOLSIZE=<int>`](#PAPERLESS_DB_POOLSIZE) {#PAPERLESS_DB_POOLSIZE}
|
||||
!!! failure "Removed in v3"
|
||||
|
||||
: Defines the maximum number of database connections to keep in the pool.
|
||||
Use [`PAPERLESS_DB_OPTIONS`](#PAPERLESS_DB_OPTIONS) instead.
|
||||
|
||||
Only applies to PostgreSQL. This setting is ignored for other database engines.
|
||||
```bash title="PostgreSQL"
|
||||
PAPERLESS_DB_OPTIONS="sslkey=/path/to/client.key"
|
||||
```
|
||||
|
||||
The value must be greater than or equal to 1 to be used.
|
||||
Defaults to unset, which disables connection pooling.
|
||||
```bash title="MariaDB"
|
||||
PAPERLESS_DB_OPTIONS="ssl.key=/path/to/client.key"
|
||||
```
|
||||
|
||||
!!! note
|
||||
#### ~~[`PAPERLESS_DB_TIMEOUT`](#PAPERLESS_DB_TIMEOUT)~~ {#PAPERLESS_DB_TIMEOUT}
|
||||
|
||||
A pool of 8-10 connections per worker is typically sufficient.
|
||||
If you encounter error messages such as `couldn't get a connection`
|
||||
or database connection timeouts, you probably need to increase the pool size.
|
||||
!!! failure "Removed in v3"
|
||||
|
||||
!!! warning
|
||||
Make sure your PostgreSQL `max_connections` setting is large enough to handle the connection pools:
|
||||
`(NB_PAPERLESS_WORKERS + NB_CELERY_WORKERS) × POOL_SIZE + SAFETY_MARGIN`. For example, with
|
||||
4 Paperless workers and 2 Celery workers, and a pool size of 8:``(4 + 2) × 8 + 10 = 58`,
|
||||
so `max_connections = 60` (or even more) is appropriate.
|
||||
Use [`PAPERLESS_DB_OPTIONS`](#PAPERLESS_DB_OPTIONS) instead.
|
||||
|
||||
This assumes only Paperless-ngx connects to your PostgreSQL instance. If you have other applications,
|
||||
you should increase `max_connections` accordingly.
|
||||
```bash title="SQLite"
|
||||
PAPERLESS_DB_OPTIONS="timeout=30"
|
||||
```
|
||||
|
||||
```bash title="PostgreSQL or MariaDB"
|
||||
PAPERLESS_DB_OPTIONS="connect_timeout=30"
|
||||
```
|
||||
|
||||
#### ~~[`PAPERLESS_DB_POOLSIZE`](#PAPERLESS_DB_POOLSIZE)~~ {#PAPERLESS_DB_POOLSIZE}
|
||||
|
||||
!!! failure "Removed in v3"
|
||||
|
||||
Use [`PAPERLESS_DB_OPTIONS`](#PAPERLESS_DB_OPTIONS) instead.
|
||||
|
||||
```bash
|
||||
PAPERLESS_DB_OPTIONS="pool.max_size=10"
|
||||
```
|
||||
|
||||
#### [`PAPERLESS_DB_READ_CACHE_ENABLED=<bool>`](#PAPERLESS_DB_READ_CACHE_ENABLED) {#PAPERLESS_DB_READ_CACHE_ENABLED}
|
||||
|
||||
|
||||
@@ -48,3 +48,58 @@ The `CONSUMER_BARCODE_SCANNER` setting has been removed. zxing-cpp is now the on
|
||||
reliability.
|
||||
- The `libzbar0` / `libzbar-dev` system packages are no longer required and can be removed from any custom Docker
|
||||
images or host installations.
|
||||
|
||||
## Database Engine
|
||||
|
||||
`PAPERLESS_DBENGINE` is now required to use PostgreSQL or MariaDB. Previously, the
|
||||
engine was inferred from the presence of `PAPERLESS_DBHOST`, with `PAPERLESS_DBENGINE`
|
||||
only needed to select MariaDB over PostgreSQL.
|
||||
|
||||
SQLite users require no changes, though they may explicitly set their engine if desired.
|
||||
|
||||
#### Action Required
|
||||
|
||||
PostgreSQL and MariaDB users must add `PAPERLESS_DBENGINE` to their environment:
|
||||
|
||||
```yaml
|
||||
# v2 (PostgreSQL inferred from PAPERLESS_DBHOST)
|
||||
PAPERLESS_DBHOST: postgres
|
||||
|
||||
# v3 (engine must be explicit)
|
||||
PAPERLESS_DBENGINE: postgresql
|
||||
PAPERLESS_DBHOST: postgres
|
||||
```
|
||||
|
||||
See [`PAPERLESS_DBENGINE`](configuration.md#PAPERLESS_DBENGINE) for accepted values.
|
||||
|
||||
## Database Advanced Options
|
||||
|
||||
The individual SSL, timeout, and pooling variables have been removed in favor of a
|
||||
single [`PAPERLESS_DB_OPTIONS`](configuration.md#PAPERLESS_DB_OPTIONS) string. This
|
||||
consolidates a growing set of engine-specific variables into one place, and allows
|
||||
any option supported by the underlying database driver to be set without requiring a
|
||||
dedicated environment variable for each.
|
||||
|
||||
The removed variables and their replacements are:
|
||||
|
||||
| Removed Variable | Replacement in `PAPERLESS_DB_OPTIONS` |
|
||||
| ------------------------- | ---------------------------------------------------------------------------- |
|
||||
| `PAPERLESS_DBSSLMODE` | `sslmode=<value>` (PostgreSQL) or `ssl_mode=<value>` (MariaDB) |
|
||||
| `PAPERLESS_DBSSLROOTCERT` | `sslrootcert=<path>` (PostgreSQL) or `ssl.ca=<path>` (MariaDB) |
|
||||
| `PAPERLESS_DBSSLCERT` | `sslcert=<path>` (PostgreSQL) or `ssl.cert=<path>` (MariaDB) |
|
||||
| `PAPERLESS_DBSSLKEY` | `sslkey=<path>` (PostgreSQL) or `ssl.key=<path>` (MariaDB) |
|
||||
| `PAPERLESS_DB_POOLSIZE` | `pool.max_size=<value>` (PostgreSQL only) |
|
||||
| `PAPERLESS_DB_TIMEOUT` | `timeout=<value>` (SQLite) or `connect_timeout=<value>` (PostgreSQL/MariaDB) |
|
||||
|
||||
The deprecated variables will continue to function for now but will be removed in a
|
||||
future release. A deprecation warning is logged at startup for each deprecated variable
|
||||
that is still set.
|
||||
|
||||
#### Action Required
|
||||
|
||||
Users with any of the deprecated variables set should migrate to `PAPERLESS_DB_OPTIONS`.
|
||||
Multiple options are combined in a single value:
|
||||
|
||||
```bash
|
||||
PAPERLESS_DB_OPTIONS="sslmode=require;sslrootcert=/certs/ca.pem;pool.max_size=10"
|
||||
```
|
||||
@@ -504,9 +504,8 @@ installation. Keep these points in mind:
|
||||
- Read the [changelog](changelog.md) and
|
||||
take note of breaking changes.
|
||||
- Decide whether to stay on SQLite or migrate to PostgreSQL.
|
||||
See [documentation](#sqlite_to_psql) for details on moving data
|
||||
from SQLite to PostgreSQL. Both work fine with
|
||||
Paperless. However, if you already have a database server running
|
||||
Both work fine with Paperless-ngx.
|
||||
However, if you already have a database server running
|
||||
for other services, you might as well use it for Paperless as well.
|
||||
- The task scheduler of Paperless, which is used to execute periodic
|
||||
tasks such as email checking and maintenance, requires a
|
||||
|
||||
@@ -618,6 +618,7 @@ applied. You can use the following placeholders in the template with any trigger
|
||||
- `{{original_filename}}`: original file name without extension
|
||||
- `{{filename}}`: current file name without extension
|
||||
- `{{doc_title}}`: current document title (cannot be used in title assignment)
|
||||
- `{{version_label}}`: the document version label (empty if not explicitly set)
|
||||
|
||||
The following placeholders are only available for "added" or "updated" triggers
|
||||
|
||||
@@ -626,7 +627,7 @@ The following placeholders are only available for "added" or "updated" triggers
|
||||
- `{{created_year_short}}`: created year
|
||||
- `{{created_month}}`: created month
|
||||
- `{{created_month_name}}`: created month name
|
||||
- `{created_month_name_short}}`: created month short name
|
||||
- `{{created_month_name_short}}`: created month short name
|
||||
- `{{created_day}}`: created day
|
||||
- `{{created_time}}`: created time in HH:MM format
|
||||
- `{{doc_url}}`: URL to the document in the web UI. Requires the `PAPERLESS_URL` setting to be set.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "paperless-ngx"
|
||||
version = "2.20.8"
|
||||
version = "2.20.9"
|
||||
description = "A community-supported supercharged document management system: scan, index and archive all your physical documents"
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.10"
|
||||
|
||||
@@ -503,14 +503,29 @@
|
||||
<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 context-type="linenumber">26</context>
|
||||
</context-group>
|
||||
</trans-unit>
|
||||
<trans-unit id="7808756054397155068" datatype="html">
|
||||
<source>Reset</source>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/components/admin/config/config.component.html</context>
|
||||
<context context-type="linenumber">30</context>
|
||||
</context-group>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/components/admin/config/config.component.html</context>
|
||||
<context context-type="linenumber">31</context>
|
||||
</context-group>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/components/admin/settings/settings.component.html</context>
|
||||
<context context-type="linenumber">136</context>
|
||||
</context-group>
|
||||
</trans-unit>
|
||||
<trans-unit id="2180291763949669799" datatype="html">
|
||||
<source>Enable</source>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/components/admin/config/config.component.html</context>
|
||||
<context context-type="linenumber">34</context>
|
||||
<context context-type="linenumber">39</context>
|
||||
</context-group>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/components/common/profile-edit-dialog/profile-edit-dialog.component.html</context>
|
||||
@@ -521,7 +536,7 @@
|
||||
<source>Discard</source>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/components/admin/config/config.component.html</context>
|
||||
<context context-type="linenumber">57</context>
|
||||
<context context-type="linenumber">62</context>
|
||||
</context-group>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.html</context>
|
||||
@@ -532,7 +547,7 @@
|
||||
<source>Save</source>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/components/admin/config/config.component.html</context>
|
||||
<context context-type="linenumber">60</context>
|
||||
<context context-type="linenumber">65</context>
|
||||
</context-group>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/components/admin/settings/settings.component.html</context>
|
||||
@@ -958,13 +973,6 @@
|
||||
<context context-type="linenumber">129</context>
|
||||
</context-group>
|
||||
</trans-unit>
|
||||
<trans-unit id="7808756054397155068" datatype="html">
|
||||
<source>Reset</source>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/components/admin/settings/settings.component.html</context>
|
||||
<context context-type="linenumber">136</context>
|
||||
</context-group>
|
||||
</trans-unit>
|
||||
<trans-unit id="6760166989231109310" datatype="html">
|
||||
<source>Global search</source>
|
||||
<context-group purpose="location">
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "paperless-ngx-ui",
|
||||
"version": "2.20.8",
|
||||
"version": "2.20.9",
|
||||
"scripts": {
|
||||
"preinstall": "npx only-allow pnpm",
|
||||
"ng": "ng",
|
||||
|
||||
@@ -19,13 +19,18 @@
|
||||
<div class="col">
|
||||
<div class="card bg-light">
|
||||
<div class="card-body">
|
||||
<div class="card-title">
|
||||
<h6>
|
||||
{{option.title}}
|
||||
<a class="btn btn-sm btn-link" title="Read the documentation about this setting" i18n-title [href]="getDocsUrl(option.config_key)" target="_blank" referrerpolicy="no-referrer">
|
||||
<i-bs name="info-circle"></i-bs>
|
||||
</a>
|
||||
<div class="card-title d-flex align-items-center">
|
||||
<h6 class="mb-0">
|
||||
{{option.title}}
|
||||
</h6>
|
||||
<a class="btn btn-sm btn-link" title="Read the documentation about this setting" i18n-title [href]="getDocsUrl(option.config_key)" target="_blank" referrerpolicy="no-referrer">
|
||||
<i-bs name="info-circle"></i-bs>
|
||||
</a>
|
||||
@if (isSet(option.key)) {
|
||||
<button type="button" class="btn btn-sm btn-link text-danger ms-auto pe-0" title="Reset" i18n-title (click)="resetOption(option.key)">
|
||||
<i-bs class="me-1" name="x"></i-bs><ng-container i18n>Reset</ng-container>
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
<div class="mb-n3">
|
||||
@switch (option.type) {
|
||||
|
||||
@@ -144,4 +144,18 @@ describe('ConfigComponent', () => {
|
||||
component.uploadFile(new File([], 'test.png'), 'app_logo')
|
||||
expect(initSpy).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should reset option to null', () => {
|
||||
component.configForm.patchValue({ output_type: OutputTypeConfig.PDF_A })
|
||||
expect(component.isSet('output_type')).toBeTruthy()
|
||||
component.resetOption('output_type')
|
||||
expect(component.configForm.get('output_type').value).toBeNull()
|
||||
expect(component.isSet('output_type')).toBeFalsy()
|
||||
component.configForm.patchValue({ app_title: 'Test Title' })
|
||||
component.resetOption('app_title')
|
||||
expect(component.configForm.get('app_title').value).toBeNull()
|
||||
component.configForm.patchValue({ barcodes_enabled: true })
|
||||
component.resetOption('barcodes_enabled')
|
||||
expect(component.configForm.get('barcodes_enabled').value).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -210,4 +210,12 @@ export class ConfigComponent
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
public isSet(key: string): boolean {
|
||||
return this.configForm.get(key).value != null
|
||||
}
|
||||
|
||||
public resetOption(key: string) {
|
||||
this.configForm.get(key).setValue(null)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -65,7 +65,6 @@ import { TagService } from 'src/app/services/rest/tag.service'
|
||||
import { UserService } from 'src/app/services/rest/user.service'
|
||||
import { SettingsService } from 'src/app/services/settings.service'
|
||||
import { ToastService } from 'src/app/services/toast.service'
|
||||
import { WebsocketStatusService } from 'src/app/services/websocket-status.service'
|
||||
import { environment } from 'src/environments/environment'
|
||||
import { ConfirmDialogComponent } from '../common/confirm-dialog/confirm-dialog.component'
|
||||
import { PasswordRemovalConfirmDialogComponent } from '../common/confirm-dialog/password-removal-confirm-dialog/password-removal-confirm-dialog.component'
|
||||
@@ -84,9 +83,9 @@ const doc: Document = {
|
||||
storage_path: 31,
|
||||
tags: [41, 42, 43],
|
||||
content: 'text content',
|
||||
added: new Date('May 4, 2014 03:24:00').toISOString(),
|
||||
created: new Date('May 4, 2014 03:24:00').toISOString(),
|
||||
modified: new Date('May 4, 2014 03:24:00').toISOString(),
|
||||
added: new Date('May 4, 2014 03:24:00'),
|
||||
created: new Date('May 4, 2014 03:24:00'),
|
||||
modified: new Date('May 4, 2014 03:24:00'),
|
||||
archive_serial_number: null,
|
||||
original_file_name: 'file.pdf',
|
||||
owner: null,
|
||||
@@ -328,29 +327,6 @@ describe('DocumentDetailComponent', () => {
|
||||
expect(component.activeNavID).toEqual(component.DocumentDetailNavIDs.Notes)
|
||||
})
|
||||
|
||||
it('should switch from preview to details when pdf preview enters the DOM', fakeAsync(() => {
|
||||
component.nav = {
|
||||
activeId: component.DocumentDetailNavIDs.Preview,
|
||||
select: jest.fn(),
|
||||
} as any
|
||||
;(component as any).pdfPreview = {
|
||||
nativeElement: { offsetParent: {} },
|
||||
}
|
||||
|
||||
tick()
|
||||
expect(component.nav.select).toHaveBeenCalledWith(
|
||||
component.DocumentDetailNavIDs.Details
|
||||
)
|
||||
}))
|
||||
|
||||
it('should forward title key up value to titleSubject', () => {
|
||||
const subjectSpy = jest.spyOn(component.titleSubject, 'next')
|
||||
|
||||
component.titleKeyUp({ target: { value: 'Updated title' } })
|
||||
|
||||
expect(subjectSpy).toHaveBeenCalledWith('Updated title')
|
||||
})
|
||||
|
||||
it('should change url on tab switch', () => {
|
||||
initNormally()
|
||||
const navigateSpy = jest.spyOn(router, 'navigate')
|
||||
@@ -548,7 +524,7 @@ describe('DocumentDetailComponent', () => {
|
||||
jest.spyOn(documentService, 'get').mockReturnValue(
|
||||
of({
|
||||
...doc,
|
||||
modified: '2024-01-02T00:00:00Z',
|
||||
modified: new Date('2024-01-02T00:00:00Z'),
|
||||
duplicate_documents: updatedDuplicates,
|
||||
})
|
||||
)
|
||||
@@ -1410,21 +1386,17 @@ describe('DocumentDetailComponent', () => {
|
||||
expect(errorSpy).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should show incoming update modal when open local draft is older than backend on init', () => {
|
||||
it('should warn when open document does not match doc retrieved from backend on init', () => {
|
||||
let openModal: NgbModalRef
|
||||
modalService.activeInstances.subscribe((modals) => (openModal = modals[0]))
|
||||
const modalSpy = jest.spyOn(modalService, 'open')
|
||||
const openDoc = Object.assign({}, doc, {
|
||||
__changedFields: ['title'],
|
||||
})
|
||||
const openDoc = Object.assign({}, doc)
|
||||
// simulate a document being modified elsewhere and db updated
|
||||
const remoteDoc = Object.assign({}, doc, {
|
||||
modified: new Date(new Date(doc.modified).getTime() + 1000).toISOString(),
|
||||
})
|
||||
doc.modified = new Date()
|
||||
jest
|
||||
.spyOn(activatedRoute, 'paramMap', 'get')
|
||||
.mockReturnValue(of(convertToParamMap({ id: 3, section: 'details' })))
|
||||
jest.spyOn(documentService, 'get').mockReturnValueOnce(of(remoteDoc))
|
||||
jest.spyOn(documentService, 'get').mockReturnValueOnce(of(doc))
|
||||
jest.spyOn(openDocumentsService, 'getOpenDocument').mockReturnValue(openDoc)
|
||||
jest.spyOn(customFieldsService, 'listAll').mockReturnValue(
|
||||
of({
|
||||
@@ -1434,185 +1406,11 @@ describe('DocumentDetailComponent', () => {
|
||||
})
|
||||
)
|
||||
fixture.detectChanges() // calls ngOnInit
|
||||
expect(modalSpy).toHaveBeenCalledWith(ConfirmDialogComponent, {
|
||||
backdrop: 'static',
|
||||
})
|
||||
expect(modalSpy).toHaveBeenCalledWith(ConfirmDialogComponent)
|
||||
const closeSpy = jest.spyOn(openModal, 'close')
|
||||
const confirmDialog = openModal.componentInstance as ConfirmDialogComponent
|
||||
expect(confirmDialog.messageBold).toContain('Document was updated at')
|
||||
})
|
||||
|
||||
it('should react to websocket document updated notifications', () => {
|
||||
initNormally()
|
||||
const updateMessage = {
|
||||
document_id: component.documentId,
|
||||
modified: '2026-02-17T00:00:00Z',
|
||||
owner_id: 1,
|
||||
}
|
||||
const handleSpy = jest
|
||||
.spyOn(component as any, 'handleIncomingDocumentUpdated')
|
||||
.mockImplementation(() => {})
|
||||
const websocketStatusService = TestBed.inject(WebsocketStatusService)
|
||||
|
||||
websocketStatusService.handleDocumentUpdated(updateMessage)
|
||||
|
||||
expect(handleSpy).toHaveBeenCalledWith(updateMessage)
|
||||
})
|
||||
|
||||
it('should queue incoming update while network is active and flush after', () => {
|
||||
initNormally()
|
||||
const loadSpy = jest.spyOn(component as any, 'loadDocument')
|
||||
const toastSpy = jest.spyOn(toastService, 'showInfo')
|
||||
|
||||
component.networkActive = true
|
||||
;(component as any).handleIncomingDocumentUpdated({
|
||||
document_id: component.documentId,
|
||||
modified: '2026-02-17T00:00:00Z',
|
||||
})
|
||||
|
||||
expect(loadSpy).not.toHaveBeenCalled()
|
||||
|
||||
component.networkActive = false
|
||||
;(component as any).flushPendingIncomingUpdate()
|
||||
|
||||
expect(loadSpy).toHaveBeenCalledWith(component.documentId, true)
|
||||
expect(toastSpy).toHaveBeenCalledWith(
|
||||
'Document reloaded with latest changes.'
|
||||
)
|
||||
})
|
||||
|
||||
it('should ignore queued incoming update matching local save modified', () => {
|
||||
initNormally()
|
||||
const loadSpy = jest.spyOn(component as any, 'loadDocument')
|
||||
const toastSpy = jest.spyOn(toastService, 'showInfo')
|
||||
|
||||
component.networkActive = true
|
||||
;(component as any).lastLocalSaveModified = '2026-02-17T00:00:00+00:00'
|
||||
;(component as any).handleIncomingDocumentUpdated({
|
||||
document_id: component.documentId,
|
||||
modified: '2026-02-17T00:00:00+00:00',
|
||||
})
|
||||
|
||||
component.networkActive = false
|
||||
;(component as any).flushPendingIncomingUpdate()
|
||||
|
||||
expect(loadSpy).not.toHaveBeenCalled()
|
||||
expect(toastSpy).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should clear pdf source if preview URL is empty', () => {
|
||||
component.pdfSource = { url: '/preview', password: 'secret' } as any
|
||||
component.previewUrl = null
|
||||
;(component as any).updatePdfSource()
|
||||
|
||||
expect(component.pdfSource).toEqual({ url: null, password: undefined })
|
||||
})
|
||||
|
||||
it('should close incoming update modal if one is open', () => {
|
||||
const modalRef = { close: jest.fn() } as unknown as NgbModalRef
|
||||
;(component as any).incomingUpdateModal = modalRef
|
||||
;(component as any).closeIncomingUpdateModal()
|
||||
|
||||
expect(modalRef.close).toHaveBeenCalled()
|
||||
expect((component as any).incomingUpdateModal).toBeNull()
|
||||
})
|
||||
|
||||
it('should reload remote version when incoming update modal is confirmed', async () => {
|
||||
let openModal: NgbModalRef
|
||||
modalService.activeInstances.subscribe((modals) => (openModal = modals[0]))
|
||||
const reloadSpy = jest
|
||||
.spyOn(component as any, 'reloadRemoteVersion')
|
||||
.mockImplementation(() => {})
|
||||
|
||||
;(component as any).showIncomingUpdateModal('2026-02-17T00:00:00Z')
|
||||
|
||||
const dialog = openModal.componentInstance as ConfirmDialogComponent
|
||||
dialog.confirmClicked.next()
|
||||
await openModal.result
|
||||
|
||||
expect(dialog.buttonsEnabled).toBe(false)
|
||||
expect(reloadSpy).toHaveBeenCalled()
|
||||
expect((component as any).incomingUpdateModal).toBeNull()
|
||||
})
|
||||
|
||||
it('should overwrite open document state when loading remote version with force', () => {
|
||||
const openDoc = Object.assign({}, doc, {
|
||||
title: 'Locally edited title',
|
||||
__changedFields: ['title'],
|
||||
})
|
||||
const remoteDoc = Object.assign({}, doc, {
|
||||
title: 'Remote title',
|
||||
modified: '2026-02-17T00:00:00Z',
|
||||
})
|
||||
jest.spyOn(documentService, 'get').mockReturnValue(of(remoteDoc))
|
||||
jest.spyOn(documentService, 'getMetadata').mockReturnValue(
|
||||
of({
|
||||
has_archive_version: false,
|
||||
original_mime_type: 'application/pdf',
|
||||
})
|
||||
)
|
||||
jest.spyOn(documentService, 'getSuggestions').mockReturnValue(
|
||||
of({
|
||||
suggested_tags: [],
|
||||
suggested_document_types: [],
|
||||
suggested_correspondents: [],
|
||||
})
|
||||
)
|
||||
jest.spyOn(openDocumentsService, 'getOpenDocument').mockReturnValue(openDoc)
|
||||
const setDirtySpy = jest.spyOn(openDocumentsService, 'setDirty')
|
||||
const saveSpy = jest.spyOn(openDocumentsService, 'save')
|
||||
|
||||
;(component as any).loadDocument(doc.id, true)
|
||||
|
||||
expect(openDoc.title).toEqual('Remote title')
|
||||
expect(openDoc.__changedFields).toEqual([])
|
||||
expect(setDirtySpy).toHaveBeenCalledWith(openDoc, false)
|
||||
expect(saveSpy).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should ignore incoming update for a different document id', () => {
|
||||
initNormally()
|
||||
const loadSpy = jest.spyOn(component as any, 'loadDocument')
|
||||
|
||||
;(component as any).handleIncomingDocumentUpdated({
|
||||
document_id: component.documentId + 1,
|
||||
modified: '2026-02-17T00:00:00Z',
|
||||
})
|
||||
|
||||
expect(loadSpy).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should show incoming update modal when local document has unsaved edits', () => {
|
||||
initNormally()
|
||||
jest.spyOn(openDocumentsService, 'isDirty').mockReturnValue(true)
|
||||
const modalSpy = jest
|
||||
.spyOn(component as any, 'showIncomingUpdateModal')
|
||||
.mockImplementation(() => {})
|
||||
|
||||
;(component as any).handleIncomingDocumentUpdated({
|
||||
document_id: component.documentId,
|
||||
modified: '2026-02-17T00:00:00Z',
|
||||
})
|
||||
|
||||
expect(modalSpy).toHaveBeenCalledWith('2026-02-17T00:00:00Z')
|
||||
})
|
||||
|
||||
it('should reload current document and show toast when reloading remote version', () => {
|
||||
component.documentId = doc.id
|
||||
const closeModalSpy = jest
|
||||
.spyOn(component as any, 'closeIncomingUpdateModal')
|
||||
.mockImplementation(() => {})
|
||||
const loadSpy = jest
|
||||
.spyOn(component as any, 'loadDocument')
|
||||
.mockImplementation(() => {})
|
||||
const notifySpy = jest.spyOn(component.docChangeNotifier, 'next')
|
||||
const toastSpy = jest.spyOn(toastService, 'showInfo')
|
||||
|
||||
;(component as any).reloadRemoteVersion()
|
||||
|
||||
expect(closeModalSpy).toHaveBeenCalled()
|
||||
expect(notifySpy).toHaveBeenCalledWith(doc.id)
|
||||
expect(loadSpy).toHaveBeenCalledWith(doc.id, true)
|
||||
expect(toastSpy).toHaveBeenCalledWith('Document reloaded.')
|
||||
confirmDialog.confirmClicked.next(confirmDialog)
|
||||
expect(closeSpy).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should change preview element by render type', () => {
|
||||
@@ -1923,14 +1721,6 @@ describe('DocumentDetailComponent', () => {
|
||||
expect(component.createDisabled(DataType.Tag)).toBeFalsy()
|
||||
})
|
||||
|
||||
it('should expose add permission via userCanAdd getter', () => {
|
||||
currentUserCan = true
|
||||
expect(component.userCanAdd).toBeTruthy()
|
||||
|
||||
currentUserCan = false
|
||||
expect(component.userCanAdd).toBeFalsy()
|
||||
})
|
||||
|
||||
it('should call tryRenderTiff when no archive and file is tiff', () => {
|
||||
initNormally()
|
||||
const tiffRenderSpy = jest.spyOn(
|
||||
|
||||
@@ -13,7 +13,6 @@ import {
|
||||
NgbDateStruct,
|
||||
NgbDropdownModule,
|
||||
NgbModal,
|
||||
NgbModalRef,
|
||||
NgbNav,
|
||||
NgbNavChangeEvent,
|
||||
NgbNavModule,
|
||||
@@ -81,7 +80,6 @@ import { TagService } from 'src/app/services/rest/tag.service'
|
||||
import { UserService } from 'src/app/services/rest/user.service'
|
||||
import { SettingsService } from 'src/app/services/settings.service'
|
||||
import { ToastService } from 'src/app/services/toast.service'
|
||||
import { WebsocketStatusService } from 'src/app/services/websocket-status.service'
|
||||
import { getFilenameFromContentDisposition } from 'src/app/utils/http'
|
||||
import { ISODateAdapter } from 'src/app/utils/ngb-iso-date-adapter'
|
||||
import * as UTIF from 'utif'
|
||||
@@ -145,11 +143,6 @@ enum ContentRenderType {
|
||||
TIFF = 'tiff',
|
||||
}
|
||||
|
||||
interface IncomingDocumentUpdate {
|
||||
document_id: number
|
||||
modified: string
|
||||
}
|
||||
|
||||
@Component({
|
||||
selector: 'pngx-document-detail',
|
||||
templateUrl: './document-detail.component.html',
|
||||
@@ -215,7 +208,6 @@ export class DocumentDetailComponent
|
||||
private componentRouterService = inject(ComponentRouterService)
|
||||
private deviceDetectorService = inject(DeviceDetectorService)
|
||||
private savedViewService = inject(SavedViewService)
|
||||
private readonly websocketStatusService = inject(WebsocketStatusService)
|
||||
|
||||
@ViewChild('inputTitle')
|
||||
titleInput: TextComponent
|
||||
@@ -275,9 +267,6 @@ export class DocumentDetailComponent
|
||||
isDirty$: Observable<boolean>
|
||||
unsubscribeNotifier: Subject<any> = new Subject()
|
||||
docChangeNotifier: Subject<any> = new Subject()
|
||||
private incomingUpdateModal: NgbModalRef
|
||||
private pendingIncomingUpdate: IncomingDocumentUpdate
|
||||
private lastLocalSaveModified: string | null = null
|
||||
|
||||
requiresPassword: boolean = false
|
||||
password: string
|
||||
@@ -486,12 +475,9 @@ export class DocumentDetailComponent
|
||||
)
|
||||
}
|
||||
|
||||
private loadDocument(documentId: number, forceRemote: boolean = false): void {
|
||||
private loadDocument(documentId: number): void {
|
||||
let redirectedToRoot = false
|
||||
this.closeIncomingUpdateModal()
|
||||
this.pendingIncomingUpdate = null
|
||||
this.selectedVersionId = documentId
|
||||
this.lastLocalSaveModified = null
|
||||
this.previewUrl = this.documentsService.getPreviewUrl(
|
||||
this.selectedVersionId
|
||||
)
|
||||
@@ -559,25 +545,21 @@ export class DocumentDetailComponent
|
||||
openDocument.duplicate_documents = doc.duplicate_documents
|
||||
this.openDocumentService.save()
|
||||
}
|
||||
let useDoc = openDocument || doc
|
||||
if (openDocument && forceRemote) {
|
||||
Object.assign(openDocument, doc)
|
||||
openDocument.__changedFields = []
|
||||
this.openDocumentService.setDirty(openDocument, false)
|
||||
this.openDocumentService.save()
|
||||
useDoc = openDocument
|
||||
} else if (openDocument) {
|
||||
if (new Date(doc.modified) > new Date(openDocument.modified)) {
|
||||
if (this.hasLocalEdits(openDocument)) {
|
||||
this.showIncomingUpdateModal(doc.modified)
|
||||
} else {
|
||||
// No local edits to preserve, so keep the tab in sync automatically.
|
||||
Object.assign(openDocument, doc)
|
||||
openDocument.__changedFields = []
|
||||
this.openDocumentService.setDirty(openDocument, false)
|
||||
this.openDocumentService.save()
|
||||
useDoc = openDocument
|
||||
}
|
||||
const useDoc = openDocument || doc
|
||||
if (openDocument) {
|
||||
if (
|
||||
new Date(doc.modified) > new Date(openDocument.modified) &&
|
||||
!this.modalService.hasOpenModals()
|
||||
) {
|
||||
const modal = this.modalService.open(ConfirmDialogComponent)
|
||||
modal.componentInstance.title = $localize`Document changes detected`
|
||||
modal.componentInstance.messageBold = $localize`The version of this document in your browser session appears older than the existing version.`
|
||||
modal.componentInstance.message = $localize`Saving the document here may overwrite other changes that were made. To restore the existing version, discard your changes or close the document.`
|
||||
modal.componentInstance.cancelBtnClass = 'visually-hidden'
|
||||
modal.componentInstance.btnCaption = $localize`Ok`
|
||||
modal.componentInstance.confirmClicked.subscribe(() =>
|
||||
modal.close()
|
||||
)
|
||||
}
|
||||
} else {
|
||||
this.openDocumentService
|
||||
@@ -608,98 +590,6 @@ export class DocumentDetailComponent
|
||||
})
|
||||
}
|
||||
|
||||
private hasLocalEdits(doc: Document): boolean {
|
||||
return (
|
||||
this.openDocumentService.isDirty(doc) || !!doc.__changedFields?.length
|
||||
)
|
||||
}
|
||||
|
||||
private showIncomingUpdateModal(modified: string): void {
|
||||
if (this.incomingUpdateModal) return
|
||||
|
||||
const modal = this.modalService.open(ConfirmDialogComponent, {
|
||||
backdrop: 'static',
|
||||
})
|
||||
this.incomingUpdateModal = modal
|
||||
|
||||
let formattedModified = null
|
||||
const parsed = new Date(modified)
|
||||
formattedModified = parsed.toLocaleString()
|
||||
|
||||
modal.componentInstance.title = $localize`Document was updated`
|
||||
modal.componentInstance.messageBold = $localize`Document was updated at ${formattedModified}.`
|
||||
modal.componentInstance.message = $localize`Reload to discard your local unsaved edits and load the latest remote version.`
|
||||
modal.componentInstance.btnClass = 'btn-warning'
|
||||
modal.componentInstance.btnCaption = $localize`Reload`
|
||||
modal.componentInstance.cancelBtnCaption = $localize`Dismiss`
|
||||
|
||||
modal.componentInstance.confirmClicked.pipe(first()).subscribe(() => {
|
||||
modal.componentInstance.buttonsEnabled = false
|
||||
modal.close()
|
||||
this.reloadRemoteVersion()
|
||||
})
|
||||
modal.result.finally(() => {
|
||||
this.incomingUpdateModal = null
|
||||
})
|
||||
}
|
||||
|
||||
private closeIncomingUpdateModal() {
|
||||
if (!this.incomingUpdateModal) return
|
||||
this.incomingUpdateModal.close()
|
||||
this.incomingUpdateModal = null
|
||||
}
|
||||
|
||||
private flushPendingIncomingUpdate() {
|
||||
if (!this.pendingIncomingUpdate || this.networkActive) return
|
||||
const pendingUpdate = this.pendingIncomingUpdate
|
||||
this.pendingIncomingUpdate = null
|
||||
this.handleIncomingDocumentUpdated(pendingUpdate)
|
||||
}
|
||||
|
||||
private handleIncomingDocumentUpdated(data: IncomingDocumentUpdate): void {
|
||||
if (
|
||||
!this.documentId ||
|
||||
!this.document ||
|
||||
data.document_id !== this.documentId
|
||||
)
|
||||
return
|
||||
if (this.networkActive) {
|
||||
this.pendingIncomingUpdate = data
|
||||
return
|
||||
}
|
||||
// If modified timestamp of the incoming update is the same as the last local save,
|
||||
// we assume this update is from our own save and dont notify
|
||||
const incomingModified = data.modified
|
||||
if (
|
||||
incomingModified &&
|
||||
this.lastLocalSaveModified &&
|
||||
incomingModified === this.lastLocalSaveModified
|
||||
) {
|
||||
this.lastLocalSaveModified = null
|
||||
return
|
||||
}
|
||||
this.lastLocalSaveModified = null
|
||||
|
||||
if (this.openDocumentService.isDirty(this.document)) {
|
||||
this.showIncomingUpdateModal(data.modified)
|
||||
} else {
|
||||
this.docChangeNotifier.next(this.documentId)
|
||||
this.loadDocument(this.documentId, true)
|
||||
this.toastService.showInfo(
|
||||
$localize`Document reloaded with latest changes.`
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private reloadRemoteVersion() {
|
||||
if (!this.documentId) return
|
||||
|
||||
this.closeIncomingUpdateModal()
|
||||
this.docChangeNotifier.next(this.documentId)
|
||||
this.loadDocument(this.documentId, true)
|
||||
this.toastService.showInfo($localize`Document reloaded.`)
|
||||
}
|
||||
|
||||
ngOnInit(): void {
|
||||
this.setZoom(
|
||||
this.settings.get(SETTINGS_KEYS.PDF_VIEWER_ZOOM_SETTING) as PdfZoomScale
|
||||
@@ -758,11 +648,6 @@ export class DocumentDetailComponent
|
||||
|
||||
this.getCustomFields()
|
||||
|
||||
this.websocketStatusService
|
||||
.onDocumentUpdated()
|
||||
.pipe(takeUntil(this.unsubscribeNotifier))
|
||||
.subscribe((data) => this.handleIncomingDocumentUpdated(data))
|
||||
|
||||
this.route.paramMap
|
||||
.pipe(
|
||||
filter(
|
||||
@@ -1148,7 +1033,6 @@ export class DocumentDetailComponent
|
||||
)
|
||||
.subscribe({
|
||||
next: (doc) => {
|
||||
this.closeIncomingUpdateModal()
|
||||
Object.assign(this.document, doc)
|
||||
doc['permissions_form'] = {
|
||||
owner: doc.owner,
|
||||
@@ -1195,8 +1079,6 @@ export class DocumentDetailComponent
|
||||
.pipe(first())
|
||||
.subscribe({
|
||||
next: (docValues) => {
|
||||
this.closeIncomingUpdateModal()
|
||||
this.lastLocalSaveModified = docValues.modified ?? null
|
||||
// in case data changed while saving eg removing inbox_tags
|
||||
this.documentForm.patchValue(docValues)
|
||||
const newValues = Object.assign({}, this.documentForm.value)
|
||||
@@ -1211,19 +1093,16 @@ export class DocumentDetailComponent
|
||||
this.networkActive = false
|
||||
this.error = null
|
||||
if (close) {
|
||||
this.pendingIncomingUpdate = null
|
||||
this.close(() =>
|
||||
this.openDocumentService.refreshDocument(this.documentId)
|
||||
)
|
||||
} else {
|
||||
this.openDocumentService.refreshDocument(this.documentId)
|
||||
this.flushPendingIncomingUpdate()
|
||||
}
|
||||
this.savedViewService.maybeRefreshDocumentCounts()
|
||||
},
|
||||
error: (error) => {
|
||||
this.networkActive = false
|
||||
this.lastLocalSaveModified = null
|
||||
const canEdit =
|
||||
this.permissionsService.currentUserHasObjectPermissions(
|
||||
PermissionAction.Change,
|
||||
@@ -1243,7 +1122,6 @@ export class DocumentDetailComponent
|
||||
error
|
||||
)
|
||||
}
|
||||
this.flushPendingIncomingUpdate()
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -1280,11 +1158,8 @@ export class DocumentDetailComponent
|
||||
.pipe(first())
|
||||
.subscribe({
|
||||
next: ({ updateResult, nextDocId, closeResult }) => {
|
||||
this.closeIncomingUpdateModal()
|
||||
this.error = null
|
||||
this.networkActive = false
|
||||
this.pendingIncomingUpdate = null
|
||||
this.lastLocalSaveModified = null
|
||||
if (closeResult && updateResult && nextDocId) {
|
||||
this.router.navigate(['documents', nextDocId])
|
||||
this.titleInput?.focus()
|
||||
@@ -1292,10 +1167,8 @@ export class DocumentDetailComponent
|
||||
},
|
||||
error: (error) => {
|
||||
this.networkActive = false
|
||||
this.lastLocalSaveModified = null
|
||||
this.error = error.error
|
||||
this.toastService.showError($localize`Error saving document`, error)
|
||||
this.flushPendingIncomingUpdate()
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -1381,7 +1254,7 @@ export class DocumentDetailComponent
|
||||
.subscribe({
|
||||
next: () => {
|
||||
this.toastService.showInfo(
|
||||
$localize`Reprocess operation for "${this.document.title}" will begin in the background.`
|
||||
$localize`Reprocess operation for "${this.document.title}" will begin in the background. Close and re-open or reload this document after the operation has completed to see new content.`
|
||||
)
|
||||
if (modal) {
|
||||
modal.close()
|
||||
|
||||
@@ -57,7 +57,7 @@
|
||||
}
|
||||
</div>
|
||||
@for (version of versions; track version.id) {
|
||||
<div class="dropdown-item border-top px-0">
|
||||
<div class="dropdown-item border-top px-0" [class.pe-3]="versions.length === 1">
|
||||
<div class="d-flex align-items-center w-100 py-2 version-item">
|
||||
<div class="btn btn-link link-underline link-underline-opacity-0 d-flex align-items-center small text-start p-0 version-link"
|
||||
(click)="selectVersion(version.id)"
|
||||
@@ -88,7 +88,7 @@
|
||||
@if (version.version_label) {
|
||||
{{ version.version_label }}
|
||||
} @else {
|
||||
<span i18n>Version</span> #{{ version.id }}
|
||||
<span class="fst-italic"><ng-container i18n>Version</ng-container> {{ versions.length - $index }} <span class="text-muted small">(#{{ version.id }})</span></span>
|
||||
}
|
||||
</span>
|
||||
}
|
||||
|
||||
@@ -128,15 +128,15 @@ export interface Document extends ObjectWithPermissions {
|
||||
checksum?: string
|
||||
|
||||
// UTC
|
||||
created?: string // ISO string
|
||||
created?: Date
|
||||
|
||||
modified?: string // ISO string
|
||||
modified?: Date
|
||||
|
||||
added?: string // ISO string
|
||||
added?: Date
|
||||
|
||||
mime_type?: string
|
||||
|
||||
deleted_at?: string // ISO string
|
||||
deleted_at?: Date
|
||||
|
||||
original_file_name?: string
|
||||
|
||||
|
||||
@@ -1,7 +0,0 @@
|
||||
export interface WebsocketDocumentUpdatedMessage {
|
||||
document_id: number
|
||||
modified: string
|
||||
owner_id?: number
|
||||
users_can_view?: number[]
|
||||
groups_can_view?: number[]
|
||||
}
|
||||
@@ -416,42 +416,4 @@ describe('ConsumerStatusService', () => {
|
||||
websocketStatusService.disconnect()
|
||||
expect(deleted).toBeTruthy()
|
||||
})
|
||||
|
||||
it('should trigger updated subject on document updated', () => {
|
||||
let updated = false
|
||||
websocketStatusService.onDocumentUpdated().subscribe((data) => {
|
||||
updated = true
|
||||
expect(data.document_id).toEqual(12)
|
||||
})
|
||||
|
||||
websocketStatusService.connect()
|
||||
server.send({
|
||||
type: WebsocketStatusType.DOCUMENT_UPDATED,
|
||||
data: {
|
||||
document_id: 12,
|
||||
modified: '2026-02-17T00:00:00Z',
|
||||
owner_id: 1,
|
||||
},
|
||||
})
|
||||
|
||||
websocketStatusService.disconnect()
|
||||
expect(updated).toBeTruthy()
|
||||
})
|
||||
|
||||
it('should ignore document updated events the user cannot view', () => {
|
||||
let updated = false
|
||||
websocketStatusService.onDocumentUpdated().subscribe(() => {
|
||||
updated = true
|
||||
})
|
||||
|
||||
websocketStatusService.handleDocumentUpdated({
|
||||
document_id: 12,
|
||||
modified: '2026-02-17T00:00:00Z',
|
||||
owner_id: 2,
|
||||
users_can_view: [],
|
||||
groups_can_view: [],
|
||||
})
|
||||
|
||||
expect(updated).toBeFalsy()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -2,7 +2,6 @@ import { Injectable, inject } from '@angular/core'
|
||||
import { Subject } from 'rxjs'
|
||||
import { environment } from 'src/environments/environment'
|
||||
import { User } from '../data/user'
|
||||
import { WebsocketDocumentUpdatedMessage } from '../data/websocket-document-updated-message'
|
||||
import { WebsocketDocumentsDeletedMessage } from '../data/websocket-documents-deleted-message'
|
||||
import { WebsocketProgressMessage } from '../data/websocket-progress-message'
|
||||
import { SettingsService } from './settings.service'
|
||||
@@ -10,7 +9,6 @@ import { SettingsService } from './settings.service'
|
||||
export enum WebsocketStatusType {
|
||||
STATUS_UPDATE = 'status_update',
|
||||
DOCUMENTS_DELETED = 'documents_deleted',
|
||||
DOCUMENT_UPDATED = 'document_updated',
|
||||
}
|
||||
|
||||
// see ProgressStatusOptions in src/documents/plugins/helpers.py
|
||||
@@ -102,20 +100,17 @@ export enum UploadState {
|
||||
providedIn: 'root',
|
||||
})
|
||||
export class WebsocketStatusService {
|
||||
private readonly settingsService = inject(SettingsService)
|
||||
private settingsService = inject(SettingsService)
|
||||
|
||||
private statusWebSocket: WebSocket
|
||||
|
||||
private consumerStatus: FileStatus[] = []
|
||||
|
||||
private readonly documentDetectedSubject = new Subject<FileStatus>()
|
||||
private readonly documentConsumptionFinishedSubject =
|
||||
new Subject<FileStatus>()
|
||||
private readonly documentConsumptionFailedSubject = new Subject<FileStatus>()
|
||||
private readonly documentDeletedSubject = new Subject<boolean>()
|
||||
private readonly documentUpdatedSubject =
|
||||
new Subject<WebsocketDocumentUpdatedMessage>()
|
||||
private readonly connectionStatusSubject = new Subject<boolean>()
|
||||
private documentDetectedSubject = new Subject<FileStatus>()
|
||||
private documentConsumptionFinishedSubject = new Subject<FileStatus>()
|
||||
private documentConsumptionFailedSubject = new Subject<FileStatus>()
|
||||
private documentDeletedSubject = new Subject<boolean>()
|
||||
private connectionStatusSubject = new Subject<boolean>()
|
||||
|
||||
private get(taskId: string, filename?: string) {
|
||||
let status =
|
||||
@@ -181,10 +176,7 @@ export class WebsocketStatusService {
|
||||
data: messageData,
|
||||
}: {
|
||||
type: WebsocketStatusType
|
||||
data:
|
||||
| WebsocketProgressMessage
|
||||
| WebsocketDocumentsDeletedMessage
|
||||
| WebsocketDocumentUpdatedMessage
|
||||
data: WebsocketProgressMessage | WebsocketDocumentsDeletedMessage
|
||||
} = JSON.parse(ev.data)
|
||||
|
||||
switch (type) {
|
||||
@@ -192,12 +184,6 @@ export class WebsocketStatusService {
|
||||
this.documentDeletedSubject.next(true)
|
||||
break
|
||||
|
||||
case WebsocketStatusType.DOCUMENT_UPDATED:
|
||||
this.handleDocumentUpdated(
|
||||
messageData as WebsocketDocumentUpdatedMessage
|
||||
)
|
||||
break
|
||||
|
||||
case WebsocketStatusType.STATUS_UPDATE:
|
||||
this.handleProgressUpdate(messageData as WebsocketProgressMessage)
|
||||
break
|
||||
@@ -205,11 +191,7 @@ export class WebsocketStatusService {
|
||||
}
|
||||
}
|
||||
|
||||
private canViewMessage(messageData: {
|
||||
owner_id?: number
|
||||
users_can_view?: number[]
|
||||
groups_can_view?: number[]
|
||||
}): boolean {
|
||||
private canViewMessage(messageData: WebsocketProgressMessage): boolean {
|
||||
// see paperless.consumers.StatusConsumer._can_view
|
||||
const user: User = this.settingsService.currentUser
|
||||
return (
|
||||
@@ -269,15 +251,6 @@ export class WebsocketStatusService {
|
||||
}
|
||||
}
|
||||
|
||||
handleDocumentUpdated(messageData: WebsocketDocumentUpdatedMessage) {
|
||||
// fallback if backend didn't restrict message
|
||||
if (!this.canViewMessage(messageData)) {
|
||||
return
|
||||
}
|
||||
|
||||
this.documentUpdatedSubject.next(messageData)
|
||||
}
|
||||
|
||||
fail(status: FileStatus, message: string) {
|
||||
status.message = message
|
||||
status.phase = FileStatusPhase.FAILED
|
||||
@@ -331,10 +304,6 @@ export class WebsocketStatusService {
|
||||
return this.documentDeletedSubject
|
||||
}
|
||||
|
||||
onDocumentUpdated() {
|
||||
return this.documentUpdatedSubject
|
||||
}
|
||||
|
||||
onConnectionStatus() {
|
||||
return this.connectionStatusSubject.asObservable()
|
||||
}
|
||||
|
||||
@@ -6,7 +6,7 @@ export const environment = {
|
||||
apiVersion: '9', // match src/paperless/settings.py
|
||||
appTitle: 'Paperless-ngx',
|
||||
tag: 'prod',
|
||||
version: '2.20.8',
|
||||
version: '2.20.9',
|
||||
webSocketHost: window.location.host,
|
||||
webSocketProtocol: window.location.protocol == 'https:' ? 'wss:' : 'ws:',
|
||||
webSocketBaseUrl: base_url.pathname + 'ws/',
|
||||
|
||||
@@ -1,11 +0,0 @@
|
||||
import pytest
|
||||
from pytest_django.fixtures import SettingsWrapper
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def in_memory_channel_layers(settings: SettingsWrapper) -> None:
|
||||
settings.CHANNEL_LAYERS = {
|
||||
"default": {
|
||||
"BACKEND": "channels.layers.InMemoryChannelLayer",
|
||||
},
|
||||
}
|
||||
@@ -15,7 +15,6 @@ class DocumentsConfig(AppConfig):
|
||||
from documents.signals.handlers import add_to_index
|
||||
from documents.signals.handlers import run_workflows_added
|
||||
from documents.signals.handlers import run_workflows_updated
|
||||
from documents.signals.handlers import send_websocket_document_updated
|
||||
from documents.signals.handlers import set_correspondent
|
||||
from documents.signals.handlers import set_document_type
|
||||
from documents.signals.handlers import set_storage_path
|
||||
@@ -30,7 +29,6 @@ class DocumentsConfig(AppConfig):
|
||||
document_consumption_finished.connect(run_workflows_added)
|
||||
document_consumption_finished.connect(add_or_update_document_in_llm_index)
|
||||
document_updated.connect(run_workflows_updated)
|
||||
document_updated.connect(send_websocket_document_updated)
|
||||
|
||||
import documents.schema # noqa: F401
|
||||
|
||||
|
||||
@@ -715,6 +715,17 @@ class ConsumerPlugin(
|
||||
else None
|
||||
)
|
||||
|
||||
version_index = (
|
||||
0
|
||||
if self.input_doc.root_document_id is None
|
||||
else (
|
||||
Document.objects.filter(
|
||||
root_document_id=self.input_doc.root_document_id,
|
||||
).count()
|
||||
+ 1
|
||||
)
|
||||
)
|
||||
|
||||
return parse_w_workflow_placeholders(
|
||||
title,
|
||||
correspondent_name,
|
||||
@@ -723,6 +734,8 @@ class ConsumerPlugin(
|
||||
local_added,
|
||||
self.filename,
|
||||
self.filename,
|
||||
version_label=self.metadata.version_label,
|
||||
version_index=version_index,
|
||||
)
|
||||
|
||||
def _store(
|
||||
|
||||
@@ -427,6 +427,16 @@ class Document(SoftDeleteModel, ModelWithOwner): # type: ignore[django-manager-
|
||||
def created_date(self):
|
||||
return self.created
|
||||
|
||||
@property
|
||||
def version_index(self):
|
||||
if self.root_document_id is None or self.pk is None:
|
||||
return 0
|
||||
|
||||
return Document.objects.filter(
|
||||
root_document_id=self.root_document_id,
|
||||
id__lte=self.id,
|
||||
).count()
|
||||
|
||||
def add_nested_tags(self, tags) -> None:
|
||||
tag_ids = set()
|
||||
for tag in tags:
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import enum
|
||||
from collections.abc import Mapping
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from asgiref.sync import async_to_sync
|
||||
@@ -48,7 +47,7 @@ class BaseStatusManager:
|
||||
async_to_sync(self._channel.flush)
|
||||
self._channel = None
|
||||
|
||||
def send(self, payload: Mapping[str, object]) -> None:
|
||||
def send(self, payload: dict[str, str | int | None]) -> None:
|
||||
# Ensure the layer is open
|
||||
self.open()
|
||||
|
||||
@@ -74,28 +73,26 @@ class ProgressManager(BaseStatusManager):
|
||||
max_progress: int,
|
||||
extra_args: dict[str, str | int | None] | None = None,
|
||||
) -> None:
|
||||
data: dict[str, object] = {
|
||||
"filename": self.filename,
|
||||
"task_id": self.task_id,
|
||||
"current_progress": current_progress,
|
||||
"max_progress": max_progress,
|
||||
"status": status,
|
||||
"message": message,
|
||||
payload = {
|
||||
"type": "status_update",
|
||||
"data": {
|
||||
"filename": self.filename,
|
||||
"task_id": self.task_id,
|
||||
"current_progress": current_progress,
|
||||
"max_progress": max_progress,
|
||||
"status": status,
|
||||
"message": message,
|
||||
},
|
||||
}
|
||||
if extra_args is not None:
|
||||
data.update(extra_args)
|
||||
|
||||
payload: dict[str, object] = {
|
||||
"type": "status_update",
|
||||
"data": data,
|
||||
}
|
||||
payload["data"].update(extra_args)
|
||||
|
||||
self.send(payload)
|
||||
|
||||
|
||||
class DocumentsStatusManager(BaseStatusManager):
|
||||
def send_documents_deleted(self, documents: list[int]) -> None:
|
||||
payload: dict[str, object] = {
|
||||
payload = {
|
||||
"type": "documents_deleted",
|
||||
"data": {
|
||||
"documents": documents,
|
||||
@@ -103,25 +100,3 @@ class DocumentsStatusManager(BaseStatusManager):
|
||||
}
|
||||
|
||||
self.send(payload)
|
||||
|
||||
def send_document_updated(
|
||||
self,
|
||||
*,
|
||||
document_id: int,
|
||||
modified: str,
|
||||
owner_id: int | None = None,
|
||||
users_can_view: list[int] | None = None,
|
||||
groups_can_view: list[int] | None = None,
|
||||
) -> None:
|
||||
payload: dict[str, object] = {
|
||||
"type": "document_updated",
|
||||
"data": {
|
||||
"document_id": document_id,
|
||||
"modified": modified,
|
||||
"owner_id": owner_id,
|
||||
"users_can_view": users_can_view or [],
|
||||
"groups_can_view": groups_can_view or [],
|
||||
},
|
||||
}
|
||||
|
||||
self.send(payload)
|
||||
|
||||
@@ -80,6 +80,7 @@ from documents.parsers import is_mime_type_supported
|
||||
from documents.permissions import get_document_count_filter_for_user
|
||||
from documents.permissions import get_groups_with_only_permission
|
||||
from documents.permissions import get_objects_for_user_owner_aware
|
||||
from documents.permissions import has_perms_owner_aware
|
||||
from documents.permissions import set_permissions_for_object
|
||||
from documents.regex import validate_regex_pattern
|
||||
from documents.templating.filepath import validate_filepath_template_and_render
|
||||
@@ -2321,6 +2322,17 @@ class ShareLinkSerializer(OwnedObjectSerializer):
|
||||
validated_data["slug"] = get_random_string(50)
|
||||
return super().create(validated_data)
|
||||
|
||||
def validate_document(self, document):
|
||||
if self.user is not None and has_perms_owner_aware(
|
||||
self.user,
|
||||
"view_document",
|
||||
document,
|
||||
):
|
||||
return document
|
||||
raise PermissionDenied(
|
||||
_("Insufficient permissions."),
|
||||
)
|
||||
|
||||
|
||||
class ShareLinkBundleSerializer(OwnedObjectSerializer):
|
||||
document_ids = serializers.ListField(
|
||||
@@ -2737,6 +2749,7 @@ class WorkflowActionSerializer(serializers.ModelSerializer):
|
||||
created_month_name_short="",
|
||||
created_day="",
|
||||
created_time="",
|
||||
version_index="",
|
||||
)
|
||||
except (ValueError, KeyError) as e:
|
||||
raise serializers.ValidationError(
|
||||
|
||||
@@ -4,7 +4,6 @@ import logging
|
||||
import shutil
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING
|
||||
from typing import Any
|
||||
|
||||
from celery import shared_task
|
||||
from celery import states
|
||||
@@ -24,7 +23,6 @@ from django.db.models import Q
|
||||
from django.dispatch import receiver
|
||||
from django.utils import timezone
|
||||
from filelock import FileLock
|
||||
from rest_framework import serializers
|
||||
|
||||
from documents import matching
|
||||
from documents.caching import clear_document_caches
|
||||
@@ -47,7 +45,6 @@ from documents.models import WorkflowAction
|
||||
from documents.models import WorkflowRun
|
||||
from documents.models import WorkflowTrigger
|
||||
from documents.permissions import get_objects_for_user_owner_aware
|
||||
from documents.plugins.helpers import DocumentsStatusManager
|
||||
from documents.templating.utils import convert_format_str_to_template_format
|
||||
from documents.workflows.actions import build_workflow_action_context
|
||||
from documents.workflows.actions import execute_email_action
|
||||
@@ -69,7 +66,6 @@ if TYPE_CHECKING:
|
||||
from documents.data_models import DocumentMetadataOverrides
|
||||
|
||||
logger = logging.getLogger("paperless.handlers")
|
||||
DRF_DATETIME_FIELD = serializers.DateTimeField()
|
||||
|
||||
|
||||
def add_inbox_tags(sender, document: Document, logging_group=None, **kwargs) -> None:
|
||||
@@ -766,28 +762,6 @@ def run_workflows_updated(
|
||||
)
|
||||
|
||||
|
||||
def send_websocket_document_updated(
|
||||
sender,
|
||||
document: Document,
|
||||
**kwargs,
|
||||
) -> None:
|
||||
# At this point, workflows may already have applied additional changes.
|
||||
document.refresh_from_db()
|
||||
|
||||
from documents.data_models import DocumentMetadataOverrides
|
||||
|
||||
doc_overrides = DocumentMetadataOverrides.from_document(document)
|
||||
|
||||
with DocumentsStatusManager() as status_mgr:
|
||||
status_mgr.send_document_updated(
|
||||
document_id=document.id,
|
||||
modified=DRF_DATETIME_FIELD.to_representation(document.modified),
|
||||
owner_id=doc_overrides.owner_id,
|
||||
users_can_view=doc_overrides.view_users,
|
||||
groups_can_view=doc_overrides.view_groups,
|
||||
)
|
||||
|
||||
|
||||
def run_workflows(
|
||||
trigger_type: WorkflowTrigger.WorkflowTriggerType,
|
||||
document: Document | ConsumableDocument,
|
||||
@@ -1061,11 +1035,7 @@ def add_or_update_document_in_llm_index(sender, document, **kwargs):
|
||||
|
||||
|
||||
@receiver(models.signals.post_delete, sender=Document)
|
||||
def delete_document_from_llm_index(
|
||||
sender: Any,
|
||||
instance: Document,
|
||||
**kwargs: Any,
|
||||
) -> None:
|
||||
def delete_document_from_llm_index(sender, instance: Document, **kwargs):
|
||||
"""
|
||||
Delete a document from the LLM index when it is deleted.
|
||||
"""
|
||||
|
||||
@@ -60,7 +60,6 @@ from documents.sanity_checker import SanityCheckFailedException
|
||||
from documents.signals import document_updated
|
||||
from documents.signals.handlers import cleanup_document_deletion
|
||||
from documents.signals.handlers import run_workflows
|
||||
from documents.signals.handlers import send_websocket_document_updated
|
||||
from documents.workflows.utils import get_workflows_for_trigger
|
||||
from paperless.config import AIConfig
|
||||
from paperless_ai.indexing import llm_index_add_or_update_document
|
||||
@@ -542,11 +541,6 @@ def check_scheduled_workflows() -> None:
|
||||
workflow_to_run=workflow,
|
||||
document=document,
|
||||
)
|
||||
# Scheduled workflows dont send document_updated signal, so send a websocket update here to ensure clients are updated
|
||||
send_websocket_document_updated(
|
||||
sender=None,
|
||||
document=document,
|
||||
)
|
||||
|
||||
|
||||
def update_document_parent_tags(tag: Tag, new_parent: Tag) -> None:
|
||||
|
||||
@@ -6,6 +6,7 @@ from pathlib import PurePath
|
||||
|
||||
import pathvalidate
|
||||
from django.utils import timezone
|
||||
from django.utils.functional import SimpleLazyObject
|
||||
from django.utils.text import slugify as django_slugify
|
||||
from jinja2 import StrictUndefined
|
||||
from jinja2 import Template
|
||||
@@ -113,6 +114,7 @@ def create_dummy_document():
|
||||
archive_filename="/dummy/archive_filename.pdf",
|
||||
original_filename="original_file.pdf",
|
||||
archive_serial_number=12345,
|
||||
version_label="Version #1",
|
||||
)
|
||||
return dummy_doc
|
||||
|
||||
@@ -155,14 +157,15 @@ def get_basic_metadata_context(
|
||||
document: Document,
|
||||
*,
|
||||
no_value_default: str = NO_VALUE_PLACEHOLDER,
|
||||
) -> dict[str, str]:
|
||||
version_index: object | None = None,
|
||||
) -> dict[str, object]:
|
||||
"""
|
||||
Given a Document, constructs some basic information about it. If certain values are not set,
|
||||
they will be replaced with the no_value_default.
|
||||
|
||||
Regardless of set or not, the values will be sanitized
|
||||
"""
|
||||
return {
|
||||
context: dict[str, object] = {
|
||||
"title": pathvalidate.sanitize_filename(
|
||||
document.title,
|
||||
replacement_text="-",
|
||||
@@ -189,17 +192,30 @@ def get_basic_metadata_context(
|
||||
if document.original_filename
|
||||
else no_value_default,
|
||||
"doc_pk": f"{document.pk:07}",
|
||||
"version_label": pathvalidate.sanitize_filename(
|
||||
document.version_label,
|
||||
replacement_text="-",
|
||||
)
|
||||
if document.version_label
|
||||
else no_value_default,
|
||||
}
|
||||
|
||||
if version_index is not None:
|
||||
context["version_index"] = version_index
|
||||
|
||||
return context
|
||||
|
||||
|
||||
def get_safe_document_context(
|
||||
document: Document,
|
||||
tags: Iterable[Tag],
|
||||
*,
|
||||
version_index: object | None = None,
|
||||
) -> dict[str, object]:
|
||||
"""
|
||||
Build a document context object to avoid supplying entire model instance.
|
||||
"""
|
||||
return {
|
||||
context: dict[str, object] = {
|
||||
"id": document.pk,
|
||||
"pk": document.pk,
|
||||
"title": document.title,
|
||||
@@ -237,6 +253,11 @@ def get_safe_document_context(
|
||||
else None,
|
||||
}
|
||||
|
||||
if version_index is not None:
|
||||
context["version_index"] = version_index
|
||||
|
||||
return context
|
||||
|
||||
|
||||
def get_tags_context(tags: Iterable[Tag]) -> dict[str, str | list[str]]:
|
||||
"""
|
||||
@@ -347,9 +368,20 @@ def validate_filepath_template_and_render(
|
||||
custom_fields = CustomFieldInstance.global_objects.filter(document=document)
|
||||
|
||||
# Build the context dictionary
|
||||
lazy_version_index = SimpleLazyObject(lambda: document.version_index)
|
||||
context = (
|
||||
{"document": get_safe_document_context(document, tags=tags_list)}
|
||||
| get_basic_metadata_context(document, no_value_default=NO_VALUE_PLACEHOLDER)
|
||||
{
|
||||
"document": get_safe_document_context(
|
||||
document,
|
||||
tags=tags_list,
|
||||
version_index=lazy_version_index,
|
||||
),
|
||||
}
|
||||
| get_basic_metadata_context(
|
||||
document,
|
||||
no_value_default=NO_VALUE_PLACEHOLDER,
|
||||
version_index=lazy_version_index,
|
||||
)
|
||||
| get_creation_date_context(document)
|
||||
| get_added_date_context(document)
|
||||
| get_tags_context(tags_list)
|
||||
|
||||
@@ -41,6 +41,8 @@ def parse_w_workflow_placeholders(
|
||||
doc_title: str | None = None,
|
||||
doc_url: str | None = None,
|
||||
doc_id: int | None = None,
|
||||
version_label: str | None = None,
|
||||
version_index: int | None = None,
|
||||
) -> str:
|
||||
"""
|
||||
Available title placeholders for Workflows depend on what has already been assigned,
|
||||
@@ -62,6 +64,8 @@ def parse_w_workflow_placeholders(
|
||||
"owner_username": owner_username,
|
||||
"original_filename": Path(original_filename).stem,
|
||||
"filename": Path(filename).stem,
|
||||
"version_label": version_label or "",
|
||||
"version_index": version_index or "1",
|
||||
}
|
||||
if created is not None:
|
||||
formatting.update(
|
||||
|
||||
@@ -773,6 +773,22 @@ class TestBulkEditAPI(DirectoriesMixin, APITestCase):
|
||||
],
|
||||
)
|
||||
|
||||
def test_api_selection_data_requires_view_permission(self):
|
||||
self.doc2.owner = self.user
|
||||
self.doc2.save()
|
||||
|
||||
user1 = User.objects.create(username="user1")
|
||||
self.client.force_authenticate(user=user1)
|
||||
|
||||
response = self.client.post(
|
||||
"/api/documents/selection_data/",
|
||||
json.dumps({"documents": [self.doc2.id]}),
|
||||
content_type="application/json",
|
||||
)
|
||||
|
||||
self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN)
|
||||
self.assertEqual(response.content, b"Insufficient permissions")
|
||||
|
||||
@mock.patch("documents.serialisers.bulk_edit.set_permissions")
|
||||
def test_set_permissions(self, m) -> None:
|
||||
self.setup_mock(m, "set_permissions")
|
||||
|
||||
@@ -1270,11 +1270,7 @@ class TestDocumentApi(DirectoriesMixin, DocumentConsumeDelayMixin, APITestCase):
|
||||
input_doc, overrides = self.get_last_consume_delay_call_args()
|
||||
|
||||
self.assertEqual(input_doc.original_file.name, "simple.pdf")
|
||||
self.assertTrue(
|
||||
input_doc.original_file.resolve(strict=False).is_relative_to(
|
||||
Path(settings.SCRATCH_DIR).resolve(strict=False),
|
||||
),
|
||||
)
|
||||
self.assertIn(Path(settings.SCRATCH_DIR), input_doc.original_file.parents)
|
||||
self.assertIsNone(overrides.title)
|
||||
self.assertIsNone(overrides.correspondent_id)
|
||||
self.assertIsNone(overrides.document_type_id)
|
||||
@@ -1355,11 +1351,7 @@ class TestDocumentApi(DirectoriesMixin, DocumentConsumeDelayMixin, APITestCase):
|
||||
input_doc, overrides = self.get_last_consume_delay_call_args()
|
||||
|
||||
self.assertEqual(input_doc.original_file.name, "simple.pdf")
|
||||
self.assertTrue(
|
||||
input_doc.original_file.resolve(strict=False).is_relative_to(
|
||||
Path(settings.SCRATCH_DIR).resolve(strict=False),
|
||||
),
|
||||
)
|
||||
self.assertIn(Path(settings.SCRATCH_DIR), input_doc.original_file.parents)
|
||||
self.assertIsNone(overrides.title)
|
||||
self.assertIsNone(overrides.correspondent_id)
|
||||
self.assertIsNone(overrides.document_type_id)
|
||||
@@ -2963,6 +2955,54 @@ class TestDocumentApi(DirectoriesMixin, DocumentConsumeDelayMixin, APITestCase):
|
||||
)
|
||||
self.assertEqual(resp.status_code, status.HTTP_200_OK)
|
||||
|
||||
def test_create_share_link_requires_view_permission_for_document(self):
|
||||
"""
|
||||
GIVEN:
|
||||
- A user with add_sharelink but without view permission on a document
|
||||
WHEN:
|
||||
- API request is made to create a share link for that document
|
||||
THEN:
|
||||
- Share link creation is denied until view permission is granted
|
||||
"""
|
||||
user1 = User.objects.create_user(username="test1")
|
||||
user1.user_permissions.add(*Permission.objects.filter(codename="add_sharelink"))
|
||||
user1.save()
|
||||
|
||||
user2 = User.objects.create_user(username="test2")
|
||||
user2.save()
|
||||
|
||||
doc = Document.objects.create(
|
||||
title="test",
|
||||
mime_type="application/pdf",
|
||||
content="this is a document which will be protected",
|
||||
owner=user2,
|
||||
)
|
||||
|
||||
self.client.force_authenticate(user1)
|
||||
|
||||
create_resp = self.client.post(
|
||||
"/api/share_links/",
|
||||
data={
|
||||
"document": doc.pk,
|
||||
"file_version": "original",
|
||||
},
|
||||
format="json",
|
||||
)
|
||||
self.assertEqual(create_resp.status_code, status.HTTP_403_FORBIDDEN)
|
||||
|
||||
assign_perm("view_document", user1, doc)
|
||||
|
||||
create_resp = self.client.post(
|
||||
"/api/share_links/",
|
||||
data={
|
||||
"document": doc.pk,
|
||||
"file_version": "original",
|
||||
},
|
||||
format="json",
|
||||
)
|
||||
self.assertEqual(create_resp.status_code, status.HTTP_201_CREATED)
|
||||
self.assertEqual(create_resp.data["document"], doc.pk)
|
||||
|
||||
def test_next_asn(self) -> None:
|
||||
"""
|
||||
GIVEN:
|
||||
|
||||
@@ -267,7 +267,7 @@ class TestApiStoragePaths(DirectoriesMixin, APITestCase):
|
||||
"/{created_month_name_short}/{created_day}/{added}/{added_year}"
|
||||
"/{added_year_short}/{added_month}/{added_month_name}"
|
||||
"/{added_month_name_short}/{added_day}/{asn}"
|
||||
"/{tag_list}/{owner_username}/{original_name}/{doc_pk}/",
|
||||
"/{tag_list}/{owner_username}/{original_name}/{doc_pk}/{version_index}/",
|
||||
},
|
||||
),
|
||||
content_type="application/json",
|
||||
|
||||
@@ -21,6 +21,16 @@ class TestApiUiSettings(DirectoriesMixin, APITestCase):
|
||||
self.test_user.save()
|
||||
self.client.force_authenticate(user=self.test_user)
|
||||
|
||||
@override_settings(
|
||||
APP_TITLE=None,
|
||||
APP_LOGO=None,
|
||||
AUDIT_LOG_ENABLED=True,
|
||||
EMPTY_TRASH_DELAY=30,
|
||||
ENABLE_UPDATE_CHECK="default",
|
||||
EMAIL_ENABLED=False,
|
||||
GMAIL_OAUTH_ENABLED=False,
|
||||
OUTLOOK_OAUTH_ENABLED=False,
|
||||
)
|
||||
def test_api_get_ui_settings(self) -> None:
|
||||
response = self.client.get(self.ENDPOINT, format="json")
|
||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||
|
||||
@@ -355,6 +355,35 @@ class TestApiWorkflows(DirectoriesMixin, APITestCase):
|
||||
|
||||
self.assertEqual(Workflow.objects.count(), 1)
|
||||
|
||||
def test_api_create_assign_title_accepts_version_index(self) -> None:
|
||||
response = self.client.post(
|
||||
self.ENDPOINT,
|
||||
json.dumps(
|
||||
{
|
||||
"name": "Workflow with version index",
|
||||
"order": 1,
|
||||
"triggers": [
|
||||
{
|
||||
"type": WorkflowTrigger.WorkflowTriggerType.DOCUMENT_UPDATED,
|
||||
},
|
||||
],
|
||||
"actions": [
|
||||
{
|
||||
"assign_title": "Version {version_index}",
|
||||
},
|
||||
],
|
||||
},
|
||||
),
|
||||
content_type="application/json",
|
||||
)
|
||||
|
||||
self.assertEqual(response.status_code, status.HTTP_201_CREATED)
|
||||
workflow = Workflow.objects.get(name="Workflow with version index")
|
||||
self.assertEqual(
|
||||
workflow.actions.first().assign_title,
|
||||
"Version {version_index}",
|
||||
)
|
||||
|
||||
def test_api_create_workflow_trigger_action_empty_fields(self) -> None:
|
||||
"""
|
||||
GIVEN:
|
||||
|
||||
@@ -919,6 +919,7 @@ class TestTagBarcode(DirectoriesMixin, SampleDirMixin, GetReaderPluginMixin, Tes
|
||||
@override_settings(
|
||||
CONSUMER_ENABLE_TAG_BARCODE=True,
|
||||
CONSUMER_TAG_BARCODE_MAPPING={"ASN(.*)": "\\g<1>"},
|
||||
CONSUMER_ENABLE_ASN_BARCODE=False,
|
||||
)
|
||||
def test_scan_file_for_many_custom_tags(self) -> None:
|
||||
"""
|
||||
|
||||
@@ -428,7 +428,11 @@ class TestConsumer(
|
||||
DocumentMetadataOverrides(
|
||||
correspondent_id=c.pk,
|
||||
document_type_id=dt.pk,
|
||||
title="{{correspondent}}{{document_type}} {{added_month}}-{{added_year_short}}",
|
||||
title=(
|
||||
"{{correspondent}}{{document_type}} "
|
||||
"{{added_month}}-{{added_year_short}}.{{version_label}}"
|
||||
),
|
||||
version_label="v2",
|
||||
),
|
||||
) as consumer:
|
||||
consumer.run()
|
||||
@@ -436,7 +440,10 @@ class TestConsumer(
|
||||
document = Document.objects.first()
|
||||
|
||||
now = timezone.now()
|
||||
self.assertEqual(document.title, f"{c.name}{dt.name} {now.strftime('%m-%y')}")
|
||||
self.assertEqual(
|
||||
document.title,
|
||||
f"{c.name}{dt.name} {now.strftime('%m-%y')}.v2",
|
||||
)
|
||||
self._assert_first_last_send_progress()
|
||||
|
||||
def testOverrideOwner(self) -> None:
|
||||
|
||||
@@ -156,6 +156,54 @@ class TestDocument(TestCase):
|
||||
)
|
||||
self.assertEqual(doc.get_public_filename(), "2020-12-25 test")
|
||||
|
||||
def test_version_index_for_root_is_zero(self) -> None:
|
||||
root = Document.objects.create(
|
||||
title="Root",
|
||||
content="content",
|
||||
checksum="checksum-root",
|
||||
mime_type="application/pdf",
|
||||
created=date(2025, 1, 1),
|
||||
)
|
||||
|
||||
self.assertEqual(root.version_index, 0)
|
||||
|
||||
def test_version_index_uses_id_ordering(self) -> None:
|
||||
root = Document.objects.create(
|
||||
title="Root",
|
||||
content="content",
|
||||
checksum="checksum-root",
|
||||
mime_type="application/pdf",
|
||||
created=date(2025, 1, 1),
|
||||
)
|
||||
v1 = Document.objects.create(
|
||||
root_document=root,
|
||||
title="V1",
|
||||
content="content",
|
||||
checksum="checksum-v1",
|
||||
mime_type="application/pdf",
|
||||
created=date(2025, 1, 3),
|
||||
)
|
||||
v2 = Document.objects.create(
|
||||
root_document=root,
|
||||
title="V2",
|
||||
content="content",
|
||||
checksum="checksum-v2",
|
||||
mime_type="application/pdf",
|
||||
created=date(2025, 1, 2),
|
||||
)
|
||||
v3 = Document.objects.create(
|
||||
root_document=root,
|
||||
title="V3",
|
||||
content="content",
|
||||
checksum="checksum-v3",
|
||||
mime_type="application/pdf",
|
||||
created=date(2025, 1, 1),
|
||||
)
|
||||
|
||||
self.assertEqual(v1.version_index, 1)
|
||||
self.assertEqual(v2.version_index, 2)
|
||||
self.assertEqual(v3.version_index, 3)
|
||||
|
||||
|
||||
def test_suggestion_content() -> None:
|
||||
"""
|
||||
|
||||
@@ -281,6 +281,32 @@ class TestFileHandling(DirectoriesMixin, FileSystemAssertsMixin, TestCase):
|
||||
self.assertEqual(generate_filename(d1), Path("652 - the_doc.pdf"))
|
||||
self.assertEqual(generate_filename(d2), Path("none - the_doc.pdf"))
|
||||
|
||||
@override_settings(FILENAME_FORMAT="{title}.{version_label}")
|
||||
def test_version_label(self) -> None:
|
||||
d1 = Document.objects.create(
|
||||
title="the_doc",
|
||||
mime_type="application/pdf",
|
||||
checksum="A",
|
||||
version_label="Version #2",
|
||||
)
|
||||
d2 = Document.objects.create(
|
||||
title="the_doc",
|
||||
mime_type="application/pdf",
|
||||
checksum="B",
|
||||
)
|
||||
d3 = Document.objects.create(
|
||||
title="the_doc",
|
||||
mime_type="application/pdf",
|
||||
checksum="C",
|
||||
version_label="Super weird %@\"'<> ¯\\_(ツ)_/¯",
|
||||
)
|
||||
self.assertEqual(generate_filename(d1), Path("the_doc.Version #2.pdf"))
|
||||
self.assertEqual(generate_filename(d2), Path("the_doc.none.pdf"))
|
||||
self.assertEqual(
|
||||
generate_filename(d3),
|
||||
Path("the_doc.Super weird %@-'-- ¯-_(ツ)_-¯.pdf"),
|
||||
)
|
||||
|
||||
@override_settings(FILENAME_FORMAT="{title} {tag_list}")
|
||||
def test_tag_list(self) -> None:
|
||||
doc = Document.objects.create(title="doc1", mime_type="application/pdf")
|
||||
@@ -329,14 +355,14 @@ class TestFileHandling(DirectoriesMixin, FileSystemAssertsMixin, TestCase):
|
||||
FILENAME_FORMAT="{added_year}-{added_month}-{added_day}",
|
||||
)
|
||||
def test_added_year_month_day(self) -> None:
|
||||
d1 = timezone.make_aware(datetime.datetime(232, 1, 9, 1, 1, 1))
|
||||
d1 = timezone.make_aware(datetime.datetime(1232, 1, 9, 1, 1, 1))
|
||||
doc1 = Document.objects.create(
|
||||
title="doc1",
|
||||
mime_type="application/pdf",
|
||||
added=d1,
|
||||
)
|
||||
|
||||
self.assertEqual(generate_filename(doc1), Path("232-01-09.pdf"))
|
||||
self.assertEqual(generate_filename(doc1), Path("1232-01-09.pdf"))
|
||||
|
||||
doc1.added = timezone.make_aware(datetime.datetime(2020, 11, 16, 1, 1, 1))
|
||||
|
||||
@@ -383,6 +409,21 @@ class TestFileHandling(DirectoriesMixin, FileSystemAssertsMixin, TestCase):
|
||||
|
||||
self.assertEqual(generate_filename(document), Path("0013579.pdf"))
|
||||
|
||||
@override_settings(FILENAME_FORMAT="{version_index}")
|
||||
def test_format_version_index(self) -> None:
|
||||
root = Document.objects.create(
|
||||
checksum="aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
|
||||
mime_type="application/pdf",
|
||||
)
|
||||
version = Document.objects.create(
|
||||
root_document=root,
|
||||
checksum="bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb",
|
||||
mime_type="application/pdf",
|
||||
)
|
||||
|
||||
self.assertEqual(generate_filename(root), Path("0.pdf"))
|
||||
self.assertEqual(generate_filename(version), Path("1.pdf"))
|
||||
|
||||
@override_settings(FILENAME_FORMAT=None)
|
||||
def test_format_none(self) -> None:
|
||||
document = Document()
|
||||
|
||||
@@ -140,7 +140,7 @@ class TestFuzzyMatchCommand(TestCase):
|
||||
mime_type="application/pdf",
|
||||
filename="final_test.pdf",
|
||||
)
|
||||
stdout, _ = self.call_command("--no-progress-bar")
|
||||
stdout, _ = self.call_command("--no-progress-bar", "--processes", "1")
|
||||
lines = [x.strip() for x in stdout.splitlines() if x.strip()]
|
||||
self.assertEqual(len(lines), 3)
|
||||
for line in lines:
|
||||
@@ -183,7 +183,12 @@ class TestFuzzyMatchCommand(TestCase):
|
||||
|
||||
self.assertEqual(Document.objects.count(), 3)
|
||||
|
||||
stdout, _ = self.call_command("--delete", "--no-progress-bar")
|
||||
stdout, _ = self.call_command(
|
||||
"--delete",
|
||||
"--no-progress-bar",
|
||||
"--processes",
|
||||
"1",
|
||||
)
|
||||
|
||||
self.assertIn(
|
||||
"The command is configured to delete documents. Use with caution",
|
||||
|
||||
@@ -643,9 +643,7 @@ class TestWorkflows(
|
||||
|
||||
expected_str = f"Document did not match {w}"
|
||||
self.assertIn(expected_str, cm.output[0])
|
||||
expected_str = (
|
||||
f"Document path {Path(test_file).resolve(strict=False)} does not match"
|
||||
)
|
||||
expected_str = f"Document path {test_file} does not match"
|
||||
self.assertIn(expected_str, cm.output[1])
|
||||
|
||||
def test_workflow_no_match_mail_rule(self) -> None:
|
||||
@@ -953,6 +951,38 @@ class TestWorkflows(
|
||||
self.assertEqual(doc.correspondent, self.c2)
|
||||
self.assertEqual(doc.title, f"Doc created in {created.year}")
|
||||
|
||||
def test_document_updated_workflow_version_index_placeholder(self) -> None:
|
||||
trigger = WorkflowTrigger.objects.create(
|
||||
type=WorkflowTrigger.WorkflowTriggerType.DOCUMENT_UPDATED,
|
||||
)
|
||||
action = WorkflowAction.objects.create(
|
||||
assign_title="Version {{ version_index }}",
|
||||
)
|
||||
workflow = Workflow.objects.create(
|
||||
name="Workflow version index",
|
||||
order=0,
|
||||
)
|
||||
workflow.triggers.add(trigger)
|
||||
workflow.actions.add(action)
|
||||
workflow.save()
|
||||
|
||||
root = Document.objects.create(
|
||||
title="root",
|
||||
checksum="cccccccccccccccccccccccccccccccc",
|
||||
mime_type="application/pdf",
|
||||
)
|
||||
version = Document.objects.create(
|
||||
title="v1",
|
||||
checksum="dddddddddddddddddddddddddddddddd",
|
||||
mime_type="application/pdf",
|
||||
root_document=root,
|
||||
)
|
||||
|
||||
run_workflows(WorkflowTrigger.WorkflowTriggerType.DOCUMENT_UPDATED, version)
|
||||
version.refresh_from_db()
|
||||
|
||||
self.assertEqual(version.title, "Version 1")
|
||||
|
||||
def test_document_added_no_match_filename(self) -> None:
|
||||
trigger = WorkflowTrigger.objects.create(
|
||||
type=WorkflowTrigger.WorkflowTriggerType.DOCUMENT_ADDED,
|
||||
@@ -1970,36 +2000,6 @@ class TestWorkflows(
|
||||
doc.refresh_from_db()
|
||||
self.assertEqual(doc.owner, self.user2)
|
||||
|
||||
@mock.patch("documents.tasks.send_websocket_document_updated")
|
||||
def test_workflow_scheduled_trigger_sends_websocket_update(
|
||||
self,
|
||||
mock_send_websocket_document_updated,
|
||||
) -> None:
|
||||
trigger = WorkflowTrigger.objects.create(
|
||||
type=WorkflowTrigger.WorkflowTriggerType.SCHEDULED,
|
||||
schedule_offset_days=1,
|
||||
schedule_date_field=WorkflowTrigger.ScheduleDateField.CREATED,
|
||||
)
|
||||
action = WorkflowAction.objects.create(assign_owner=self.user2)
|
||||
workflow = Workflow.objects.create(name="Workflow 1", order=0)
|
||||
workflow.triggers.add(trigger)
|
||||
workflow.actions.add(action)
|
||||
|
||||
doc = Document.objects.create(
|
||||
title="sample test",
|
||||
correspondent=self.c,
|
||||
original_filename="sample.pdf",
|
||||
created=timezone.now() - timedelta(days=2),
|
||||
)
|
||||
|
||||
tasks.check_scheduled_workflows()
|
||||
|
||||
self.assertEqual(mock_send_websocket_document_updated.call_count, 1)
|
||||
self.assertEqual(
|
||||
mock_send_websocket_document_updated.call_args.kwargs["document"].pk,
|
||||
doc.pk,
|
||||
)
|
||||
|
||||
def test_workflow_scheduled_trigger_added(self) -> None:
|
||||
"""
|
||||
GIVEN:
|
||||
@@ -3444,7 +3444,10 @@ class TestWorkflows(
|
||||
)
|
||||
webhook_action = WorkflowActionWebhook.objects.create(
|
||||
use_params=False,
|
||||
body="Test message: {{doc_url}} with id {{doc_id}}",
|
||||
body=(
|
||||
"Test message: {{doc_url}} with id {{doc_id}} "
|
||||
"and version {{version_label}}"
|
||||
),
|
||||
url="http://paperless-ngx.com",
|
||||
include_document=False,
|
||||
)
|
||||
@@ -3468,6 +3471,7 @@ class TestWorkflows(
|
||||
title="sample test",
|
||||
correspondent=self.c,
|
||||
original_filename="sample.pdf",
|
||||
version_label="v3",
|
||||
)
|
||||
|
||||
run_workflows(WorkflowTrigger.WorkflowTriggerType.DOCUMENT_UPDATED, doc)
|
||||
@@ -3476,7 +3480,7 @@ class TestWorkflows(
|
||||
url="http://paperless-ngx.com",
|
||||
data=(
|
||||
f"Test message: http://localhost:8000/paperless/documents/{doc.id}/"
|
||||
f" with id {doc.id}"
|
||||
f" with id {doc.id} and version {doc.version_label}"
|
||||
),
|
||||
headers={},
|
||||
files=None,
|
||||
|
||||
@@ -33,11 +33,11 @@ from documents.plugins.helpers import ProgressStatusOptions
|
||||
def setup_directories():
|
||||
dirs = namedtuple("Dirs", ())
|
||||
|
||||
dirs.data_dir = Path(tempfile.mkdtemp())
|
||||
dirs.scratch_dir = Path(tempfile.mkdtemp())
|
||||
dirs.media_dir = Path(tempfile.mkdtemp())
|
||||
dirs.consumption_dir = Path(tempfile.mkdtemp())
|
||||
dirs.static_dir = Path(tempfile.mkdtemp())
|
||||
dirs.data_dir = Path(tempfile.mkdtemp()).resolve()
|
||||
dirs.scratch_dir = Path(tempfile.mkdtemp()).resolve()
|
||||
dirs.media_dir = Path(tempfile.mkdtemp()).resolve()
|
||||
dirs.consumption_dir = Path(tempfile.mkdtemp()).resolve()
|
||||
dirs.static_dir = Path(tempfile.mkdtemp()).resolve()
|
||||
dirs.index_dir = dirs.data_dir / "index"
|
||||
dirs.originals_dir = dirs.media_dir / "documents" / "originals"
|
||||
dirs.thumbnail_dir = dirs.media_dir / "documents" / "thumbnails"
|
||||
|
||||
@@ -2434,6 +2434,13 @@ class SelectionDataView(GenericAPIView):
|
||||
serializer.is_valid(raise_exception=True)
|
||||
|
||||
ids = serializer.validated_data.get("documents")
|
||||
permitted_documents = get_objects_for_user_owner_aware(
|
||||
request.user,
|
||||
"documents.view_document",
|
||||
Document,
|
||||
)
|
||||
if permitted_documents.filter(pk__in=ids).count() != len(ids):
|
||||
return HttpResponseForbidden("Insufficient permissions")
|
||||
|
||||
correspondents = Correspondent.objects.annotate(
|
||||
document_count=Count(
|
||||
|
||||
@@ -6,6 +6,7 @@ from pathlib import Path
|
||||
from django.conf import settings
|
||||
from django.contrib.auth.models import User
|
||||
from django.utils import timezone
|
||||
from django.utils.functional import SimpleLazyObject
|
||||
|
||||
from documents.data_models import ConsumableDocument
|
||||
from documents.data_models import DocumentMetadataOverrides
|
||||
@@ -24,6 +25,16 @@ from documents.workflows.webhooks import send_webhook
|
||||
logger = logging.getLogger("paperless.workflows.actions")
|
||||
|
||||
|
||||
def _get_consumable_version_index(document: ConsumableDocument) -> int:
|
||||
if document.root_document_id is None:
|
||||
return 0
|
||||
|
||||
# The document is not yet saved, so use the next index in the version chain.
|
||||
return (
|
||||
Document.objects.filter(root_document_id=document.root_document_id).count() + 1
|
||||
)
|
||||
|
||||
|
||||
def build_workflow_action_context(
|
||||
document: Document | ConsumableDocument,
|
||||
overrides: DocumentMetadataOverrides | None,
|
||||
@@ -34,6 +45,11 @@ def build_workflow_action_context(
|
||||
use_overrides = overrides is not None
|
||||
|
||||
if not use_overrides:
|
||||
version_index = (
|
||||
document.version_index
|
||||
if isinstance(document, Document)
|
||||
else _get_consumable_version_index(document)
|
||||
)
|
||||
return {
|
||||
"title": document.title,
|
||||
"doc_url": f"{settings.PAPERLESS_URL}{settings.BASE_URL}documents/{document.pk}/",
|
||||
@@ -49,6 +65,8 @@ def build_workflow_action_context(
|
||||
"added": timezone.localtime(document.added),
|
||||
"created": document.created,
|
||||
"id": document.pk,
|
||||
"version_label": document.version_label,
|
||||
"version_index": version_index,
|
||||
}
|
||||
|
||||
correspondent_obj = (
|
||||
@@ -68,6 +86,11 @@ def build_workflow_action_context(
|
||||
)
|
||||
|
||||
filename = document.original_file if document.original_file else ""
|
||||
version_index = (
|
||||
SimpleLazyObject(lambda: _get_consumable_version_index(document))
|
||||
if isinstance(document, ConsumableDocument)
|
||||
else SimpleLazyObject(lambda: document.version_index)
|
||||
)
|
||||
return {
|
||||
"title": overrides.title
|
||||
if overrides and overrides.title
|
||||
@@ -81,6 +104,8 @@ def build_workflow_action_context(
|
||||
"added": timezone.localtime(timezone.now()),
|
||||
"created": overrides.created if overrides else None,
|
||||
"id": "",
|
||||
"version_label": overrides.version_label if overrides else None,
|
||||
"version_index": version_index,
|
||||
}
|
||||
|
||||
|
||||
@@ -116,6 +141,8 @@ def execute_email_action(
|
||||
context["title"],
|
||||
context["doc_url"],
|
||||
context["id"],
|
||||
context["version_label"],
|
||||
context["version_index"],
|
||||
)
|
||||
if action.email.subject
|
||||
else ""
|
||||
@@ -133,6 +160,8 @@ def execute_email_action(
|
||||
context["title"],
|
||||
context["doc_url"],
|
||||
context["id"],
|
||||
context["version_label"],
|
||||
context["version_index"],
|
||||
)
|
||||
if action.email.body
|
||||
else ""
|
||||
@@ -212,6 +241,8 @@ def execute_webhook_action(
|
||||
context["title"],
|
||||
context["doc_url"],
|
||||
context["id"],
|
||||
context["version_label"],
|
||||
context["version_index"],
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
@@ -231,6 +262,8 @@ def execute_webhook_action(
|
||||
context["title"],
|
||||
context["doc_url"],
|
||||
context["id"],
|
||||
context["version_label"],
|
||||
context["version_index"],
|
||||
)
|
||||
headers = {}
|
||||
if action.webhook.headers:
|
||||
|
||||
@@ -58,6 +58,8 @@ def apply_assignment_to_document(
|
||||
"", # dont pass the title to avoid recursion
|
||||
"", # no urls in titles
|
||||
document.pk,
|
||||
document.version_label,
|
||||
document.version_index,
|
||||
)
|
||||
except Exception: # pragma: no cover
|
||||
logger.exception(
|
||||
|
||||
@@ -2,7 +2,7 @@ msgid ""
|
||||
msgstr ""
|
||||
"Project-Id-Version: paperless-ngx\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"POT-Creation-Date: 2026-02-26 18:09+0000\n"
|
||||
"POT-Creation-Date: 2026-02-28 10:33+0000\n"
|
||||
"PO-Revision-Date: 2022-02-17 04:17\n"
|
||||
"Last-Translator: \n"
|
||||
"Language-Team: English\n"
|
||||
@@ -1299,47 +1299,47 @@ msgstr ""
|
||||
msgid "workflow runs"
|
||||
msgstr ""
|
||||
|
||||
#: documents/serialisers.py:462
|
||||
#: documents/serialisers.py:463 documents/serialisers.py:2333
|
||||
msgid "Insufficient permissions."
|
||||
msgstr ""
|
||||
|
||||
#: documents/serialisers.py:650
|
||||
#: documents/serialisers.py:651
|
||||
msgid "Invalid color."
|
||||
msgstr ""
|
||||
|
||||
#: documents/serialisers.py:1955
|
||||
#: documents/serialisers.py:1956
|
||||
#, python-format
|
||||
msgid "File type %(type)s not supported"
|
||||
msgstr ""
|
||||
|
||||
#: documents/serialisers.py:1999
|
||||
#: documents/serialisers.py:2000
|
||||
#, python-format
|
||||
msgid "Custom field id must be an integer: %(id)s"
|
||||
msgstr ""
|
||||
|
||||
#: documents/serialisers.py:2006
|
||||
#: documents/serialisers.py:2007
|
||||
#, python-format
|
||||
msgid "Custom field with id %(id)s does not exist"
|
||||
msgstr ""
|
||||
|
||||
#: documents/serialisers.py:2023 documents/serialisers.py:2033
|
||||
#: documents/serialisers.py:2024 documents/serialisers.py:2034
|
||||
msgid ""
|
||||
"Custom fields must be a list of integers or an object mapping ids to values."
|
||||
msgstr ""
|
||||
|
||||
#: documents/serialisers.py:2028
|
||||
#: documents/serialisers.py:2029
|
||||
msgid "Some custom fields don't exist or were specified twice."
|
||||
msgstr ""
|
||||
|
||||
#: documents/serialisers.py:2175
|
||||
#: documents/serialisers.py:2176
|
||||
msgid "Invalid variable detected."
|
||||
msgstr ""
|
||||
|
||||
#: documents/serialisers.py:2377
|
||||
#: documents/serialisers.py:2389
|
||||
msgid "Duplicate document identifiers are not allowed."
|
||||
msgstr ""
|
||||
|
||||
#: documents/serialisers.py:2407 documents/views.py:3310
|
||||
#: documents/serialisers.py:2419 documents/views.py:3317
|
||||
#, python-format
|
||||
msgid "Documents not found: %(ids)s"
|
||||
msgstr ""
|
||||
@@ -1603,20 +1603,20 @@ msgstr ""
|
||||
msgid "Unable to parse URI {value}"
|
||||
msgstr ""
|
||||
|
||||
#: documents/views.py:3322
|
||||
#: documents/views.py:3329
|
||||
#, python-format
|
||||
msgid "Insufficient permissions to share document %(id)s."
|
||||
msgstr ""
|
||||
|
||||
#: documents/views.py:3365
|
||||
#: documents/views.py:3372
|
||||
msgid "Bundle is already being processed."
|
||||
msgstr ""
|
||||
|
||||
#: documents/views.py:3422
|
||||
#: documents/views.py:3429
|
||||
msgid "The share link bundle is still being prepared. Please try again later."
|
||||
msgstr ""
|
||||
|
||||
#: documents/views.py:3432
|
||||
#: documents/views.py:3439
|
||||
msgid "The share link bundle is unavailable."
|
||||
msgstr ""
|
||||
|
||||
@@ -1856,151 +1856,151 @@ msgstr ""
|
||||
msgid "paperless application settings"
|
||||
msgstr ""
|
||||
|
||||
#: paperless/settings.py:819
|
||||
#: paperless/settings/__init__.py:746
|
||||
msgid "English (US)"
|
||||
msgstr ""
|
||||
|
||||
#: paperless/settings.py:820
|
||||
#: paperless/settings/__init__.py:747
|
||||
msgid "Arabic"
|
||||
msgstr ""
|
||||
|
||||
#: paperless/settings.py:821
|
||||
#: paperless/settings/__init__.py:748
|
||||
msgid "Afrikaans"
|
||||
msgstr ""
|
||||
|
||||
#: paperless/settings.py:822
|
||||
#: paperless/settings/__init__.py:749
|
||||
msgid "Belarusian"
|
||||
msgstr ""
|
||||
|
||||
#: paperless/settings.py:823
|
||||
#: paperless/settings/__init__.py:750
|
||||
msgid "Bulgarian"
|
||||
msgstr ""
|
||||
|
||||
#: paperless/settings.py:824
|
||||
#: paperless/settings/__init__.py:751
|
||||
msgid "Catalan"
|
||||
msgstr ""
|
||||
|
||||
#: paperless/settings.py:825
|
||||
#: paperless/settings/__init__.py:752
|
||||
msgid "Czech"
|
||||
msgstr ""
|
||||
|
||||
#: paperless/settings.py:826
|
||||
#: paperless/settings/__init__.py:753
|
||||
msgid "Danish"
|
||||
msgstr ""
|
||||
|
||||
#: paperless/settings.py:827
|
||||
#: paperless/settings/__init__.py:754
|
||||
msgid "German"
|
||||
msgstr ""
|
||||
|
||||
#: paperless/settings.py:828
|
||||
#: paperless/settings/__init__.py:755
|
||||
msgid "Greek"
|
||||
msgstr ""
|
||||
|
||||
#: paperless/settings.py:829
|
||||
#: paperless/settings/__init__.py:756
|
||||
msgid "English (GB)"
|
||||
msgstr ""
|
||||
|
||||
#: paperless/settings.py:830
|
||||
#: paperless/settings/__init__.py:757
|
||||
msgid "Spanish"
|
||||
msgstr ""
|
||||
|
||||
#: paperless/settings.py:831
|
||||
#: paperless/settings/__init__.py:758
|
||||
msgid "Persian"
|
||||
msgstr ""
|
||||
|
||||
#: paperless/settings.py:832
|
||||
#: paperless/settings/__init__.py:759
|
||||
msgid "Finnish"
|
||||
msgstr ""
|
||||
|
||||
#: paperless/settings.py:833
|
||||
#: paperless/settings/__init__.py:760
|
||||
msgid "French"
|
||||
msgstr ""
|
||||
|
||||
#: paperless/settings.py:834
|
||||
#: paperless/settings/__init__.py:761
|
||||
msgid "Hungarian"
|
||||
msgstr ""
|
||||
|
||||
#: paperless/settings.py:835
|
||||
#: paperless/settings/__init__.py:762
|
||||
msgid "Indonesian"
|
||||
msgstr ""
|
||||
|
||||
#: paperless/settings.py:836
|
||||
#: paperless/settings/__init__.py:763
|
||||
msgid "Italian"
|
||||
msgstr ""
|
||||
|
||||
#: paperless/settings.py:837
|
||||
#: paperless/settings/__init__.py:764
|
||||
msgid "Japanese"
|
||||
msgstr ""
|
||||
|
||||
#: paperless/settings.py:838
|
||||
#: paperless/settings/__init__.py:765
|
||||
msgid "Korean"
|
||||
msgstr ""
|
||||
|
||||
#: paperless/settings.py:839
|
||||
#: paperless/settings/__init__.py:766
|
||||
msgid "Luxembourgish"
|
||||
msgstr ""
|
||||
|
||||
#: paperless/settings.py:840
|
||||
#: paperless/settings/__init__.py:767
|
||||
msgid "Norwegian"
|
||||
msgstr ""
|
||||
|
||||
#: paperless/settings.py:841
|
||||
#: paperless/settings/__init__.py:768
|
||||
msgid "Dutch"
|
||||
msgstr ""
|
||||
|
||||
#: paperless/settings.py:842
|
||||
#: paperless/settings/__init__.py:769
|
||||
msgid "Polish"
|
||||
msgstr ""
|
||||
|
||||
#: paperless/settings.py:843
|
||||
#: paperless/settings/__init__.py:770
|
||||
msgid "Portuguese (Brazil)"
|
||||
msgstr ""
|
||||
|
||||
#: paperless/settings.py:844
|
||||
#: paperless/settings/__init__.py:771
|
||||
msgid "Portuguese"
|
||||
msgstr ""
|
||||
|
||||
#: paperless/settings.py:845
|
||||
#: paperless/settings/__init__.py:772
|
||||
msgid "Romanian"
|
||||
msgstr ""
|
||||
|
||||
#: paperless/settings.py:846
|
||||
#: paperless/settings/__init__.py:773
|
||||
msgid "Russian"
|
||||
msgstr ""
|
||||
|
||||
#: paperless/settings.py:847
|
||||
#: paperless/settings/__init__.py:774
|
||||
msgid "Slovak"
|
||||
msgstr ""
|
||||
|
||||
#: paperless/settings.py:848
|
||||
#: paperless/settings/__init__.py:775
|
||||
msgid "Slovenian"
|
||||
msgstr ""
|
||||
|
||||
#: paperless/settings.py:849
|
||||
#: paperless/settings/__init__.py:776
|
||||
msgid "Serbian"
|
||||
msgstr ""
|
||||
|
||||
#: paperless/settings.py:850
|
||||
#: paperless/settings/__init__.py:777
|
||||
msgid "Swedish"
|
||||
msgstr ""
|
||||
|
||||
#: paperless/settings.py:851
|
||||
#: paperless/settings/__init__.py:778
|
||||
msgid "Turkish"
|
||||
msgstr ""
|
||||
|
||||
#: paperless/settings.py:852
|
||||
#: paperless/settings/__init__.py:779
|
||||
msgid "Ukrainian"
|
||||
msgstr ""
|
||||
|
||||
#: paperless/settings.py:853
|
||||
#: paperless/settings/__init__.py:780
|
||||
msgid "Vietnamese"
|
||||
msgstr ""
|
||||
|
||||
#: paperless/settings.py:854
|
||||
#: paperless/settings/__init__.py:781
|
||||
msgid "Chinese Simplified"
|
||||
msgstr ""
|
||||
|
||||
#: paperless/settings.py:855
|
||||
#: paperless/settings/__init__.py:782
|
||||
msgid "Chinese Traditional"
|
||||
msgstr ""
|
||||
|
||||
|
||||
@@ -202,3 +202,43 @@ def audit_log_check(app_configs, **kwargs):
|
||||
)
|
||||
|
||||
return result
|
||||
|
||||
|
||||
@register()
|
||||
def check_deprecated_db_settings(
|
||||
app_configs: object,
|
||||
**kwargs: object,
|
||||
) -> list[Warning]:
|
||||
"""Check for deprecated database environment variables.
|
||||
|
||||
Detects legacy advanced options that should be migrated to
|
||||
PAPERLESS_DB_OPTIONS. Returns one Warning per deprecated variable found.
|
||||
"""
|
||||
deprecated_vars: dict[str, str] = {
|
||||
"PAPERLESS_DB_TIMEOUT": "timeout",
|
||||
"PAPERLESS_DB_POOLSIZE": "pool.min_size / pool.max_size",
|
||||
"PAPERLESS_DBSSLMODE": "sslmode",
|
||||
"PAPERLESS_DBSSLROOTCERT": "sslrootcert",
|
||||
"PAPERLESS_DBSSLCERT": "sslcert",
|
||||
"PAPERLESS_DBSSLKEY": "sslkey",
|
||||
}
|
||||
|
||||
warnings: list[Warning] = []
|
||||
|
||||
for var_name, db_option_key in deprecated_vars.items():
|
||||
if not os.getenv(var_name):
|
||||
continue
|
||||
warnings.append(
|
||||
Warning(
|
||||
f"Deprecated environment variable: {var_name}",
|
||||
hint=(
|
||||
f"{var_name} is no longer supported and will be removed in v3.2. "
|
||||
f"Set the equivalent option via PAPERLESS_DB_OPTIONS instead. "
|
||||
f'Example: PAPERLESS_DB_OPTIONS=\'{{"{db_option_key}": "<value>"}}\'. '
|
||||
"See https://docs.paperless-ngx.com/migration/ for the full reference."
|
||||
),
|
||||
id="paperless.W001",
|
||||
),
|
||||
)
|
||||
|
||||
return warnings
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import json
|
||||
from typing import Any
|
||||
|
||||
from asgiref.sync import async_to_sync
|
||||
from channels.exceptions import AcceptConnection
|
||||
@@ -53,10 +52,3 @@ class StatusConsumer(WebsocketConsumer):
|
||||
self.close()
|
||||
else:
|
||||
self.send(json.dumps(event))
|
||||
|
||||
def document_updated(self, event: Any) -> None:
|
||||
if not self._authenticated():
|
||||
self.close()
|
||||
else:
|
||||
if self._can_view(event["data"]):
|
||||
self.send(json.dumps(event))
|
||||
|
||||
@@ -17,6 +17,8 @@ from dateparser.languages.loader import LocaleDataLoader
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
from dotenv import load_dotenv
|
||||
|
||||
from paperless.settings.custom import parse_db_settings
|
||||
|
||||
logger = logging.getLogger("paperless.settings")
|
||||
|
||||
# Tap paperless.conf if it's available
|
||||
@@ -282,7 +284,7 @@ DEBUG = __get_boolean("PAPERLESS_DEBUG", "NO")
|
||||
# Directories #
|
||||
###############################################################################
|
||||
|
||||
BASE_DIR: Path = Path(__file__).resolve().parent.parent
|
||||
BASE_DIR: Path = Path(__file__).resolve().parent.parent.parent
|
||||
|
||||
STATIC_ROOT = __get_path("PAPERLESS_STATICDIR", BASE_DIR.parent / "static")
|
||||
|
||||
@@ -722,83 +724,8 @@ EMAIL_CERTIFICATE_FILE = __get_optional_path("PAPERLESS_EMAIL_CERTIFICATE_LOCATI
|
||||
###############################################################################
|
||||
# Database #
|
||||
###############################################################################
|
||||
def _parse_db_settings() -> dict:
|
||||
databases = {
|
||||
"default": {
|
||||
"ENGINE": "django.db.backends.sqlite3",
|
||||
"NAME": DATA_DIR / "db.sqlite3",
|
||||
"OPTIONS": {},
|
||||
},
|
||||
}
|
||||
if os.getenv("PAPERLESS_DBHOST"):
|
||||
# Have sqlite available as a second option for management commands
|
||||
# This is important when migrating to/from sqlite
|
||||
databases["sqlite"] = databases["default"].copy()
|
||||
|
||||
databases["default"] = {
|
||||
"HOST": os.getenv("PAPERLESS_DBHOST"),
|
||||
"NAME": os.getenv("PAPERLESS_DBNAME", "paperless"),
|
||||
"USER": os.getenv("PAPERLESS_DBUSER", "paperless"),
|
||||
"PASSWORD": os.getenv("PAPERLESS_DBPASS", "paperless"),
|
||||
"OPTIONS": {},
|
||||
}
|
||||
if os.getenv("PAPERLESS_DBPORT"):
|
||||
databases["default"]["PORT"] = os.getenv("PAPERLESS_DBPORT")
|
||||
|
||||
# Leave room for future extensibility
|
||||
if os.getenv("PAPERLESS_DBENGINE") == "mariadb":
|
||||
engine = "django.db.backends.mysql"
|
||||
# Contrary to Postgres, Django does not natively support connection pooling for MariaDB.
|
||||
# However, since MariaDB uses threads instead of forks, establishing connections is significantly faster
|
||||
# compared to PostgreSQL, so the lack of pooling is not an issue
|
||||
options = {
|
||||
"read_default_file": "/etc/mysql/my.cnf",
|
||||
"charset": "utf8mb4",
|
||||
"ssl_mode": os.getenv("PAPERLESS_DBSSLMODE", "PREFERRED"),
|
||||
"ssl": {
|
||||
"ca": os.getenv("PAPERLESS_DBSSLROOTCERT", None),
|
||||
"cert": os.getenv("PAPERLESS_DBSSLCERT", None),
|
||||
"key": os.getenv("PAPERLESS_DBSSLKEY", None),
|
||||
},
|
||||
}
|
||||
|
||||
else: # Default to PostgresDB
|
||||
engine = "django.db.backends.postgresql"
|
||||
options = {
|
||||
"sslmode": os.getenv("PAPERLESS_DBSSLMODE", "prefer"),
|
||||
"sslrootcert": os.getenv("PAPERLESS_DBSSLROOTCERT", None),
|
||||
"sslcert": os.getenv("PAPERLESS_DBSSLCERT", None),
|
||||
"sslkey": os.getenv("PAPERLESS_DBSSLKEY", None),
|
||||
}
|
||||
if int(os.getenv("PAPERLESS_DB_POOLSIZE", 0)) > 0:
|
||||
options.update(
|
||||
{
|
||||
"pool": {
|
||||
"min_size": 1,
|
||||
"max_size": int(os.getenv("PAPERLESS_DB_POOLSIZE")),
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
databases["default"]["ENGINE"] = engine
|
||||
databases["default"]["OPTIONS"].update(options)
|
||||
|
||||
if os.getenv("PAPERLESS_DB_TIMEOUT") is not None:
|
||||
if databases["default"]["ENGINE"] == "django.db.backends.sqlite3":
|
||||
databases["default"]["OPTIONS"].update(
|
||||
{"timeout": int(os.getenv("PAPERLESS_DB_TIMEOUT"))},
|
||||
)
|
||||
else:
|
||||
databases["default"]["OPTIONS"].update(
|
||||
{"connect_timeout": int(os.getenv("PAPERLESS_DB_TIMEOUT"))},
|
||||
)
|
||||
databases["sqlite"]["OPTIONS"].update(
|
||||
{"timeout": int(os.getenv("PAPERLESS_DB_TIMEOUT"))},
|
||||
)
|
||||
return databases
|
||||
|
||||
|
||||
DATABASES = _parse_db_settings()
|
||||
DATABASES = parse_db_settings(DATA_DIR)
|
||||
|
||||
if os.getenv("PAPERLESS_DBENGINE") == "mariadb":
|
||||
# Silence Django error on old MariaDB versions.
|
||||
122
src/paperless/settings/custom.py
Normal file
122
src/paperless/settings/custom.py
Normal file
@@ -0,0 +1,122 @@
|
||||
import os
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from paperless.settings.parsers import get_choice_from_env
|
||||
from paperless.settings.parsers import get_int_from_env
|
||||
from paperless.settings.parsers import parse_dict_from_str
|
||||
|
||||
|
||||
def parse_db_settings(data_dir: Path) -> dict[str, dict[str, Any]]:
|
||||
"""Parse database settings from environment variables.
|
||||
|
||||
Core connection variables (no deprecation):
|
||||
- PAPERLESS_DBENGINE (sqlite/postgresql/mariadb)
|
||||
- PAPERLESS_DBHOST, PAPERLESS_DBPORT
|
||||
- PAPERLESS_DBNAME, PAPERLESS_DBUSER, PAPERLESS_DBPASS
|
||||
|
||||
Advanced options can be set via:
|
||||
- Legacy individual env vars (deprecated in v3.0, removed in v3.2)
|
||||
- PAPERLESS_DB_OPTIONS (recommended v3+ approach)
|
||||
|
||||
Args:
|
||||
data_dir: The data directory path for SQLite database location.
|
||||
|
||||
Returns:
|
||||
A databases dict suitable for Django DATABASES setting.
|
||||
"""
|
||||
try:
|
||||
engine = get_choice_from_env(
|
||||
"PAPERLESS_DBENGINE",
|
||||
{"sqlite", "postgresql", "mariadb"},
|
||||
default="sqlite",
|
||||
)
|
||||
except ValueError:
|
||||
# MariaDB users already had to set PAPERLESS_DBENGINE, so it was picked up above
|
||||
# SQLite users didn't need to set anything
|
||||
engine = "postgresql" if "PAPERLESS_DBHOST" in os.environ else "sqlite"
|
||||
|
||||
db_config: dict[str, Any]
|
||||
base_options: dict[str, Any]
|
||||
|
||||
match engine:
|
||||
case "sqlite":
|
||||
db_config = {
|
||||
"ENGINE": "django.db.backends.sqlite3",
|
||||
"NAME": str((data_dir / "db.sqlite3").resolve()),
|
||||
}
|
||||
base_options = {}
|
||||
|
||||
case "postgresql":
|
||||
db_config = {
|
||||
"ENGINE": "django.db.backends.postgresql",
|
||||
"HOST": os.getenv("PAPERLESS_DBHOST"),
|
||||
"NAME": os.getenv("PAPERLESS_DBNAME", "paperless"),
|
||||
"USER": os.getenv("PAPERLESS_DBUSER", "paperless"),
|
||||
"PASSWORD": os.getenv("PAPERLESS_DBPASS", "paperless"),
|
||||
}
|
||||
|
||||
base_options = {
|
||||
"sslmode": os.getenv("PAPERLESS_DBSSLMODE", "prefer"),
|
||||
"sslrootcert": os.getenv("PAPERLESS_DBSSLROOTCERT"),
|
||||
"sslcert": os.getenv("PAPERLESS_DBSSLCERT"),
|
||||
"sslkey": os.getenv("PAPERLESS_DBSSLKEY"),
|
||||
}
|
||||
|
||||
if (pool_size := get_int_from_env("PAPERLESS_DB_POOLSIZE")) is not None:
|
||||
base_options["pool"] = {
|
||||
"min_size": 1,
|
||||
"max_size": pool_size,
|
||||
}
|
||||
|
||||
case "mariadb":
|
||||
db_config = {
|
||||
"ENGINE": "django.db.backends.mysql",
|
||||
"HOST": os.getenv("PAPERLESS_DBHOST"),
|
||||
"NAME": os.getenv("PAPERLESS_DBNAME", "paperless"),
|
||||
"USER": os.getenv("PAPERLESS_DBUSER", "paperless"),
|
||||
"PASSWORD": os.getenv("PAPERLESS_DBPASS", "paperless"),
|
||||
}
|
||||
|
||||
base_options = {
|
||||
"read_default_file": "/etc/mysql/my.cnf",
|
||||
"charset": "utf8mb4",
|
||||
"collation": "utf8mb4_unicode_ci",
|
||||
"ssl_mode": os.getenv("PAPERLESS_DBSSLMODE", "PREFERRED"),
|
||||
"ssl": {
|
||||
"ca": os.getenv("PAPERLESS_DBSSLROOTCERT"),
|
||||
"cert": os.getenv("PAPERLESS_DBSSLCERT"),
|
||||
"key": os.getenv("PAPERLESS_DBSSLKEY"),
|
||||
},
|
||||
}
|
||||
case _: # pragma: no cover
|
||||
raise NotImplementedError(engine)
|
||||
|
||||
# Handle port setting for external databases
|
||||
if (
|
||||
engine in ("postgresql", "mariadb")
|
||||
and (port := get_int_from_env("PAPERLESS_DBPORT")) is not None
|
||||
):
|
||||
db_config["PORT"] = port
|
||||
|
||||
# Handle timeout setting (common across all engines, different key names)
|
||||
if (timeout := get_int_from_env("PAPERLESS_DB_TIMEOUT")) is not None:
|
||||
timeout_key = "timeout" if engine == "sqlite" else "connect_timeout"
|
||||
base_options[timeout_key] = timeout
|
||||
|
||||
# Apply PAPERLESS_DB_OPTIONS overrides
|
||||
db_config["OPTIONS"] = parse_dict_from_str(
|
||||
os.getenv("PAPERLESS_DB_OPTIONS"),
|
||||
defaults=base_options,
|
||||
separator=";",
|
||||
type_map={
|
||||
# SQLite options
|
||||
"timeout": int,
|
||||
# Postgres/MariaDB options
|
||||
"connect_timeout": int,
|
||||
"pool.min_size": int,
|
||||
"pool.max_size": int,
|
||||
},
|
||||
)
|
||||
|
||||
return {"default": db_config}
|
||||
192
src/paperless/settings/parsers.py
Normal file
192
src/paperless/settings/parsers.py
Normal file
@@ -0,0 +1,192 @@
|
||||
import copy
|
||||
import os
|
||||
from collections.abc import Callable
|
||||
from collections.abc import Mapping
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
from typing import TypeVar
|
||||
from typing import overload
|
||||
|
||||
T = TypeVar("T")
|
||||
|
||||
|
||||
def str_to_bool(value: str) -> bool:
|
||||
"""
|
||||
Converts a string representation of truth to a boolean value.
|
||||
|
||||
Recognizes 'true', '1', 't', 'y', 'yes' as True, and
|
||||
'false', '0', 'f', 'n', 'no' as False. Case-insensitive.
|
||||
|
||||
Args:
|
||||
value: The string to convert.
|
||||
|
||||
Returns:
|
||||
The boolean representation of the string.
|
||||
|
||||
Raises:
|
||||
ValueError: If the string is not a recognized boolean value.
|
||||
"""
|
||||
val_lower = value.strip().lower()
|
||||
if val_lower in ("true", "1", "t", "y", "yes"):
|
||||
return True
|
||||
elif val_lower in ("false", "0", "f", "n", "no"):
|
||||
return False
|
||||
raise ValueError(f"Cannot convert '{value}' to a boolean.")
|
||||
|
||||
|
||||
@overload
|
||||
def get_int_from_env(key: str) -> int | None: ...
|
||||
|
||||
|
||||
@overload
|
||||
def get_int_from_env(key: str, default: None) -> int | None: ...
|
||||
|
||||
|
||||
@overload
|
||||
def get_int_from_env(key: str, default: int) -> int: ...
|
||||
|
||||
|
||||
def get_int_from_env(key: str, default: int | None = None) -> int | None:
|
||||
"""
|
||||
Return an integer value based on the environment variable.
|
||||
If default is provided, returns that value when key is missing.
|
||||
If default is None, returns None when key is missing.
|
||||
"""
|
||||
if key not in os.environ:
|
||||
return default
|
||||
|
||||
return int(os.environ[key])
|
||||
|
||||
|
||||
def parse_dict_from_str(
|
||||
env_str: str | None,
|
||||
defaults: dict[str, Any] | None = None,
|
||||
type_map: Mapping[str, Callable[[str], Any]] | None = None,
|
||||
separator: str = ",",
|
||||
) -> dict[str, Any]:
|
||||
"""
|
||||
Parses a key-value string into a dictionary, applying defaults and casting types.
|
||||
|
||||
Supports nested keys via dot-notation, e.g.:
|
||||
"database.host=localhost,database.port=5432"
|
||||
|
||||
Args:
|
||||
env_str: The string from the environment variable (e.g., "port=9090,debug=true").
|
||||
defaults: A dictionary of default values (can contain nested dicts).
|
||||
type_map: A dictionary mapping keys (dot-notation allowed) to a type or a parsing
|
||||
function (e.g., {'port': int, 'debug': bool, 'database.port': int}).
|
||||
The special `bool` type triggers custom boolean parsing.
|
||||
separator: The character used to separate key-value pairs. Defaults to ','.
|
||||
|
||||
Returns:
|
||||
A dictionary with the parsed and correctly-typed settings.
|
||||
|
||||
Raises:
|
||||
ValueError: If a value cannot be cast to its specified type.
|
||||
"""
|
||||
|
||||
def _set_nested(d: dict, keys: list[str], value: Any) -> None:
|
||||
"""Set a nested value, creating intermediate dicts as needed."""
|
||||
cur = d
|
||||
for k in keys[:-1]:
|
||||
if k not in cur or not isinstance(cur[k], dict):
|
||||
cur[k] = {}
|
||||
cur = cur[k]
|
||||
cur[keys[-1]] = value
|
||||
|
||||
def _get_nested(d: dict, keys: list[str]) -> Any:
|
||||
"""Get nested value or raise KeyError if not present."""
|
||||
cur = d
|
||||
for k in keys:
|
||||
if not isinstance(cur, dict) or k not in cur:
|
||||
raise KeyError
|
||||
cur = cur[k]
|
||||
return cur
|
||||
|
||||
def _has_nested(d: dict, keys: list[str]) -> bool:
|
||||
try:
|
||||
_get_nested(d, keys)
|
||||
return True
|
||||
except KeyError:
|
||||
return False
|
||||
|
||||
settings: dict[str, Any] = copy.deepcopy(defaults) if defaults else {}
|
||||
_type_map = type_map if type_map else {}
|
||||
|
||||
if not env_str:
|
||||
return settings
|
||||
|
||||
# Parse the environment string using the specified separator
|
||||
pairs = [p.strip() for p in env_str.split(separator) if p.strip()]
|
||||
for pair in pairs:
|
||||
if "=" not in pair:
|
||||
# ignore malformed pairs
|
||||
continue
|
||||
key, val = pair.split("=", 1)
|
||||
key = key.strip()
|
||||
val = val.strip()
|
||||
if not key:
|
||||
continue
|
||||
parts = key.split(".")
|
||||
_set_nested(settings, parts, val)
|
||||
|
||||
# Apply type casting to the updated settings (supports nested keys in type_map)
|
||||
for key, caster in _type_map.items():
|
||||
key_parts = key.split(".")
|
||||
if _has_nested(settings, key_parts):
|
||||
raw_val = _get_nested(settings, key_parts)
|
||||
# Only cast if it's a string (i.e. from env parsing). If defaults already provided
|
||||
# a different type we leave it as-is.
|
||||
if isinstance(raw_val, str):
|
||||
try:
|
||||
if caster is bool:
|
||||
parsed = str_to_bool(raw_val)
|
||||
elif caster is Path:
|
||||
parsed = Path(raw_val).resolve()
|
||||
else:
|
||||
parsed = caster(raw_val)
|
||||
except (ValueError, TypeError) as e:
|
||||
caster_name = getattr(caster, "__name__", repr(caster))
|
||||
raise ValueError(
|
||||
f"Error casting key '{key}' with value '{raw_val}' "
|
||||
f"to type '{caster_name}'",
|
||||
) from e
|
||||
_set_nested(settings, key_parts, parsed)
|
||||
|
||||
return settings
|
||||
|
||||
|
||||
def get_choice_from_env(
|
||||
env_key: str,
|
||||
choices: set[str],
|
||||
default: str | None = None,
|
||||
) -> str:
|
||||
"""
|
||||
Gets and validates an environment variable against a set of allowed choices.
|
||||
|
||||
Args:
|
||||
env_key: The environment variable key to validate
|
||||
choices: Set of valid choices for the environment variable
|
||||
default: Optional default value if environment variable is not set
|
||||
|
||||
Returns:
|
||||
The validated environment variable value
|
||||
|
||||
Raises:
|
||||
ValueError: If the environment variable value is not in choices
|
||||
or if no default is provided and env var is missing
|
||||
"""
|
||||
value = os.environ.get(env_key, default)
|
||||
|
||||
if value is None:
|
||||
raise ValueError(
|
||||
f"Environment variable '{env_key}' is required but not set.",
|
||||
)
|
||||
|
||||
if value not in choices:
|
||||
raise ValueError(
|
||||
f"Environment variable '{env_key}' has invalid value '{value}'. "
|
||||
f"Valid choices are: {', '.join(sorted(choices))}",
|
||||
)
|
||||
|
||||
return value
|
||||
0
src/paperless/tests/settings/__init__.py
Normal file
0
src/paperless/tests/settings/__init__.py
Normal file
266
src/paperless/tests/settings/test_custom_parsers.py
Normal file
266
src/paperless/tests/settings/test_custom_parsers.py
Normal file
@@ -0,0 +1,266 @@
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
from pytest_mock import MockerFixture
|
||||
|
||||
from paperless.settings.custom import parse_db_settings
|
||||
|
||||
|
||||
class TestParseDbSettings:
|
||||
"""Test suite for parse_db_settings function."""
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("env_vars", "expected_database_settings"),
|
||||
[
|
||||
pytest.param(
|
||||
{},
|
||||
{
|
||||
"default": {
|
||||
"ENGINE": "django.db.backends.sqlite3",
|
||||
"NAME": None, # Will be replaced with tmp_path
|
||||
"OPTIONS": {},
|
||||
},
|
||||
},
|
||||
id="default-sqlite",
|
||||
),
|
||||
pytest.param(
|
||||
{
|
||||
"PAPERLESS_DBENGINE": "sqlite",
|
||||
"PAPERLESS_DB_OPTIONS": "timeout=30",
|
||||
},
|
||||
{
|
||||
"default": {
|
||||
"ENGINE": "django.db.backends.sqlite3",
|
||||
"NAME": None, # Will be replaced with tmp_path
|
||||
"OPTIONS": {
|
||||
"timeout": 30,
|
||||
},
|
||||
},
|
||||
},
|
||||
id="sqlite-with-timeout-override",
|
||||
),
|
||||
pytest.param(
|
||||
{
|
||||
"PAPERLESS_DBENGINE": "postgresql",
|
||||
"PAPERLESS_DBHOST": "localhost",
|
||||
},
|
||||
{
|
||||
"default": {
|
||||
"ENGINE": "django.db.backends.postgresql",
|
||||
"HOST": "localhost",
|
||||
"NAME": "paperless",
|
||||
"USER": "paperless",
|
||||
"PASSWORD": "paperless",
|
||||
"OPTIONS": {
|
||||
"sslmode": "prefer",
|
||||
"sslrootcert": None,
|
||||
"sslcert": None,
|
||||
"sslkey": None,
|
||||
},
|
||||
},
|
||||
},
|
||||
id="postgresql-defaults",
|
||||
),
|
||||
pytest.param(
|
||||
{
|
||||
"PAPERLESS_DBENGINE": "postgresql",
|
||||
"PAPERLESS_DBHOST": "paperless-db-host",
|
||||
"PAPERLESS_DBPORT": "1111",
|
||||
"PAPERLESS_DBNAME": "customdb",
|
||||
"PAPERLESS_DBUSER": "customuser",
|
||||
"PAPERLESS_DBPASS": "custompass",
|
||||
"PAPERLESS_DB_OPTIONS": "pool.max_size=50;pool.min_size=2;sslmode=require",
|
||||
},
|
||||
{
|
||||
"default": {
|
||||
"ENGINE": "django.db.backends.postgresql",
|
||||
"HOST": "paperless-db-host",
|
||||
"PORT": 1111,
|
||||
"NAME": "customdb",
|
||||
"USER": "customuser",
|
||||
"PASSWORD": "custompass",
|
||||
"OPTIONS": {
|
||||
"sslmode": "require",
|
||||
"sslrootcert": None,
|
||||
"sslcert": None,
|
||||
"sslkey": None,
|
||||
"pool": {
|
||||
"min_size": 2,
|
||||
"max_size": 50,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
id="postgresql-overrides",
|
||||
),
|
||||
pytest.param(
|
||||
{
|
||||
"PAPERLESS_DBENGINE": "postgresql",
|
||||
"PAPERLESS_DBHOST": "pghost",
|
||||
"PAPERLESS_DB_POOLSIZE": "10",
|
||||
},
|
||||
{
|
||||
"default": {
|
||||
"ENGINE": "django.db.backends.postgresql",
|
||||
"HOST": "pghost",
|
||||
"NAME": "paperless",
|
||||
"USER": "paperless",
|
||||
"PASSWORD": "paperless",
|
||||
"OPTIONS": {
|
||||
"sslmode": "prefer",
|
||||
"sslrootcert": None,
|
||||
"sslcert": None,
|
||||
"sslkey": None,
|
||||
"pool": {
|
||||
"min_size": 1,
|
||||
"max_size": 10,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
id="postgresql-legacy-poolsize",
|
||||
),
|
||||
pytest.param(
|
||||
{
|
||||
"PAPERLESS_DBENGINE": "postgresql",
|
||||
"PAPERLESS_DBHOST": "pghost",
|
||||
"PAPERLESS_DBSSLMODE": "require",
|
||||
"PAPERLESS_DBSSLROOTCERT": "/certs/ca.crt",
|
||||
"PAPERLESS_DB_TIMEOUT": "30",
|
||||
},
|
||||
{
|
||||
"default": {
|
||||
"ENGINE": "django.db.backends.postgresql",
|
||||
"HOST": "pghost",
|
||||
"NAME": "paperless",
|
||||
"USER": "paperless",
|
||||
"PASSWORD": "paperless",
|
||||
"OPTIONS": {
|
||||
"sslmode": "require",
|
||||
"sslrootcert": "/certs/ca.crt",
|
||||
"sslcert": None,
|
||||
"sslkey": None,
|
||||
"connect_timeout": 30,
|
||||
},
|
||||
},
|
||||
},
|
||||
id="postgresql-legacy-ssl-and-timeout",
|
||||
),
|
||||
pytest.param(
|
||||
{
|
||||
"PAPERLESS_DBENGINE": "mariadb",
|
||||
"PAPERLESS_DBHOST": "localhost",
|
||||
},
|
||||
{
|
||||
"default": {
|
||||
"ENGINE": "django.db.backends.mysql",
|
||||
"HOST": "localhost",
|
||||
"NAME": "paperless",
|
||||
"USER": "paperless",
|
||||
"PASSWORD": "paperless",
|
||||
"OPTIONS": {
|
||||
"read_default_file": "/etc/mysql/my.cnf",
|
||||
"charset": "utf8mb4",
|
||||
"collation": "utf8mb4_unicode_ci",
|
||||
"ssl_mode": "PREFERRED",
|
||||
"ssl": {
|
||||
"ca": None,
|
||||
"cert": None,
|
||||
"key": None,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
id="mariadb-defaults",
|
||||
),
|
||||
pytest.param(
|
||||
{
|
||||
"PAPERLESS_DBENGINE": "mariadb",
|
||||
"PAPERLESS_DBHOST": "paperless-mariadb-host",
|
||||
"PAPERLESS_DBPORT": "5555",
|
||||
"PAPERLESS_DBUSER": "my-cool-user",
|
||||
"PAPERLESS_DBPASS": "my-secure-password",
|
||||
"PAPERLESS_DB_OPTIONS": "ssl.ca=/path/to/ca.pem;ssl_mode=REQUIRED",
|
||||
},
|
||||
{
|
||||
"default": {
|
||||
"ENGINE": "django.db.backends.mysql",
|
||||
"HOST": "paperless-mariadb-host",
|
||||
"PORT": 5555,
|
||||
"NAME": "paperless",
|
||||
"USER": "my-cool-user",
|
||||
"PASSWORD": "my-secure-password",
|
||||
"OPTIONS": {
|
||||
"read_default_file": "/etc/mysql/my.cnf",
|
||||
"charset": "utf8mb4",
|
||||
"collation": "utf8mb4_unicode_ci",
|
||||
"ssl_mode": "REQUIRED",
|
||||
"ssl": {
|
||||
"ca": "/path/to/ca.pem",
|
||||
"cert": None,
|
||||
"key": None,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
id="mariadb-overrides",
|
||||
),
|
||||
pytest.param(
|
||||
{
|
||||
"PAPERLESS_DBENGINE": "mariadb",
|
||||
"PAPERLESS_DBHOST": "mariahost",
|
||||
"PAPERLESS_DBSSLMODE": "REQUIRED",
|
||||
"PAPERLESS_DBSSLROOTCERT": "/certs/ca.pem",
|
||||
"PAPERLESS_DBSSLCERT": "/certs/client.pem",
|
||||
"PAPERLESS_DBSSLKEY": "/certs/client.key",
|
||||
"PAPERLESS_DB_TIMEOUT": "25",
|
||||
},
|
||||
{
|
||||
"default": {
|
||||
"ENGINE": "django.db.backends.mysql",
|
||||
"HOST": "mariahost",
|
||||
"NAME": "paperless",
|
||||
"USER": "paperless",
|
||||
"PASSWORD": "paperless",
|
||||
"OPTIONS": {
|
||||
"read_default_file": "/etc/mysql/my.cnf",
|
||||
"charset": "utf8mb4",
|
||||
"collation": "utf8mb4_unicode_ci",
|
||||
"ssl_mode": "REQUIRED",
|
||||
"ssl": {
|
||||
"ca": "/certs/ca.pem",
|
||||
"cert": "/certs/client.pem",
|
||||
"key": "/certs/client.key",
|
||||
},
|
||||
"connect_timeout": 25,
|
||||
},
|
||||
},
|
||||
},
|
||||
id="mariadb-legacy-ssl-and-timeout",
|
||||
),
|
||||
],
|
||||
)
|
||||
def test_parse_db_settings(
|
||||
self,
|
||||
tmp_path: Path,
|
||||
mocker: MockerFixture,
|
||||
env_vars: dict[str, str],
|
||||
expected_database_settings: dict[str, dict],
|
||||
) -> None:
|
||||
"""Test various database configurations with defaults and overrides."""
|
||||
# Clear environment and set test vars
|
||||
mocker.patch.dict(os.environ, env_vars, clear=True)
|
||||
|
||||
# Update expected paths with actual tmp_path
|
||||
if (
|
||||
"default" in expected_database_settings
|
||||
and expected_database_settings["default"]["NAME"] is None
|
||||
):
|
||||
expected_database_settings["default"]["NAME"] = str(
|
||||
tmp_path / "db.sqlite3",
|
||||
)
|
||||
|
||||
settings = parse_db_settings(tmp_path)
|
||||
|
||||
assert settings == expected_database_settings
|
||||
414
src/paperless/tests/settings/test_environment_parsers.py
Normal file
414
src/paperless/tests/settings/test_environment_parsers.py
Normal file
@@ -0,0 +1,414 @@
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
from pytest_mock import MockerFixture
|
||||
|
||||
from paperless.settings.parsers import get_choice_from_env
|
||||
from paperless.settings.parsers import get_int_from_env
|
||||
from paperless.settings.parsers import parse_dict_from_str
|
||||
from paperless.settings.parsers import str_to_bool
|
||||
|
||||
|
||||
class TestStringToBool:
|
||||
@pytest.mark.parametrize(
|
||||
"true_value",
|
||||
[
|
||||
pytest.param("true", id="lowercase_true"),
|
||||
pytest.param("1", id="digit_1"),
|
||||
pytest.param("T", id="capital_T"),
|
||||
pytest.param("y", id="lowercase_y"),
|
||||
pytest.param("YES", id="uppercase_YES"),
|
||||
pytest.param(" True ", id="whitespace_true"),
|
||||
],
|
||||
)
|
||||
def test_true_conversion(self, true_value: str):
|
||||
"""Test that various 'true' strings correctly evaluate to True."""
|
||||
assert str_to_bool(true_value) is True
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"false_value",
|
||||
[
|
||||
pytest.param("false", id="lowercase_false"),
|
||||
pytest.param("0", id="digit_0"),
|
||||
pytest.param("f", id="capital_f"),
|
||||
pytest.param("N", id="capital_N"),
|
||||
pytest.param("no", id="lowercase_no"),
|
||||
pytest.param(" False ", id="whitespace_false"),
|
||||
],
|
||||
)
|
||||
def test_false_conversion(self, false_value: str):
|
||||
"""Test that various 'false' strings correctly evaluate to False."""
|
||||
assert str_to_bool(false_value) is False
|
||||
|
||||
def test_invalid_conversion(self):
|
||||
"""Test that an invalid string raises a ValueError."""
|
||||
with pytest.raises(ValueError, match="Cannot convert 'maybe' to a boolean\\."):
|
||||
str_to_bool("maybe")
|
||||
|
||||
|
||||
class TestParseDictFromString:
|
||||
def test_empty_and_none_input(self):
|
||||
"""Test behavior with None or empty string input."""
|
||||
assert parse_dict_from_str(None) == {}
|
||||
assert parse_dict_from_str("") == {}
|
||||
defaults = {"a": 1}
|
||||
res = parse_dict_from_str(None, defaults=defaults)
|
||||
assert res == defaults
|
||||
# Ensure it returns a copy, not the original object
|
||||
assert res is not defaults
|
||||
|
||||
def test_basic_parsing(self):
|
||||
"""Test simple key-value parsing without defaults or types."""
|
||||
env_str = "key1=val1, key2=val2"
|
||||
expected = {"key1": "val1", "key2": "val2"}
|
||||
assert parse_dict_from_str(env_str) == expected
|
||||
|
||||
def test_with_defaults(self):
|
||||
"""Test that environment values override defaults correctly."""
|
||||
defaults = {"host": "localhost", "port": 8000, "user": "default"}
|
||||
env_str = "port=9090, host=db.example.com"
|
||||
expected = {"host": "db.example.com", "port": "9090", "user": "default"}
|
||||
result = parse_dict_from_str(env_str, defaults=defaults)
|
||||
assert result == expected
|
||||
|
||||
def test_type_casting(self):
|
||||
"""Test successful casting of values to specified types."""
|
||||
env_str = "port=9090, debug=true, timeout=12.5, user=admin"
|
||||
type_map = {"port": int, "debug": bool, "timeout": float}
|
||||
expected = {"port": 9090, "debug": True, "timeout": 12.5, "user": "admin"}
|
||||
result = parse_dict_from_str(env_str, type_map=type_map)
|
||||
assert result == expected
|
||||
|
||||
def test_type_casting_with_defaults(self):
|
||||
"""Test casting when values come from both defaults and env string."""
|
||||
defaults = {"port": 8000, "debug": False, "retries": 3}
|
||||
env_str = "port=9090, debug=true"
|
||||
type_map = {"port": int, "debug": bool, "retries": int}
|
||||
|
||||
# The 'retries' value comes from defaults and is already an int,
|
||||
# so it should not be processed by the caster.
|
||||
expected = {"port": 9090, "debug": True, "retries": 3}
|
||||
result = parse_dict_from_str(env_str, defaults=defaults, type_map=type_map)
|
||||
assert result == expected
|
||||
assert isinstance(result["retries"], int)
|
||||
|
||||
def test_path_casting(self, tmp_path: Path):
|
||||
"""Test successful casting of a string to a resolved pathlib.Path object."""
|
||||
# Create a dummy file to resolve against
|
||||
test_file = tmp_path / "test_file.txt"
|
||||
test_file.touch()
|
||||
|
||||
env_str = f"config_path={test_file}"
|
||||
type_map = {"config_path": Path}
|
||||
result = parse_dict_from_str(env_str, type_map=type_map)
|
||||
|
||||
# The result should be a resolved Path object
|
||||
assert isinstance(result["config_path"], Path)
|
||||
assert result["config_path"] == test_file.resolve()
|
||||
|
||||
def test_custom_separator(self):
|
||||
"""Test parsing with a custom separator like a semicolon."""
|
||||
env_str = "host=db; port=5432; user=test"
|
||||
expected = {"host": "db", "port": "5432", "user": "test"}
|
||||
result = parse_dict_from_str(env_str, separator=";")
|
||||
assert result == expected
|
||||
|
||||
def test_edge_cases_in_string(self):
|
||||
"""Test malformed strings to ensure robustness."""
|
||||
# Malformed pair 'debug' is skipped, extra comma is ignored
|
||||
env_str = "key=val,, debug, foo=bar"
|
||||
expected = {"key": "val", "foo": "bar"}
|
||||
assert parse_dict_from_str(env_str) == expected
|
||||
|
||||
# Value can contain the equals sign
|
||||
env_str = "url=postgres://user:pass@host:5432/db"
|
||||
expected = {"url": "postgres://user:pass@host:5432/db"}
|
||||
assert parse_dict_from_str(env_str) == expected
|
||||
|
||||
def test_casting_error_handling(self):
|
||||
"""Test that a ValueError is raised for invalid casting."""
|
||||
env_str = "port=not-a-number"
|
||||
type_map = {"port": int}
|
||||
|
||||
with pytest.raises(ValueError) as excinfo:
|
||||
parse_dict_from_str(env_str, type_map=type_map)
|
||||
|
||||
assert "Error casting key 'port'" in str(excinfo.value)
|
||||
assert "value 'not-a-number'" in str(excinfo.value)
|
||||
assert "to type 'int'" in str(excinfo.value)
|
||||
|
||||
def test_bool_casting_error(self):
|
||||
"""Test that an invalid boolean string raises a ValueError."""
|
||||
env_str = "debug=maybe"
|
||||
type_map = {"debug": bool}
|
||||
with pytest.raises(ValueError, match="Error casting key 'debug'"):
|
||||
parse_dict_from_str(env_str, type_map=type_map)
|
||||
|
||||
def test_nested_key_parsing_basic(self):
|
||||
"""Basic nested key parsing using dot-notation."""
|
||||
env_str = "database.host=db.example.com, database.port=5432, logging.level=INFO"
|
||||
result = parse_dict_from_str(env_str)
|
||||
assert result == {
|
||||
"database": {"host": "db.example.com", "port": "5432"},
|
||||
"logging": {"level": "INFO"},
|
||||
}
|
||||
|
||||
def test_nested_overrides_defaults_and_deepcopy(self):
|
||||
"""Nested env keys override defaults and defaults are deep-copied."""
|
||||
defaults = {"database": {"host": "127.0.0.1", "port": 3306, "user": "default"}}
|
||||
env_str = "database.host=db.example.com, debug=true"
|
||||
result = parse_dict_from_str(
|
||||
env_str,
|
||||
defaults=defaults,
|
||||
type_map={"debug": bool},
|
||||
)
|
||||
|
||||
assert result["database"]["host"] == "db.example.com"
|
||||
# Unchanged default preserved
|
||||
assert result["database"]["port"] == 3306
|
||||
assert result["database"]["user"] == "default"
|
||||
# Default object was deep-copied (no same nested object identity)
|
||||
assert result is not defaults
|
||||
assert result["database"] is not defaults["database"]
|
||||
|
||||
def test_nested_type_casting(self):
|
||||
"""Type casting for nested keys (dot-notation) should work."""
|
||||
env_str = "database.host=db.example.com, database.port=5433, debug=false"
|
||||
type_map = {"database.port": int, "debug": bool}
|
||||
result = parse_dict_from_str(env_str, type_map=type_map)
|
||||
|
||||
assert result["database"]["host"] == "db.example.com"
|
||||
assert result["database"]["port"] == 5433
|
||||
assert isinstance(result["database"]["port"], int)
|
||||
assert result["debug"] is False
|
||||
assert isinstance(result["debug"], bool)
|
||||
|
||||
def test_nested_casting_error_message(self):
|
||||
"""Error messages should include the full dotted key name on failure."""
|
||||
env_str = "database.port=not-a-number"
|
||||
type_map = {"database.port": int}
|
||||
with pytest.raises(ValueError) as excinfo:
|
||||
parse_dict_from_str(env_str, type_map=type_map)
|
||||
|
||||
msg = str(excinfo.value)
|
||||
assert "Error casting key 'database.port'" in msg
|
||||
assert "value 'not-a-number'" in msg
|
||||
assert "to type 'int'" in msg
|
||||
|
||||
def test_type_map_does_not_recast_non_string_defaults(self):
|
||||
"""If a default already provides a non-string value, the caster should skip it."""
|
||||
defaults = {"database": {"port": 3306}}
|
||||
type_map = {"database.port": int}
|
||||
result = parse_dict_from_str(None, defaults=defaults, type_map=type_map)
|
||||
assert result["database"]["port"] == 3306
|
||||
assert isinstance(result["database"]["port"], int)
|
||||
|
||||
|
||||
class TestGetIntFromEnv:
|
||||
@pytest.mark.parametrize(
|
||||
("env_value", "expected"),
|
||||
[
|
||||
pytest.param("42", 42, id="positive"),
|
||||
pytest.param("-10", -10, id="negative"),
|
||||
pytest.param("0", 0, id="zero"),
|
||||
pytest.param("999", 999, id="large_positive"),
|
||||
pytest.param("-999", -999, id="large_negative"),
|
||||
],
|
||||
)
|
||||
def test_existing_env_var_valid_ints(self, mocker, env_value, expected):
|
||||
"""Test that existing environment variables with valid integers return correct values."""
|
||||
mocker.patch.dict(os.environ, {"INT_VAR": env_value})
|
||||
assert get_int_from_env("INT_VAR") == expected
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("default", "expected"),
|
||||
[
|
||||
pytest.param(100, 100, id="positive_default"),
|
||||
pytest.param(0, 0, id="zero_default"),
|
||||
pytest.param(-50, -50, id="negative_default"),
|
||||
pytest.param(None, None, id="none_default"),
|
||||
],
|
||||
)
|
||||
def test_missing_env_var_with_defaults(self, mocker, default, expected):
|
||||
"""Test that missing environment variables return provided defaults."""
|
||||
mocker.patch.dict(os.environ, {}, clear=True)
|
||||
assert get_int_from_env("MISSING_VAR", default=default) == expected
|
||||
|
||||
def test_missing_env_var_no_default(self, mocker):
|
||||
"""Test that missing environment variable with no default returns None."""
|
||||
mocker.patch.dict(os.environ, {}, clear=True)
|
||||
assert get_int_from_env("MISSING_VAR") is None
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"invalid_value",
|
||||
[
|
||||
pytest.param("not_a_number", id="text"),
|
||||
pytest.param("42.5", id="float"),
|
||||
pytest.param("42a", id="alpha_suffix"),
|
||||
pytest.param("", id="empty"),
|
||||
pytest.param(" ", id="whitespace"),
|
||||
pytest.param("true", id="boolean"),
|
||||
pytest.param("1.0", id="decimal"),
|
||||
],
|
||||
)
|
||||
def test_invalid_int_values_raise_error(self, mocker, invalid_value):
|
||||
"""Test that invalid integer values raise ValueError."""
|
||||
mocker.patch.dict(os.environ, {"INVALID_INT": invalid_value})
|
||||
with pytest.raises(ValueError):
|
||||
get_int_from_env("INVALID_INT")
|
||||
|
||||
|
||||
class TestGetEnvChoice:
|
||||
@pytest.fixture
|
||||
def valid_choices(self) -> set[str]:
|
||||
"""Fixture providing a set of valid environment choices."""
|
||||
return {"development", "staging", "production"}
|
||||
|
||||
def test_returns_valid_env_value(
|
||||
self,
|
||||
mocker: MockerFixture,
|
||||
valid_choices: set[str],
|
||||
) -> None:
|
||||
"""Test that function returns the environment value when it's valid."""
|
||||
mocker.patch.dict("os.environ", {"TEST_ENV": "development"})
|
||||
|
||||
result = get_choice_from_env("TEST_ENV", valid_choices)
|
||||
|
||||
assert result == "development"
|
||||
|
||||
def test_returns_default_when_env_not_set(
|
||||
self,
|
||||
mocker: MockerFixture,
|
||||
valid_choices: set[str],
|
||||
) -> None:
|
||||
"""Test that function returns default value when env var is not set."""
|
||||
mocker.patch.dict("os.environ", {}, clear=True)
|
||||
|
||||
result = get_choice_from_env("TEST_ENV", valid_choices, default="staging")
|
||||
|
||||
assert result == "staging"
|
||||
|
||||
def test_raises_error_when_env_not_set_and_no_default(
|
||||
self,
|
||||
mocker: MockerFixture,
|
||||
valid_choices: set[str],
|
||||
) -> None:
|
||||
"""Test that function raises ValueError when env var is missing and no default."""
|
||||
mocker.patch.dict("os.environ", {}, clear=True)
|
||||
|
||||
with pytest.raises(ValueError) as exc_info:
|
||||
get_choice_from_env("TEST_ENV", valid_choices)
|
||||
|
||||
assert "Environment variable 'TEST_ENV' is required but not set" in str(
|
||||
exc_info.value,
|
||||
)
|
||||
|
||||
def test_raises_error_when_env_value_invalid(
|
||||
self,
|
||||
mocker: MockerFixture,
|
||||
valid_choices: set[str],
|
||||
) -> None:
|
||||
"""Test that function raises ValueError when env value is not in choices."""
|
||||
mocker.patch.dict("os.environ", {"TEST_ENV": "invalid_value"})
|
||||
|
||||
with pytest.raises(ValueError) as exc_info:
|
||||
get_choice_from_env("TEST_ENV", valid_choices)
|
||||
|
||||
error_msg = str(exc_info.value)
|
||||
assert (
|
||||
"Environment variable 'TEST_ENV' has invalid value 'invalid_value'"
|
||||
in error_msg
|
||||
)
|
||||
assert "Valid choices are:" in error_msg
|
||||
assert "development" in error_msg
|
||||
assert "staging" in error_msg
|
||||
assert "production" in error_msg
|
||||
|
||||
def test_raises_error_when_default_invalid(
|
||||
self,
|
||||
mocker: MockerFixture,
|
||||
valid_choices: set[str],
|
||||
) -> None:
|
||||
"""Test that function raises ValueError when default value is not in choices."""
|
||||
mocker.patch.dict("os.environ", {}, clear=True)
|
||||
|
||||
with pytest.raises(ValueError) as exc_info:
|
||||
get_choice_from_env("TEST_ENV", valid_choices, default="invalid_default")
|
||||
|
||||
error_msg = str(exc_info.value)
|
||||
assert (
|
||||
"Environment variable 'TEST_ENV' has invalid value 'invalid_default'"
|
||||
in error_msg
|
||||
)
|
||||
|
||||
def test_case_sensitive_validation(
|
||||
self,
|
||||
mocker: MockerFixture,
|
||||
valid_choices: set[str],
|
||||
) -> None:
|
||||
"""Test that validation is case sensitive."""
|
||||
mocker.patch.dict("os.environ", {"TEST_ENV": "DEVELOPMENT"})
|
||||
|
||||
with pytest.raises(ValueError):
|
||||
get_choice_from_env("TEST_ENV", valid_choices)
|
||||
|
||||
def test_empty_string_env_value(
|
||||
self,
|
||||
mocker: MockerFixture,
|
||||
valid_choices: set[str],
|
||||
) -> None:
|
||||
"""Test behavior with empty string environment value."""
|
||||
mocker.patch.dict("os.environ", {"TEST_ENV": ""})
|
||||
|
||||
with pytest.raises(ValueError) as exc_info:
|
||||
get_choice_from_env("TEST_ENV", valid_choices)
|
||||
|
||||
assert "has invalid value ''" in str(exc_info.value)
|
||||
|
||||
def test_whitespace_env_value(
|
||||
self,
|
||||
mocker: MockerFixture,
|
||||
valid_choices: set[str],
|
||||
) -> None:
|
||||
"""Test behavior with whitespace-only environment value."""
|
||||
mocker.patch.dict("os.environ", {"TEST_ENV": " development "})
|
||||
|
||||
with pytest.raises(ValueError):
|
||||
get_choice_from_env("TEST_ENV", valid_choices)
|
||||
|
||||
def test_single_choice_set(self, mocker: MockerFixture) -> None:
|
||||
"""Test function works correctly with single choice set."""
|
||||
single_choice: set[str] = {"production"}
|
||||
mocker.patch.dict("os.environ", {"TEST_ENV": "production"})
|
||||
|
||||
result = get_choice_from_env("TEST_ENV", single_choice)
|
||||
|
||||
assert result == "production"
|
||||
|
||||
def test_large_choice_set(self, mocker: MockerFixture) -> None:
|
||||
"""Test function works correctly with large choice set."""
|
||||
large_choices: set[str] = {f"option_{i}" for i in range(100)}
|
||||
mocker.patch.dict("os.environ", {"TEST_ENV": "option_50"})
|
||||
|
||||
result = get_choice_from_env("TEST_ENV", large_choices)
|
||||
|
||||
assert result == "option_50"
|
||||
|
||||
def test_different_env_keys(
|
||||
self,
|
||||
mocker: MockerFixture,
|
||||
valid_choices: set[str],
|
||||
) -> None:
|
||||
"""Test function works with different environment variable keys."""
|
||||
test_cases = [
|
||||
("DJANGO_ENV", "development"),
|
||||
("DATABASE_BACKEND", "staging"),
|
||||
("LOG_LEVEL", "production"),
|
||||
("APP_MODE", "development"),
|
||||
]
|
||||
|
||||
for env_key, env_value in test_cases:
|
||||
mocker.patch.dict("os.environ", {env_key: env_value})
|
||||
result = get_choice_from_env(env_key, valid_choices)
|
||||
assert result == env_value
|
||||
@@ -78,11 +78,15 @@ class TestCustomAccountAdapter(TestCase):
|
||||
adapter = get_adapter()
|
||||
|
||||
# Test when PAPERLESS_URL is None
|
||||
expected_url = f"https://foo.org{reverse('account_reset_password_from_key', kwargs={'uidb36': 'UID', 'key': 'KEY'})}"
|
||||
self.assertEqual(
|
||||
adapter.get_reset_password_from_key_url("UID-KEY"),
|
||||
expected_url,
|
||||
)
|
||||
with override_settings(
|
||||
PAPERLESS_URL=None,
|
||||
ACCOUNT_DEFAULT_HTTP_PROTOCOL="https",
|
||||
):
|
||||
expected_url = f"https://foo.org{reverse('account_reset_password_from_key', kwargs={'uidb36': 'UID', 'key': 'KEY'})}"
|
||||
self.assertEqual(
|
||||
adapter.get_reset_password_from_key_url("UID-KEY"),
|
||||
expected_url,
|
||||
)
|
||||
|
||||
# Test when PAPERLESS_URL is not None
|
||||
with override_settings(PAPERLESS_URL="https://bar.com"):
|
||||
|
||||
@@ -2,13 +2,17 @@ import os
|
||||
from pathlib import Path
|
||||
from unittest import mock
|
||||
|
||||
import pytest
|
||||
from django.core.checks import Warning
|
||||
from django.test import TestCase
|
||||
from django.test import override_settings
|
||||
from pytest_mock import MockerFixture
|
||||
|
||||
from documents.tests.utils import DirectoriesMixin
|
||||
from documents.tests.utils import FileSystemAssertsMixin
|
||||
from paperless.checks import audit_log_check
|
||||
from paperless.checks import binaries_check
|
||||
from paperless.checks import check_deprecated_db_settings
|
||||
from paperless.checks import debug_mode_check
|
||||
from paperless.checks import paths_check
|
||||
from paperless.checks import settings_values_check
|
||||
@@ -237,3 +241,157 @@ class TestAuditLogChecks(TestCase):
|
||||
("auditlog table was found but audit log is disabled."),
|
||||
msg.msg,
|
||||
)
|
||||
|
||||
|
||||
DEPRECATED_VARS: dict[str, str] = {
|
||||
"PAPERLESS_DB_TIMEOUT": "timeout",
|
||||
"PAPERLESS_DB_POOLSIZE": "pool.min_size / pool.max_size",
|
||||
"PAPERLESS_DBSSLMODE": "sslmode",
|
||||
"PAPERLESS_DBSSLROOTCERT": "sslrootcert",
|
||||
"PAPERLESS_DBSSLCERT": "sslcert",
|
||||
"PAPERLESS_DBSSLKEY": "sslkey",
|
||||
}
|
||||
|
||||
|
||||
class TestDeprecatedDbSettings:
|
||||
"""Test suite for the check_deprecated_db_settings system check."""
|
||||
|
||||
def test_no_deprecated_vars_returns_empty(
|
||||
self,
|
||||
mocker: MockerFixture,
|
||||
) -> None:
|
||||
"""No warnings when none of the deprecated vars are present."""
|
||||
# clear=True ensures vars from the outer test environment do not leak in
|
||||
mocker.patch.dict(os.environ, {}, clear=True)
|
||||
result = check_deprecated_db_settings(None)
|
||||
assert result == []
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("env_var", "db_option_key"),
|
||||
[
|
||||
("PAPERLESS_DB_TIMEOUT", "timeout"),
|
||||
("PAPERLESS_DB_POOLSIZE", "pool.min_size / pool.max_size"),
|
||||
("PAPERLESS_DBSSLMODE", "sslmode"),
|
||||
("PAPERLESS_DBSSLROOTCERT", "sslrootcert"),
|
||||
("PAPERLESS_DBSSLCERT", "sslcert"),
|
||||
("PAPERLESS_DBSSLKEY", "sslkey"),
|
||||
],
|
||||
ids=[
|
||||
"db-timeout",
|
||||
"db-poolsize",
|
||||
"ssl-mode",
|
||||
"ssl-rootcert",
|
||||
"ssl-cert",
|
||||
"ssl-key",
|
||||
],
|
||||
)
|
||||
def test_single_deprecated_var_produces_one_warning(
|
||||
self,
|
||||
mocker: MockerFixture,
|
||||
env_var: str,
|
||||
db_option_key: str,
|
||||
) -> None:
|
||||
"""Each deprecated var in isolation produces exactly one warning."""
|
||||
mocker.patch.dict(os.environ, {env_var: "some_value"}, clear=True)
|
||||
result = check_deprecated_db_settings(None)
|
||||
|
||||
assert len(result) == 1
|
||||
warning = result[0]
|
||||
assert isinstance(warning, Warning)
|
||||
assert warning.id == "paperless.W001"
|
||||
assert env_var in warning.hint
|
||||
assert db_option_key in warning.hint
|
||||
|
||||
def test_multiple_deprecated_vars_produce_one_warning_each(
|
||||
self,
|
||||
mocker: MockerFixture,
|
||||
) -> None:
|
||||
"""Each deprecated var present in the environment gets its own warning."""
|
||||
set_vars = {
|
||||
"PAPERLESS_DB_TIMEOUT": "30",
|
||||
"PAPERLESS_DB_POOLSIZE": "10",
|
||||
"PAPERLESS_DBSSLMODE": "require",
|
||||
}
|
||||
mocker.patch.dict(os.environ, set_vars, clear=True)
|
||||
result = check_deprecated_db_settings(None)
|
||||
|
||||
assert len(result) == len(set_vars)
|
||||
assert all(isinstance(w, Warning) for w in result)
|
||||
assert all(w.id == "paperless.W001" for w in result)
|
||||
all_hints = " ".join(w.hint for w in result)
|
||||
for var_name in set_vars:
|
||||
assert var_name in all_hints
|
||||
|
||||
def test_all_deprecated_vars_produces_one_warning_each(
|
||||
self,
|
||||
mocker: MockerFixture,
|
||||
) -> None:
|
||||
"""All deprecated vars set simultaneously produces one warning per var."""
|
||||
all_vars = dict.fromkeys(DEPRECATED_VARS, "some_value")
|
||||
mocker.patch.dict(os.environ, all_vars, clear=True)
|
||||
result = check_deprecated_db_settings(None)
|
||||
|
||||
assert len(result) == len(DEPRECATED_VARS)
|
||||
assert all(isinstance(w, Warning) for w in result)
|
||||
assert all(w.id == "paperless.W001" for w in result)
|
||||
|
||||
def test_unset_vars_not_mentioned_in_warnings(
|
||||
self,
|
||||
mocker: MockerFixture,
|
||||
) -> None:
|
||||
"""Vars absent from the environment do not appear in any warning."""
|
||||
mocker.patch.dict(
|
||||
os.environ,
|
||||
{"PAPERLESS_DB_TIMEOUT": "30"},
|
||||
clear=True,
|
||||
)
|
||||
result = check_deprecated_db_settings(None)
|
||||
|
||||
assert len(result) == 1
|
||||
assert "PAPERLESS_DB_TIMEOUT" in result[0].hint
|
||||
unset_vars = [v for v in DEPRECATED_VARS if v != "PAPERLESS_DB_TIMEOUT"]
|
||||
for var_name in unset_vars:
|
||||
assert var_name not in result[0].hint
|
||||
|
||||
def test_empty_string_var_not_treated_as_set(
|
||||
self,
|
||||
mocker: MockerFixture,
|
||||
) -> None:
|
||||
"""A var set to an empty string is not flagged as a deprecated setting."""
|
||||
mocker.patch.dict(
|
||||
os.environ,
|
||||
{"PAPERLESS_DB_TIMEOUT": ""},
|
||||
clear=True,
|
||||
)
|
||||
result = check_deprecated_db_settings(None)
|
||||
assert result == []
|
||||
|
||||
def test_warning_mentions_migration_target(
|
||||
self,
|
||||
mocker: MockerFixture,
|
||||
) -> None:
|
||||
"""Each warning hints at PAPERLESS_DB_OPTIONS as the migration target."""
|
||||
mocker.patch.dict(
|
||||
os.environ,
|
||||
{"PAPERLESS_DBSSLMODE": "require"},
|
||||
clear=True,
|
||||
)
|
||||
result = check_deprecated_db_settings(None)
|
||||
|
||||
assert len(result) == 1
|
||||
assert "PAPERLESS_DB_OPTIONS" in result[0].hint
|
||||
|
||||
def test_warning_message_identifies_var(
|
||||
self,
|
||||
mocker: MockerFixture,
|
||||
) -> None:
|
||||
"""The warning message (not just the hint) identifies the offending var."""
|
||||
mocker.patch.dict(
|
||||
os.environ,
|
||||
{"PAPERLESS_DBSSLCERT": "/path/to/cert.pem"},
|
||||
clear=True,
|
||||
)
|
||||
result = check_deprecated_db_settings(None)
|
||||
|
||||
assert len(result) == 1
|
||||
assert "PAPERLESS_DBSSLCERT" in result[0].msg
|
||||
|
||||
@@ -9,7 +9,6 @@ from celery.schedules import crontab
|
||||
from paperless.settings import _parse_base_paths
|
||||
from paperless.settings import _parse_beat_schedule
|
||||
from paperless.settings import _parse_dateparser_languages
|
||||
from paperless.settings import _parse_db_settings
|
||||
from paperless.settings import _parse_ignore_dates
|
||||
from paperless.settings import _parse_paperless_url
|
||||
from paperless.settings import _parse_redis_url
|
||||
@@ -378,64 +377,6 @@ class TestCeleryScheduleParsing(TestCase):
|
||||
)
|
||||
|
||||
|
||||
class TestDBSettings(TestCase):
|
||||
def test_db_timeout_with_sqlite(self) -> None:
|
||||
"""
|
||||
GIVEN:
|
||||
- PAPERLESS_DB_TIMEOUT is set
|
||||
WHEN:
|
||||
- Settings are parsed
|
||||
THEN:
|
||||
- PAPERLESS_DB_TIMEOUT set for sqlite
|
||||
"""
|
||||
with mock.patch.dict(
|
||||
os.environ,
|
||||
{
|
||||
"PAPERLESS_DB_TIMEOUT": "10",
|
||||
},
|
||||
):
|
||||
databases = _parse_db_settings()
|
||||
|
||||
self.assertDictEqual(
|
||||
{
|
||||
"timeout": 10.0,
|
||||
},
|
||||
databases["default"]["OPTIONS"],
|
||||
)
|
||||
|
||||
def test_db_timeout_with_not_sqlite(self) -> None:
|
||||
"""
|
||||
GIVEN:
|
||||
- PAPERLESS_DB_TIMEOUT is set but db is not sqlite
|
||||
WHEN:
|
||||
- Settings are parsed
|
||||
THEN:
|
||||
- PAPERLESS_DB_TIMEOUT set correctly in non-sqlite db & for fallback sqlite db
|
||||
"""
|
||||
with mock.patch.dict(
|
||||
os.environ,
|
||||
{
|
||||
"PAPERLESS_DBHOST": "127.0.0.1",
|
||||
"PAPERLESS_DB_TIMEOUT": "10",
|
||||
},
|
||||
):
|
||||
databases = _parse_db_settings()
|
||||
|
||||
self.assertDictEqual(
|
||||
databases["default"]["OPTIONS"],
|
||||
databases["default"]["OPTIONS"]
|
||||
| {
|
||||
"connect_timeout": 10.0,
|
||||
},
|
||||
)
|
||||
self.assertDictEqual(
|
||||
{
|
||||
"timeout": 10.0,
|
||||
},
|
||||
databases["sqlite"]["OPTIONS"],
|
||||
)
|
||||
|
||||
|
||||
class TestPaperlessURLSettings(TestCase):
|
||||
def test_paperless_url(self) -> None:
|
||||
"""
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
from django.conf import settings
|
||||
from django.test import override_settings
|
||||
|
||||
|
||||
def test_favicon_view(client):
|
||||
@@ -11,15 +11,14 @@ def test_favicon_view(client):
|
||||
favicon_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
favicon_path.write_bytes(b"FAKE ICON DATA")
|
||||
|
||||
settings.STATIC_ROOT = static_dir
|
||||
|
||||
response = client.get("/favicon.ico")
|
||||
assert response.status_code == 200
|
||||
assert response["Content-Type"] == "image/x-icon"
|
||||
assert b"".join(response.streaming_content) == b"FAKE ICON DATA"
|
||||
with override_settings(STATIC_ROOT=static_dir):
|
||||
response = client.get("/favicon.ico")
|
||||
assert response.status_code == 200
|
||||
assert response["Content-Type"] == "image/x-icon"
|
||||
assert b"".join(response.streaming_content) == b"FAKE ICON DATA"
|
||||
|
||||
|
||||
def test_favicon_view_missing_file(client):
|
||||
settings.STATIC_ROOT = Path(tempfile.mkdtemp())
|
||||
response = client.get("/favicon.ico")
|
||||
assert response.status_code == 404
|
||||
with override_settings(STATIC_ROOT=Path(tempfile.mkdtemp())):
|
||||
response = client.get("/favicon.ico")
|
||||
assert response.status_code == 404
|
||||
|
||||
@@ -48,20 +48,6 @@ class TestWebSockets(TestCase):
|
||||
mock_close.assert_called_once()
|
||||
mock_close.reset_mock()
|
||||
|
||||
message = {
|
||||
"type": "document_updated",
|
||||
"data": {"document_id": 10, "modified": "2026-02-17T00:00:00Z"},
|
||||
}
|
||||
|
||||
await channel_layer.group_send(
|
||||
"status_updates",
|
||||
message,
|
||||
)
|
||||
await communicator.receive_nothing()
|
||||
|
||||
mock_close.assert_called_once()
|
||||
mock_close.reset_mock()
|
||||
|
||||
message = {"type": "documents_deleted", "data": {"documents": [1, 2, 3]}}
|
||||
|
||||
await channel_layer.group_send(
|
||||
@@ -172,40 +158,6 @@ class TestWebSockets(TestCase):
|
||||
|
||||
await communicator.disconnect()
|
||||
|
||||
@mock.patch("paperless.consumers.StatusConsumer._can_view")
|
||||
@mock.patch("paperless.consumers.StatusConsumer._authenticated")
|
||||
async def test_receive_document_updated(self, _authenticated, _can_view) -> None:
|
||||
_authenticated.return_value = True
|
||||
_can_view.return_value = True
|
||||
|
||||
communicator = WebsocketCommunicator(application, "/ws/status/")
|
||||
connected, _ = await communicator.connect()
|
||||
self.assertTrue(connected)
|
||||
|
||||
message = {
|
||||
"type": "document_updated",
|
||||
"data": {
|
||||
"document_id": 10,
|
||||
"modified": "2026-02-17T00:00:00Z",
|
||||
"owner_id": 1,
|
||||
"users_can_view": [1],
|
||||
"groups_can_view": [],
|
||||
},
|
||||
}
|
||||
|
||||
channel_layer = get_channel_layer()
|
||||
assert channel_layer is not None
|
||||
await channel_layer.group_send(
|
||||
"status_updates",
|
||||
message,
|
||||
)
|
||||
|
||||
response = await communicator.receive_json_from()
|
||||
|
||||
self.assertEqual(response, message)
|
||||
|
||||
await communicator.disconnect()
|
||||
|
||||
@mock.patch("channels.layers.InMemoryChannelLayer.group_send")
|
||||
def test_manager_send_progress(self, mock_group_send) -> None:
|
||||
with ProgressManager(task_id="test") as manager:
|
||||
@@ -238,10 +190,7 @@ class TestWebSockets(TestCase):
|
||||
)
|
||||
|
||||
@mock.patch("channels.layers.InMemoryChannelLayer.group_send")
|
||||
def test_manager_send_documents_deleted(
|
||||
self,
|
||||
mock_group_send: mock.MagicMock,
|
||||
) -> None:
|
||||
def test_manager_send_documents_deleted(self, mock_group_send) -> None:
|
||||
with DocumentsStatusManager() as manager:
|
||||
manager.send_documents_deleted([1, 2, 3])
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
from typing import Final
|
||||
|
||||
__version__: Final[tuple[int, int, int]] = (2, 20, 8)
|
||||
__version__: Final[tuple[int, int, int]] = (2, 20, 9)
|
||||
# Version string like X.Y.Z
|
||||
__full_version_str__: Final[str] = ".".join(map(str, __version__))
|
||||
# Version string like X.Y
|
||||
|
||||
@@ -5,6 +5,7 @@ from pathlib import Path
|
||||
from bleach import clean
|
||||
from bleach import linkify
|
||||
from django.conf import settings
|
||||
from django.utils import timezone
|
||||
from django.utils.timezone import is_naive
|
||||
from django.utils.timezone import make_aware
|
||||
from gotenberg_client import GotenbergClient
|
||||
@@ -332,7 +333,9 @@ class MailDocumentParser(DocumentParser):
|
||||
if data["attachments"]:
|
||||
data["attachments_label"] = "Attachments"
|
||||
|
||||
data["date"] = clean_html(mail.date.astimezone().strftime("%Y-%m-%d %H:%M"))
|
||||
data["date"] = clean_html(
|
||||
timezone.localtime(mail.date).strftime("%Y-%m-%d %H:%M"),
|
||||
)
|
||||
data["content"] = clean_html(mail.text.strip())
|
||||
|
||||
from django.template.loader import render_to_string
|
||||
|
||||
@@ -6,6 +6,7 @@ from unittest import mock
|
||||
import httpx
|
||||
import pytest
|
||||
from django.test.html import parse_html
|
||||
from django.utils import timezone
|
||||
from pytest_django.fixtures import SettingsWrapper
|
||||
from pytest_httpx import HTTPXMock
|
||||
from pytest_mock import MockerFixture
|
||||
@@ -634,13 +635,14 @@ class TestParser:
|
||||
THEN:
|
||||
- Resulting HTML is as expected
|
||||
"""
|
||||
mail = mail_parser.parse_file_to_message(html_email_file)
|
||||
html_file = mail_parser.mail_to_html(mail)
|
||||
with timezone.override("UTC"):
|
||||
mail = mail_parser.parse_file_to_message(html_email_file)
|
||||
html_file = mail_parser.mail_to_html(mail)
|
||||
|
||||
expected_html = parse_html(html_email_html_file.read_text())
|
||||
actual_html = parse_html(html_file.read_text())
|
||||
expected_html = parse_html(html_email_html_file.read_text())
|
||||
actual_html = parse_html(html_file.read_text())
|
||||
|
||||
assert expected_html == actual_html
|
||||
assert expected_html == actual_html
|
||||
|
||||
def test_generate_pdf_from_mail(
|
||||
self,
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import shutil
|
||||
import tempfile
|
||||
import unicodedata
|
||||
import uuid
|
||||
from pathlib import Path
|
||||
from unittest import mock
|
||||
@@ -847,8 +848,18 @@ class TestParser(DirectoriesMixin, FileSystemAssertsMixin, TestCase):
|
||||
"application/pdf",
|
||||
)
|
||||
|
||||
# Copied from the PDF to here. Don't even look at it
|
||||
self.assertIn("ةﯾﻠﺧﺎدﻻ ةرازو", parser.get_text())
|
||||
# OCR output for RTL text varies across platforms/versions due to
|
||||
# bidi controls and presentation forms; normalize before assertion.
|
||||
normalized_text = "".join(
|
||||
char
|
||||
for char in unicodedata.normalize("NFKC", parser.get_text())
|
||||
if unicodedata.category(char) != "Cf" and not char.isspace()
|
||||
)
|
||||
|
||||
self.assertIn("ةرازو", normalized_text)
|
||||
self.assertTrue(
|
||||
any(token in normalized_text for token in ("ةیلخادلا", "الاخليد")),
|
||||
)
|
||||
|
||||
@mock.patch("ocrmypdf.ocr")
|
||||
def test_gs_rendering_error(self, m) -> None:
|
||||
|
||||
2
uv.lock
generated
2
uv.lock
generated
@@ -3033,7 +3033,7 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "paperless-ngx"
|
||||
version = "2.20.8"
|
||||
version = "2.20.9"
|
||||
source = { virtual = "." }
|
||||
dependencies = [
|
||||
{ name = "azure-ai-documentintelligence", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" },
|
||||
|
||||
@@ -18,7 +18,10 @@ nav = [
|
||||
"setup.md",
|
||||
"usage.md",
|
||||
"configuration.md",
|
||||
"administration.md",
|
||||
{ Administration = [
|
||||
"administration.md",
|
||||
{ "v3 Migration Guide" = "migration-v3.md" },
|
||||
] },
|
||||
"advanced_usage.md",
|
||||
"api.md",
|
||||
"development.md",
|
||||
|
||||
Reference in New Issue
Block a user