mirror of
https://github.com/paperless-ngx/paperless-ngx.git
synced 2026-10-29 11:05:42 +00:00
Compare commits
57
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e5561ba06f | ||
|
|
1692c916f8 | ||
|
|
ffd886eae0 | ||
|
|
12c0dc635e | ||
|
|
1e01ce42c0 | ||
|
|
e46f4a5aaa | ||
|
|
1ba6c31385 | ||
|
|
f3ee820fa4 | ||
|
|
2f5bcdf66e | ||
|
|
501cdd92d2 | ||
|
|
66c5c46913 | ||
|
|
782634d912 | ||
|
|
3cfe9fa2a8 | ||
|
|
784fed447f | ||
|
|
3292a0e7cc | ||
|
|
9646b8c67d | ||
|
|
e590d7df69 | ||
|
|
cc71aad058 | ||
|
|
3cbdf5d0b7 | ||
|
|
f84e0097e5 | ||
|
|
7dbf8bdd4a | ||
|
|
d2a752a196 | ||
|
|
2cb155e717 | ||
|
|
9e9fc6213c | ||
|
|
0f7c02de5e | ||
|
|
95dea787f2 | ||
|
|
b6501b0c47 | ||
|
|
87ebd13abc | ||
|
|
a86c9d32fe | ||
|
|
7942edfdf4 | ||
|
|
1e00ad5f30 | ||
|
|
5f26c01c6f | ||
|
|
92e133eeb0 | ||
|
|
06b2d5102c | ||
|
|
40255cfdbb | ||
|
|
d919c341b1 | ||
|
|
ba0a80a8ad | ||
|
|
60319c6d37 | ||
|
|
1e8622494d | ||
|
|
d6a316b1df | ||
|
|
8f311c4b6b | ||
|
|
f25322600d | ||
|
|
615f27e6fb | ||
|
|
5b809122b5 | ||
|
|
8b8307571a | ||
|
|
0e97419e0e | ||
|
|
7ff51452f0 | ||
|
|
a6c974589f | ||
|
|
47f9f642a9 | ||
|
|
8bfebc3b9b | ||
|
|
c7f83212a3 | ||
|
|
b010f65ae7 | ||
|
|
89d3a53603 | ||
|
|
9601b3d597 | ||
|
|
13e07844fe | ||
|
|
be82fcb70a | ||
|
|
98298e37cd |
No files matched your search
@@ -2,6 +2,17 @@
|
|||||||
# shellcheck shell=bash
|
# shellcheck shell=bash
|
||||||
declare -r log_prefix="[init-user]"
|
declare -r log_prefix="[init-user]"
|
||||||
|
|
||||||
|
# When the container is started as a non-root user (e.g. via `user: 999:999`
|
||||||
|
# in Docker Compose), usermod/groupmod require root and are meaningless.
|
||||||
|
# USERMAP_* variables only apply to the root-started path.
|
||||||
|
if [[ -n "${USER_IS_NON_ROOT}" ]]; then
|
||||||
|
if [[ -n "${USERMAP_UID}" || -n "${USERMAP_GID}" ]]; then
|
||||||
|
echo "${log_prefix} WARNING: USERMAP_UID/USERMAP_GID are set but have no effect when the container is started as a non-root user"
|
||||||
|
fi
|
||||||
|
echo "${log_prefix} Running as non-root user ($(id --user):$(id --group)), skipping UID/GID remapping"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
declare -r usermap_original_uid=$(id -u paperless)
|
declare -r usermap_original_uid=$(id -u paperless)
|
||||||
declare -r usermap_original_gid=$(id -g paperless)
|
declare -r usermap_original_gid=$(id -g paperless)
|
||||||
declare -r usermap_new_uid=${USERMAP_UID:-$usermap_original_uid}
|
declare -r usermap_new_uid=${USERMAP_UID:-$usermap_original_uid}
|
||||||
|
|||||||
@@ -761,7 +761,7 @@ MariaDB: `mariadb-tzinfo-to-sql /usr/share/zoneinfo | mariadb -u root mysql -p`
|
|||||||
|
|
||||||
## Barcodes {#barcodes}
|
## Barcodes {#barcodes}
|
||||||
|
|
||||||
Paperless is able to utilize barcodes for automatically performing some tasks.
|
Paperless is able to utilize barcodes for automatically performing some tasks. Barcodes are only supported for PDF documents or TIFF, [if enabled](configuration.md#PAPERLESS_CONSUMER_BARCODE_TIFF_SUPPORT).
|
||||||
|
|
||||||
At this time, the library utilized for detection of barcodes supports the following types:
|
At this time, the library utilized for detection of barcodes supports the following types:
|
||||||
|
|
||||||
|
|||||||
Binary file not shown.
|
After Width: | Height: | Size: 57 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 61 KiB |
@@ -1,7 +1,141 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
## paperless-ngx 2.20.14
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
- Fix: do not submit permissions for non-owners [@shamoon](https://github.com/shamoon) ([#12571](https://github.com/paperless-ngx/paperless-ngx/pull/12571))
|
||||||
|
- Fix: prevent duplicate parent tag IDs [@shamoon](https://github.com/shamoon) ([#12522](https://github.com/paperless-ngx/paperless-ngx/pull/12522))
|
||||||
|
- Fix: dont defer tag change application in workflows [@shamoon](https://github.com/shamoon) ([#12478](https://github.com/paperless-ngx/paperless-ngx/pull/12478))
|
||||||
|
- Fix: limit share link viewset actions [@shamoon](https://github.com/shamoon) ([#12461](https://github.com/paperless-ngx/paperless-ngx/pull/12461))
|
||||||
|
- Fix: add fallback ordering for documents by id after created [@shamoon](https://github.com/shamoon) ([#12440](https://github.com/paperless-ngx/paperless-ngx/pull/12440))
|
||||||
|
- Fixhancement: default mail-created correspondent matching to exact [@shamoon](https://github.com/shamoon) ([#12414](https://github.com/paperless-ngx/paperless-ngx/pull/12414))
|
||||||
|
- Fix: validate date CF value in serializer [@shamoon](https://github.com/shamoon) ([#12410](https://github.com/paperless-ngx/paperless-ngx/pull/12410))
|
||||||
|
|
||||||
|
### All App Changes
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary>7 changes</summary>
|
||||||
|
|
||||||
|
- Fix: do not submit permissions for non-owners [@shamoon](https://github.com/shamoon) ([#12571](https://github.com/paperless-ngx/paperless-ngx/pull/12571))
|
||||||
|
- Fix: prevent duplicate parent tag IDs [@shamoon](https://github.com/shamoon) ([#12522](https://github.com/paperless-ngx/paperless-ngx/pull/12522))
|
||||||
|
- Fix: dont defer tag change application in workflows [@shamoon](https://github.com/shamoon) ([#12478](https://github.com/paperless-ngx/paperless-ngx/pull/12478))
|
||||||
|
- Fix: limit share link viewset actions [@shamoon](https://github.com/shamoon) ([#12461](https://github.com/paperless-ngx/paperless-ngx/pull/12461))
|
||||||
|
- Fix: add fallback ordering for documents by id after created [@shamoon](https://github.com/shamoon) ([#12440](https://github.com/paperless-ngx/paperless-ngx/pull/12440))
|
||||||
|
- Fixhancement: default mail-created correspondent matching to exact [@shamoon](https://github.com/shamoon) ([#12414](https://github.com/paperless-ngx/paperless-ngx/pull/12414))
|
||||||
|
- Fix: validate date CF value in serializer [@shamoon](https://github.com/shamoon) ([#12410](https://github.com/paperless-ngx/paperless-ngx/pull/12410))
|
||||||
|
</details>
|
||||||
|
|
||||||
|
## paperless-ngx 2.20.13
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
- Fix: suggest corrections only if visible results
|
||||||
|
- Fix: require view permission for more-like search
|
||||||
|
- Fix: validate document link targets
|
||||||
|
- Fix: enforce permissions when attaching accounts to mail rules
|
||||||
|
|
||||||
|
## paperless-ngx 2.20.12
|
||||||
|
|
||||||
|
### Security
|
||||||
|
|
||||||
|
- Resolve [GHSA-96jx-fj7m-qh6x](https://github.com/paperless-ngx/paperless-ngx/security/advisories/GHSA-96jx-fj7m-qh6x)
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
- Fix: Scope the workflow saves to prevent clobbering filename/archive_filename [@stumpylog](https://github.com/stumpylog) ([#12390](https://github.com/paperless-ngx/paperless-ngx/pull/12390))
|
||||||
|
- Fix: don't try to usermod/groupmod when non-root + update docs (#<!---->12365) [@stumpylog](https://github.com/stumpylog) ([#12391](https://github.com/paperless-ngx/paperless-ngx/pull/12391))
|
||||||
|
- Fix: avoid moving files if already moved [@shamoon](https://github.com/shamoon) ([#12389](https://github.com/paperless-ngx/paperless-ngx/pull/12389))
|
||||||
|
- Fix: remove pagination from document notes api spec [@shamoon](https://github.com/shamoon) ([#12388](https://github.com/paperless-ngx/paperless-ngx/pull/12388))
|
||||||
|
- Fix: fix file button hover color in dark mode [@shamoon](https://github.com/shamoon) ([#12367](https://github.com/paperless-ngx/paperless-ngx/pull/12367))
|
||||||
|
- Fixhancement: only offer basic auth for appropriate requests [@shamoon](https://github.com/shamoon) ([#12362](https://github.com/paperless-ngx/paperless-ngx/pull/12362))
|
||||||
|
|
||||||
|
### All App Changes
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary>5 changes</summary>
|
||||||
|
|
||||||
|
- Fix: Scope the workflow saves to prevent clobbering filename/archive_filename [@stumpylog](https://github.com/stumpylog) ([#12390](https://github.com/paperless-ngx/paperless-ngx/pull/12390))
|
||||||
|
- Fix: avoid moving files if already moved [@shamoon](https://github.com/shamoon) ([#12389](https://github.com/paperless-ngx/paperless-ngx/pull/12389))
|
||||||
|
- Fix: remove pagination from document notes api spec [@shamoon](https://github.com/shamoon) ([#12388](https://github.com/paperless-ngx/paperless-ngx/pull/12388))
|
||||||
|
- Fix: fix file button hover color in dark mode [@shamoon](https://github.com/shamoon) ([#12367](https://github.com/paperless-ngx/paperless-ngx/pull/12367))
|
||||||
|
- Fixhancement: only offer basic auth for appropriate requests [@shamoon](https://github.com/shamoon) ([#12362](https://github.com/paperless-ngx/paperless-ngx/pull/12362))
|
||||||
|
</details>
|
||||||
|
|
||||||
|
## paperless-ngx 2.20.11
|
||||||
|
|
||||||
|
### Security
|
||||||
|
|
||||||
|
- Resolve [GHSA-59xh-5vwx-4c4q](https://github.com/paperless-ngx/paperless-ngx/security/advisories/GHSA-59xh-5vwx-4c4q)
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
- Fix: correct dropdown list active color in dark mode [@shamoon](https://github.com/shamoon) ([#12328](https://github.com/paperless-ngx/paperless-ngx/pull/12328))
|
||||||
|
- Fixhancement: clear descendant selections in dropdown when parent toggled [@shamoon](https://github.com/shamoon) ([#12326](https://github.com/paperless-ngx/paperless-ngx/pull/12326))
|
||||||
|
- Fix: prevent wrapping with larger amounts of tags on small cards, reset moreTags setting to correct count [@shamoon](https://github.com/shamoon) ([#12302](https://github.com/paperless-ngx/paperless-ngx/pull/12302))
|
||||||
|
- Fix: prevent stale db filename during workflow actions [@shamoon](https://github.com/shamoon) ([#12289](https://github.com/paperless-ngx/paperless-ngx/pull/12289))
|
||||||
|
|
||||||
|
### All App Changes
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary>4 changes</summary>
|
||||||
|
|
||||||
|
- Fix: correct dropdown list active color in dark mode [@shamoon](https://github.com/shamoon) ([#12328](https://github.com/paperless-ngx/paperless-ngx/pull/12328))
|
||||||
|
- Fixhancement: clear descendant selections in dropdown when parent toggled [@shamoon](https://github.com/shamoon) ([#12326](https://github.com/paperless-ngx/paperless-ngx/pull/12326))
|
||||||
|
- Fix: prevent wrapping with larger amounts of tags on small cards, reset moreTags setting to correct count [@shamoon](https://github.com/shamoon) ([#12302](https://github.com/paperless-ngx/paperless-ngx/pull/12302))
|
||||||
|
- Fix: prevent stale db filename during workflow actions [@shamoon](https://github.com/shamoon) ([#12289](https://github.com/paperless-ngx/paperless-ngx/pull/12289))
|
||||||
|
</details>
|
||||||
|
|
||||||
|
## paperless-ngx 2.20.10
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
- Fix: support string coercion in filepath jinja templates [@shamoon](https://github.com/shamoon) ([#12244](https://github.com/paperless-ngx/paperless-ngx/pull/12244))
|
||||||
|
- Fix: apply ordering after annotating tag document count [@shamoon](https://github.com/shamoon) ([#12238](https://github.com/paperless-ngx/paperless-ngx/pull/12238))
|
||||||
|
- Fix: enforce path limit for db filename fields [@shamoon](https://github.com/shamoon) ([#12235](https://github.com/paperless-ngx/paperless-ngx/pull/12235))
|
||||||
|
|
||||||
|
### All App Changes
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary>3 changes</summary>
|
||||||
|
|
||||||
|
- Fix: support string coercion in filepath jinja templates [@shamoon](https://github.com/shamoon) ([#12244](https://github.com/paperless-ngx/paperless-ngx/pull/12244))
|
||||||
|
- Fix: apply ordering after annotating tag document count [@shamoon](https://github.com/shamoon) ([#12238](https://github.com/paperless-ngx/paperless-ngx/pull/12238))
|
||||||
|
- Fix: enforce path limit for db filename fields [@shamoon](https://github.com/shamoon) ([#12235](https://github.com/paperless-ngx/paperless-ngx/pull/12235))
|
||||||
|
</details>
|
||||||
|
|
||||||
|
## 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
|
## 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
|
### 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))
|
- 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))
|
||||||
@@ -20,6 +154,10 @@
|
|||||||
|
|
||||||
## paperless-ngx 2.20.6
|
## 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
|
### Bug Fixes
|
||||||
|
|
||||||
- Fix: extract all ids for nested tags [@shamoon](https://github.com/shamoon) ([#11888](https://github.com/paperless-ngx/paperless-ngx/pull/11888))
|
- Fix: extract all ids for nested tags [@shamoon](https://github.com/shamoon) ([#11888](https://github.com/paperless-ngx/paperless-ngx/pull/11888))
|
||||||
|
|||||||
+1
-1
@@ -358,7 +358,7 @@ If you want to build the documentation locally, this is how you do it:
|
|||||||
$ uv run zensical serve
|
$ uv run zensical serve
|
||||||
```
|
```
|
||||||
|
|
||||||
## Building the Docker image
|
## Building the Docker image {#docker_build}
|
||||||
|
|
||||||
The docker image is primarily built by the GitHub actions workflow, but
|
The docker image is primarily built by the GitHub actions workflow, but
|
||||||
it can be faster when developing to build and tag an image locally.
|
it can be faster when developing to build and tag an image locally.
|
||||||
|
|||||||
+248
-309
@@ -4,53 +4,74 @@ title: Setup
|
|||||||
|
|
||||||
# Installation
|
# Installation
|
||||||
|
|
||||||
You can go multiple routes to setup and run Paperless:
|
!!! tip "Quick Start"
|
||||||
|
|
||||||
- [Use the script to setup a Docker install](#docker_script)
|
|
||||||
- [Use the Docker compose templates](#docker)
|
|
||||||
- [Build the Docker image yourself](#docker_build)
|
|
||||||
- [Install Paperless-ngx directly on your system manually ("bare metal")](#bare_metal)
|
|
||||||
- A user-maintained list of commercial hosting providers can be found [in the wiki](https://github.com/paperless-ngx/paperless-ngx/wiki/Related-Projects)
|
|
||||||
|
|
||||||
The Docker routes are quick & easy. These are the recommended routes.
|
|
||||||
This configures all the stuff from the above automatically so that it
|
|
||||||
just works and uses sensible defaults for all configuration options.
|
|
||||||
Here you find a cheat-sheet for docker beginners: [CLI
|
|
||||||
Basics](https://www.sehn.tech/refs/devops-with-docker/)
|
|
||||||
|
|
||||||
The bare metal route is complicated to setup but makes it easier should
|
|
||||||
you want to contribute some code back. You need to configure and run the
|
|
||||||
above mentioned components yourself.
|
|
||||||
|
|
||||||
### Use the Installation Script {#docker_script}
|
|
||||||
|
|
||||||
Paperless provides an interactive installation script to setup a Docker Compose
|
|
||||||
installation. The script asks for a couple configuration options, and will then create the
|
|
||||||
necessary configuration files, pull the docker image, start Paperless-ngx and create your superuser
|
|
||||||
account. The script essentially automatically performs the steps described in [Docker setup](#docker).
|
|
||||||
|
|
||||||
1. Make sure that Docker and Docker Compose are [installed](https://docs.docker.com/engine/install/){:target="\_blank"}.
|
|
||||||
|
|
||||||
2. Download and run the installation script:
|
|
||||||
|
|
||||||
|
If you just want Paperless-ngx running quickly, use our installation script:
|
||||||
```shell-session
|
```shell-session
|
||||||
bash -c "$(curl --location --silent --show-error https://raw.githubusercontent.com/paperless-ngx/paperless-ngx/main/install-paperless-ngx.sh)"
|
bash -c "$(curl --location --silent --show-error https://raw.githubusercontent.com/paperless-ngx/paperless-ngx/main/install-paperless-ngx.sh)"
|
||||||
```
|
```
|
||||||
|
_If piping into a shell directly from the internet makes you nervous, inspect [the script](https://github.com/paperless-ngx/paperless-ngx/blob/main/install-paperless-ngx.sh) first!_
|
||||||
|
|
||||||
!!! note
|
## Overview
|
||||||
|
|
||||||
macOS users will need to install [gnu-sed](https://formulae.brew.sh/formula/gnu-sed) with support
|
Choose the installation route that best fits your setup:
|
||||||
for running as `sed` as well as [wget](https://formulae.brew.sh/formula/wget).
|
|
||||||
|
|
||||||
### Use Docker Compose {#docker}
|
| Route | Best for | Effort |
|
||||||
|
| ----------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------- | ------ |
|
||||||
|
| [Installation script](#docker_script) | Fastest first-time setup with guided prompts (recommended for most users) | Low |
|
||||||
|
| [Docker Compose templates](#docker) | Manual control over compose files and settings | Medium |
|
||||||
|
| [Bare metal](#bare_metal) | Advanced setups, packaging, and development-adjacent workflows | High |
|
||||||
|
| [Hosted providers (wiki)](https://github.com/paperless-ngx/paperless-ngx/wiki/Related-Projects#hosting-providers) | Managed hosting options maintained by the community — check details carefully | Varies |
|
||||||
|
|
||||||
1. Make sure that Docker and Docker Compose are [installed](https://docs.docker.com/engine/install/){:target="\_blank"}.
|
For most users, Docker is the best option. It is faster to set up,
|
||||||
|
easier to maintain, and ships with sensible defaults.
|
||||||
|
|
||||||
2. Go to the [/docker/compose directory on the project
|
The bare-metal route gives you more control, but it requires manual
|
||||||
|
installation and operation of all components. It is usually best suited
|
||||||
|
for advanced users and contributors.
|
||||||
|
|
||||||
|
!!! info
|
||||||
|
|
||||||
|
Because [superuser](usage.md#superusers) accounts have full access to all objects and documents, you may want to create a separate user account for daily use,
|
||||||
|
or "downgrade" your superuser account to a normal user account after setup.
|
||||||
|
|
||||||
|
## Installation Script {#docker_script}
|
||||||
|
|
||||||
|
Paperless-ngx provides an interactive script for Docker Compose setups.
|
||||||
|
It asks a few configuration questions, then creates the required files,
|
||||||
|
pulls the image, starts the containers, and creates your [superuser](usage.md#superusers)
|
||||||
|
account. In short, it automates the [Docker Compose setup](#docker) described below.
|
||||||
|
|
||||||
|
#### Prerequisites
|
||||||
|
|
||||||
|
- Docker and Docker Compose must be [installed](https://docs.docker.com/engine/install/){:target="\_blank"}.
|
||||||
|
- macOS users will need [GNU sed](https://formulae.brew.sh/formula/gnu-sed) with support for running as `sed` as well as [wget](https://formulae.brew.sh/formula/wget).
|
||||||
|
|
||||||
|
#### Run the installation script
|
||||||
|
|
||||||
|
```shell-session
|
||||||
|
bash -c "$(curl --location --silent --show-error https://raw.githubusercontent.com/paperless-ngx/paperless-ngx/main/install-paperless-ngx.sh)"
|
||||||
|
```
|
||||||
|
|
||||||
|
#### After installation
|
||||||
|
|
||||||
|
Paperless-ngx should be available at `http://127.0.0.1:8000` (or similar,
|
||||||
|
depending on your configuration) and you will be able to login with the
|
||||||
|
credentials you provided during the installation script.
|
||||||
|
|
||||||
|
## Docker Compose Install {#docker}
|
||||||
|
|
||||||
|
#### Prerequisites
|
||||||
|
|
||||||
|
- Docker and Docker Compose must be [installed](https://docs.docker.com/engine/install/){:target="\_blank"}.
|
||||||
|
|
||||||
|
#### Installation
|
||||||
|
|
||||||
|
1. Go to the [/docker/compose directory on the project
|
||||||
page](https://github.com/paperless-ngx/paperless-ngx/tree/main/docker/compose){:target="\_blank"}
|
page](https://github.com/paperless-ngx/paperless-ngx/tree/main/docker/compose){:target="\_blank"}
|
||||||
and download one of the `docker-compose.*.yml` files, depending on which database backend
|
and download one `docker-compose.*.yml` file for your preferred
|
||||||
you want to use. Place the files in a local directory and rename it `docker-compose.yml`. Download the
|
database backend. Save it in a local directory as `docker-compose.yml`.
|
||||||
`docker-compose.env` file and the `.env` file as well in the same directory.
|
Also download `docker-compose.env` and `.env` into that same directory.
|
||||||
|
|
||||||
If you want to enable optional support for Office and other documents, download a
|
If you want to enable optional support for Office and other documents, download a
|
||||||
file with `-tika` in the file name.
|
file with `-tika` in the file name.
|
||||||
@@ -60,15 +81,16 @@ account. The script essentially automatically performs the steps described in [D
|
|||||||
For new installations, it is recommended to use PostgreSQL as the
|
For new installations, it is recommended to use PostgreSQL as the
|
||||||
database backend.
|
database backend.
|
||||||
|
|
||||||
3. Modify `docker-compose.yml` as needed. For example, you may want to change the paths to the
|
2. Modify `docker-compose.yml` as needed. For example, you may want to
|
||||||
consumption, media etc. directories to use 'bind mounts'.
|
change the paths for `consume`, `media`, and other directories to
|
||||||
|
use bind mounts.
|
||||||
Find the line that specifies where to mount the directory, e.g.:
|
Find the line that specifies where to mount the directory, e.g.:
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
- ./consume:/usr/src/paperless/consume
|
- ./consume:/usr/src/paperless/consume
|
||||||
```
|
```
|
||||||
|
|
||||||
Replace the part _before_ the colon with a local directory of your choice:
|
Replace the part _before_ the colon with your local directory:
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
- /home/jonaswinkler/paperless-inbox:/usr/src/paperless/consume
|
- /home/jonaswinkler/paperless-inbox:/usr/src/paperless/consume
|
||||||
@@ -82,38 +104,15 @@ account. The script essentially automatically performs the steps described in [D
|
|||||||
- 8010:8000
|
- 8010:8000
|
||||||
```
|
```
|
||||||
|
|
||||||
**Rootless**
|
3. Modify `docker-compose.env` with any configuration options you need.
|
||||||
|
|
||||||
!!! warning
|
|
||||||
|
|
||||||
It is currently not possible to run the container rootless if additional languages are specified via `PAPERLESS_OCR_LANGUAGES`.
|
|
||||||
|
|
||||||
If you want to run Paperless as a rootless container, you will need
|
|
||||||
to do the following in your `docker-compose.yml`:
|
|
||||||
|
|
||||||
- set the `user` running the container to map to the `paperless`
|
|
||||||
user in the container. This value (`user_id` below), should be
|
|
||||||
the same id that `USERMAP_UID` and `USERMAP_GID` are set to in
|
|
||||||
the next step. See `USERMAP_UID` and `USERMAP_GID`
|
|
||||||
[here](configuration.md#docker).
|
|
||||||
|
|
||||||
Your entry for Paperless should contain something like:
|
|
||||||
|
|
||||||
> ```
|
|
||||||
> webserver:
|
|
||||||
> image: ghcr.io/paperless-ngx/paperless-ngx:latest
|
|
||||||
> user: <user_id>
|
|
||||||
> ```
|
|
||||||
|
|
||||||
4. Modify `docker-compose.env` with any configuration options you'd like.
|
|
||||||
See the [configuration documentation](configuration.md) for all options.
|
See the [configuration documentation](configuration.md) for all options.
|
||||||
|
|
||||||
You may also need to set `USERMAP_UID` and `USERMAP_GID` to
|
You may also need to set `USERMAP_UID` and `USERMAP_GID` to
|
||||||
the uid and gid of your user on the host system. Use `id -u` and
|
the UID and GID of your user on the host system. Use `id -u` and
|
||||||
`id -g` to get these. This ensures that both the container and the host
|
`id -g` to get these values. This ensures both the container and the
|
||||||
user have write access to the consumption directory. If your UID
|
host user can write to the consumption directory. If your UID and
|
||||||
and GID on the host system is 1000 (the default for the first normal
|
GID are `1000` (the default for the first normal user on many
|
||||||
user on most systems), it will work out of the box without any
|
systems), this usually works out of the box without
|
||||||
modifications. Run `id "username"` to check.
|
modifications. Run `id "username"` to check.
|
||||||
|
|
||||||
!!! note
|
!!! note
|
||||||
@@ -122,79 +121,55 @@ account. The script essentially automatically performs the steps described in [D
|
|||||||
appending `_FILE` to configuration values. For example [`PAPERLESS_DBUSER`](configuration.md#PAPERLESS_DBUSER)
|
appending `_FILE` to configuration values. For example [`PAPERLESS_DBUSER`](configuration.md#PAPERLESS_DBUSER)
|
||||||
can be set using `PAPERLESS_DBUSER_FILE=/var/run/secrets/password.txt`.
|
can be set using `PAPERLESS_DBUSER_FILE=/var/run/secrets/password.txt`.
|
||||||
|
|
||||||
!!! warning
|
4. Run `docker compose pull`. This pulls the image from the GitHub container registry
|
||||||
|
by default, but you can pull from Docker Hub by changing the `image`
|
||||||
Some file systems such as NFS network shares don't support file
|
|
||||||
system notifications with `inotify`. When storing the consumption
|
|
||||||
directory on such a file system, paperless will not pick up new
|
|
||||||
files with the default configuration. You will need to use
|
|
||||||
[`PAPERLESS_CONSUMER_POLLING`](configuration.md#PAPERLESS_CONSUMER_POLLING), which will disable inotify. See
|
|
||||||
[here](configuration.md#polling).
|
|
||||||
|
|
||||||
5. Run `docker compose pull`. This will pull the image from the GitHub container registry
|
|
||||||
by default but you can change the image to pull from Docker Hub by changing the `image`
|
|
||||||
line to `image: paperlessngx/paperless-ngx:latest`.
|
line to `image: paperlessngx/paperless-ngx:latest`.
|
||||||
|
|
||||||
6. Run `docker compose up -d`. This will create and start the necessary containers.
|
5. Run `docker compose up -d`. This will create and start the necessary containers.
|
||||||
|
|
||||||
7. Congratulations! Your Paperless-ngx instance should now be accessible at `http://127.0.0.1:8000`
|
#### After installation
|
||||||
(or similar, depending on your configuration). When you first access the web interface, you will be
|
|
||||||
prompted to create a superuser account.
|
|
||||||
|
|
||||||
### Build the Docker image yourself {#docker_build}
|
Your Paperless-ngx instance should now be accessible at
|
||||||
|
`http://127.0.0.1:8000` (or similar, depending on your configuration).
|
||||||
|
When you first access the web interface, you will be prompted to create
|
||||||
|
a [superuser](usage.md#superusers) account.
|
||||||
|
|
||||||
1. Clone the entire repository of paperless:
|
#### Optional Advanced Compose Configurations {#advanced_compose data-toc-label="Advanced Compose Configurations"}
|
||||||
|
|
||||||
```shell-session
|
**Rootless**
|
||||||
git clone https://github.com/paperless-ngx/paperless-ngx
|
|
||||||
```
|
|
||||||
|
|
||||||
The main branch always reflects the latest stable version.
|
!!! warning
|
||||||
|
|
||||||
2. Copy one of the `docker/compose/docker-compose.*.yml` to
|
It is not possible to run the container rootless if additional languages are specified via `PAPERLESS_OCR_LANGUAGES`.
|
||||||
`docker-compose.yml` in the root folder, depending on which database
|
|
||||||
backend you want to use. Copy `docker-compose.env` into the project
|
|
||||||
root as well.
|
|
||||||
|
|
||||||
3. In the `docker-compose.yml` file, find the line that instructs
|
If you want to run Paperless as a rootless container, set `user:` in `docker-compose.yml` to the UID and GID of your host user (use `id -u` and `id -g` to find these values). The container process starts directly as that user with no internal privilege remapping:
|
||||||
Docker Compose to pull the paperless image from Docker Hub:
|
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
webserver:
|
webserver:
|
||||||
image: ghcr.io/paperless-ngx/paperless-ngx:latest
|
image: ghcr.io/paperless-ngx/paperless-ngx:latest
|
||||||
```
|
user: '1000:1000'
|
||||||
|
```
|
||||||
|
|
||||||
and replace it with a line that instructs Docker Compose to build
|
Do not combine this with `USERMAP_UID` or `USERMAP_GID`, which are intended for the non-rootless case described in step 3.
|
||||||
the image from the current working directory instead:
|
|
||||||
|
|
||||||
```yaml
|
**File systems without inotify support (e.g. NFS)**
|
||||||
webserver:
|
|
||||||
build:
|
|
||||||
context: .
|
|
||||||
```
|
|
||||||
|
|
||||||
4. Follow the [Docker setup](#docker) above except when asked to run
|
Some file systems, such as NFS network shares, don't support file system
|
||||||
`docker compose pull` to pull the image, run
|
notifications with `inotify`. When the consumption directory is on such a
|
||||||
|
file system, Paperless-ngx will not pick up new files with the default
|
||||||
|
configuration. Use [`PAPERLESS_CONSUMER_POLLING`](configuration.md#PAPERLESS_CONSUMER_POLLING)
|
||||||
|
to enable polling and disable inotify. See [here](configuration.md#polling).
|
||||||
|
|
||||||
```shell-session
|
## Bare Metal Install {#bare_metal}
|
||||||
docker compose build
|
|
||||||
```
|
|
||||||
|
|
||||||
instead to build the image.
|
#### Prerequisites
|
||||||
|
|
||||||
### Bare Metal Route {#bare_metal}
|
- Paperless runs on Linux only, Windows is not supported.
|
||||||
|
- Python 3 is required with versions 3.10 - 3.12 currently supported. Newer versions may work, but some dependencies may not be fully compatible.
|
||||||
|
|
||||||
Paperless runs on linux only. The following procedure has been tested on
|
#### Installation
|
||||||
a minimal installation of Debian/Buster, which is the current stable
|
|
||||||
release at the time of writing. Windows is not and will never be
|
|
||||||
supported.
|
|
||||||
|
|
||||||
Paperless requires Python 3. At this time, 3.10 - 3.12 are tested versions.
|
1. Install dependencies. Paperless requires the following packages:
|
||||||
Newer versions may work, but some dependencies may not fully support newer versions.
|
|
||||||
Support for older Python versions may be dropped as they reach end of life or as newer versions
|
|
||||||
are released, dependency support is confirmed, etc.
|
|
||||||
|
|
||||||
1. Install dependencies. Paperless requires the following packages.
|
|
||||||
|
|
||||||
- `python3`
|
- `python3`
|
||||||
- `python3-pip`
|
- `python3-pip`
|
||||||
@@ -258,8 +233,8 @@ are released, dependency support is confirmed, etc.
|
|||||||
|
|
||||||
2. Install `redis` >= 6.0 and configure it to start automatically.
|
2. Install `redis` >= 6.0 and configure it to start automatically.
|
||||||
|
|
||||||
3. Optional. Install `postgresql` and configure a database, user and
|
3. Optional: Install `postgresql` and configure a database, user, and
|
||||||
password for paperless. If you do not wish to use PostgreSQL,
|
password for Paperless-ngx. If you do not wish to use PostgreSQL,
|
||||||
MariaDB and SQLite are available as well.
|
MariaDB and SQLite are available as well.
|
||||||
|
|
||||||
!!! note
|
!!! note
|
||||||
@@ -268,61 +243,60 @@ are released, dependency support is confirmed, etc.
|
|||||||
extension](https://code.djangoproject.com/wiki/JSON1Extension) is
|
extension](https://code.djangoproject.com/wiki/JSON1Extension) is
|
||||||
enabled. This is usually the case, but not always.
|
enabled. This is usually the case, but not always.
|
||||||
|
|
||||||
4. Create a system user with a new home folder under which you wish
|
4. Create a system user with a new home folder in which you want
|
||||||
to run paperless.
|
to run Paperless-ngx.
|
||||||
|
|
||||||
```shell-session
|
```shell-session
|
||||||
adduser paperless --system --home /opt/paperless --group
|
adduser paperless --system --home /opt/paperless --group
|
||||||
```
|
```
|
||||||
|
|
||||||
5. Get the release archive from
|
5. Download a release archive from
|
||||||
<https://github.com/paperless-ngx/paperless-ngx/releases> for example with
|
<https://github.com/paperless-ngx/paperless-ngx/releases>. For example:
|
||||||
|
|
||||||
```shell-session
|
```shell-session
|
||||||
curl -O -L https://github.com/paperless-ngx/paperless-ngx/releases/download/v1.10.2/paperless-ngx-v1.10.2.tar.xz
|
curl -O -L https://github.com/paperless-ngx/paperless-ngx/releases/download/vX.Y.Z/paperless-ngx-vX.Y.Z.tar.xz
|
||||||
```
|
```
|
||||||
|
|
||||||
Extract the archive with
|
Extract the archive with
|
||||||
|
|
||||||
```shell-session
|
```shell-session
|
||||||
tar -xf paperless-ngx-v1.10.2.tar.xz
|
tar -xf paperless-ngx-vX.Y.Z.tar.xz
|
||||||
```
|
```
|
||||||
|
|
||||||
and copy the contents to the
|
and copy the contents to the home directory of the user you created
|
||||||
home folder of the user you created before (`/opt/paperless`).
|
earlier (`/opt/paperless`).
|
||||||
|
|
||||||
Optional: If you cloned the git repo, you will have to
|
Optional: If you cloned the Git repository, you will need to
|
||||||
compile the frontend yourself, see [here](development.md#front-end-development)
|
compile the frontend yourself. See [here](development.md#front-end-development)
|
||||||
and use the `build` step, not `serve`.
|
and use the `build` step, not `serve`.
|
||||||
|
|
||||||
6. Configure paperless. See [configuration](configuration.md) for details.
|
6. Configure Paperless-ngx. See [configuration](configuration.md) for details.
|
||||||
Edit the included `paperless.conf` and adjust the settings to your
|
Edit the included `paperless.conf` and adjust the settings to your
|
||||||
needs. Required settings for getting
|
needs. Required settings for getting Paperless-ngx running are:
|
||||||
paperless running are:
|
|
||||||
|
|
||||||
- [`PAPERLESS_REDIS`](configuration.md#PAPERLESS_REDIS) should point to your redis server, such as
|
- [`PAPERLESS_REDIS`](configuration.md#PAPERLESS_REDIS) should point to your Redis server, such as
|
||||||
<redis://localhost:6379>.
|
`redis://localhost:6379`.
|
||||||
- [`PAPERLESS_DBENGINE`](configuration.md#PAPERLESS_DBENGINE) optional, and should be one of `postgres`,
|
- [`PAPERLESS_DBENGINE`](configuration.md#PAPERLESS_DBENGINE) is optional, and should be one of `postgres`,
|
||||||
`mariadb`, or `sqlite`
|
`mariadb`, or `sqlite`
|
||||||
- [`PAPERLESS_DBHOST`](configuration.md#PAPERLESS_DBHOST) should be the hostname on which your
|
- [`PAPERLESS_DBHOST`](configuration.md#PAPERLESS_DBHOST) should be the hostname on which your
|
||||||
PostgreSQL server is running. Do not configure this to use
|
PostgreSQL server is running. Do not configure this to use
|
||||||
SQLite instead. Also configure port, database name, user and
|
SQLite instead. Also configure port, database name, user and
|
||||||
password as necessary.
|
password as necessary.
|
||||||
- [`PAPERLESS_CONSUMPTION_DIR`](configuration.md#PAPERLESS_CONSUMPTION_DIR) should point to a folder which
|
- [`PAPERLESS_CONSUMPTION_DIR`](configuration.md#PAPERLESS_CONSUMPTION_DIR) should point to the folder
|
||||||
paperless should watch for documents. You might want to have
|
that Paperless-ngx should watch for incoming documents.
|
||||||
this somewhere else. Likewise, [`PAPERLESS_DATA_DIR`](configuration.md#PAPERLESS_DATA_DIR) and
|
Likewise, [`PAPERLESS_DATA_DIR`](configuration.md#PAPERLESS_DATA_DIR) and
|
||||||
[`PAPERLESS_MEDIA_ROOT`](configuration.md#PAPERLESS_MEDIA_ROOT) define where paperless stores its data.
|
[`PAPERLESS_MEDIA_ROOT`](configuration.md#PAPERLESS_MEDIA_ROOT) define where Paperless-ngx stores its data.
|
||||||
If you like, you can point both to the same directory.
|
If needed, these can point to the same directory.
|
||||||
- [`PAPERLESS_SECRET_KEY`](configuration.md#PAPERLESS_SECRET_KEY) should be a random sequence of
|
- [`PAPERLESS_SECRET_KEY`](configuration.md#PAPERLESS_SECRET_KEY) should be a random sequence of
|
||||||
characters. It's used for authentication. Failure to do so
|
characters. It's used for authentication. Failure to do so
|
||||||
allows third parties to forge authentication credentials.
|
allows third parties to forge authentication credentials.
|
||||||
- [`PAPERLESS_URL`](configuration.md#PAPERLESS_URL) if you are behind a reverse proxy. This should
|
- Set [`PAPERLESS_URL`](configuration.md#PAPERLESS_URL) if you are behind a reverse proxy. This should
|
||||||
point to your domain. Please see
|
point to your domain. Please see
|
||||||
[configuration](configuration.md) for more
|
[configuration](configuration.md) for more
|
||||||
information.
|
information.
|
||||||
|
|
||||||
Many more adjustments can be made to paperless, especially the OCR
|
You can make many more adjustments, especially for OCR.
|
||||||
part. The following options are recommended for everyone:
|
The following options are recommended for most users:
|
||||||
|
|
||||||
- Set [`PAPERLESS_OCR_LANGUAGE`](configuration.md#PAPERLESS_OCR_LANGUAGE) to the language most of your
|
- Set [`PAPERLESS_OCR_LANGUAGE`](configuration.md#PAPERLESS_OCR_LANGUAGE) to the language most of your
|
||||||
documents are written in.
|
documents are written in.
|
||||||
@@ -332,15 +306,14 @@ are released, dependency support is confirmed, etc.
|
|||||||
|
|
||||||
Ensure your Redis instance [is secured](https://redis.io/docs/latest/operate/oss_and_stack/management/security/).
|
Ensure your Redis instance [is secured](https://redis.io/docs/latest/operate/oss_and_stack/management/security/).
|
||||||
|
|
||||||
7. Create the following directories if they are missing:
|
7. Create the following directories if they do not already exist:
|
||||||
|
|
||||||
- `/opt/paperless/media`
|
- `/opt/paperless/media`
|
||||||
- `/opt/paperless/data`
|
- `/opt/paperless/data`
|
||||||
- `/opt/paperless/consume`
|
- `/opt/paperless/consume`
|
||||||
|
|
||||||
Adjust as necessary if you configured different folders.
|
Adjust these paths if you configured different folders.
|
||||||
Ensure that the paperless user has write permissions for every one
|
Then verify that the `paperless` user has write permissions:
|
||||||
of these folders with
|
|
||||||
|
|
||||||
```shell-session
|
```shell-session
|
||||||
ls -l -d /opt/paperless/media
|
ls -l -d /opt/paperless/media
|
||||||
@@ -354,45 +327,44 @@ are released, dependency support is confirmed, etc.
|
|||||||
sudo chown paperless:paperless /opt/paperless/consume
|
sudo chown paperless:paperless /opt/paperless/consume
|
||||||
```
|
```
|
||||||
|
|
||||||
8. Install python requirements from the `requirements.txt` file.
|
8. Install Python dependencies from `requirements.txt`.
|
||||||
|
|
||||||
```shell-session
|
```shell-session
|
||||||
sudo -Hu paperless pip3 install -r requirements.txt
|
sudo -Hu paperless pip3 install -r requirements.txt
|
||||||
```
|
```
|
||||||
|
|
||||||
This will install all python dependencies in the home directory of
|
This will install all Python dependencies in the home directory of
|
||||||
the new paperless user.
|
the new paperless user.
|
||||||
|
|
||||||
!!! tip
|
!!! tip
|
||||||
|
|
||||||
It is up to you if you wish to use a virtual environment or not for the Python
|
You can use a virtual environment if you prefer. If you do,
|
||||||
dependencies. This is an alternative to the above and may require adjusting
|
you may need to adjust the example scripts for your virtual
|
||||||
the example scripts to utilize the virtual environment paths
|
environment paths.
|
||||||
|
|
||||||
!!! tip
|
!!! tip
|
||||||
|
|
||||||
If you use modern Python tooling, such as `uv`, installation will not include
|
If you use modern Python tooling, such as `uv`, installation will not include
|
||||||
dependencies for Postgres or Mariadb. You can select those extras with `--extra <EXTRA>`
|
dependencies for PostgreSQL or MariaDB. You can select those
|
||||||
or all with `--all-extras`
|
extras with `--extra <EXTRA>`, or install all extras with
|
||||||
|
`--all-extras`.
|
||||||
|
|
||||||
9. Go to `/opt/paperless/src`, and execute the following command:
|
9. Go to `/opt/paperless/src` and execute the following command:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# This creates the database schema.
|
# This creates the database schema.
|
||||||
sudo -Hu paperless python3 manage.py migrate
|
sudo -Hu paperless python3 manage.py migrate
|
||||||
```
|
```
|
||||||
|
|
||||||
When you first access the web interface you will be prompted to create a superuser account.
|
10. Optional: Test that Paperless-ngx is working by running
|
||||||
|
|
||||||
10. Optional: Test that paperless is working by executing
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Manually starts the webserver
|
# Manually starts the webserver
|
||||||
sudo -Hu paperless python3 manage.py runserver
|
sudo -Hu paperless python3 manage.py runserver
|
||||||
```
|
```
|
||||||
|
|
||||||
and pointing your browser to http://localhost:8000 if
|
Then point your browser to `http://localhost:8000` if
|
||||||
accessing from the same devices on which paperless is installed.
|
accessing from the same device on which Paperless-ngx is installed.
|
||||||
If accessing from another machine, set up systemd services. You may need
|
If accessing from another machine, set up systemd services. You may need
|
||||||
to set `PAPERLESS_DEBUG=true` in order for the development server to work
|
to set `PAPERLESS_DEBUG=true` in order for the development server to work
|
||||||
normally in your browser.
|
normally in your browser.
|
||||||
@@ -400,23 +372,24 @@ are released, dependency support is confirmed, etc.
|
|||||||
!!! warning
|
!!! warning
|
||||||
|
|
||||||
This is a development server which should not be used in production.
|
This is a development server which should not be used in production.
|
||||||
It is not audited for security and performance is inferior to
|
It is not audited for security, and performance is inferior to
|
||||||
production ready web servers.
|
production-ready web servers.
|
||||||
|
|
||||||
!!! tip
|
!!! tip
|
||||||
|
|
||||||
This will not start the consumer. Paperless does this in a separate
|
This will not start the consumer. Paperless does this in a separate
|
||||||
process.
|
process.
|
||||||
|
|
||||||
11. Setup systemd services to run paperless automatically. You may use
|
11. Set up systemd services to run Paperless-ngx automatically. You may use
|
||||||
the service definition files included in the `scripts` folder as a
|
the service definition files included in the `scripts` folder as a
|
||||||
starting point.
|
starting point.
|
||||||
|
|
||||||
Paperless needs the `webserver` script to run the webserver, the
|
Paperless needs:
|
||||||
`consumer` script to watch the input folder, `taskqueue` for the
|
|
||||||
background workers used to handle things like document consumption
|
- The `webserver` script to run the webserver.
|
||||||
and the `scheduler` script to run tasks such as email checking at
|
- The `consumer` script to watch the input folder.
|
||||||
certain times .
|
- The `taskqueue` script for background workers (document consumption, etc.).
|
||||||
|
- The `scheduler` script for periodic tasks such as email checking.
|
||||||
|
|
||||||
!!! note
|
!!! note
|
||||||
|
|
||||||
@@ -425,9 +398,9 @@ are released, dependency support is confirmed, etc.
|
|||||||
`Require=paperless-webserver.socket` in the `webserver` script
|
`Require=paperless-webserver.socket` in the `webserver` script
|
||||||
and configure `granian` to listen on port 80 (set `GRANIAN_PORT`).
|
and configure `granian` to listen on port 80 (set `GRANIAN_PORT`).
|
||||||
|
|
||||||
These services rely on redis and optionally the database server, but
|
These services rely on Redis and optionally the database server, but
|
||||||
don't need to be started in any particular order. The example files
|
don't need to be started in any particular order. The example files
|
||||||
depend on redis being started. If you use a database server, you
|
depend on Redis being started. If you use a database server, you
|
||||||
should add additional dependencies.
|
should add additional dependencies.
|
||||||
|
|
||||||
!!! note
|
!!! note
|
||||||
@@ -437,18 +410,15 @@ are released, dependency support is confirmed, etc.
|
|||||||
|
|
||||||
!!! warning
|
!!! warning
|
||||||
|
|
||||||
If celery won't start (check with
|
If Celery won't start, check
|
||||||
`sudo systemctl status paperless-task-queue.service` for
|
`sudo systemctl status paperless-task-queue.service` for
|
||||||
paperless-task-queue.service and paperless-scheduler.service
|
`paperless-task-queue.service` and `paperless-scheduler.service`.
|
||||||
) you need to change the path in the files. Example:
|
You may need to change the path in the files. Example:
|
||||||
`ExecStart=/opt/paperless/.local/bin/celery --app paperless worker --loglevel INFO`
|
`ExecStart=/opt/paperless/.local/bin/celery --app paperless worker --loglevel INFO`
|
||||||
|
|
||||||
12. Optional: Install a samba server and make the consumption folder
|
12. Configure ImageMagick to allow processing of PDF documents. Most
|
||||||
available as a network share.
|
|
||||||
|
|
||||||
13. Configure ImageMagick to allow processing of PDF documents. Most
|
|
||||||
distributions have this disabled by default, since PDF documents can
|
distributions have this disabled by default, since PDF documents can
|
||||||
contain malware. If you don't do this, paperless will fall back to
|
contain malware. If you don't do this, Paperless-ngx will fall back to
|
||||||
Ghostscript for certain steps such as thumbnail generation.
|
Ghostscript for certain steps such as thumbnail generation.
|
||||||
|
|
||||||
Edit `/etc/ImageMagick-6/policy.xml` and adjust
|
Edit `/etc/ImageMagick-6/policy.xml` and adjust
|
||||||
@@ -463,32 +433,38 @@ are released, dependency support is confirmed, etc.
|
|||||||
<policy domain="coder" rights="read|write" pattern="PDF" />
|
<policy domain="coder" rights="read|write" pattern="PDF" />
|
||||||
```
|
```
|
||||||
|
|
||||||
14. Optional: Install the
|
**Optional: Install the [jbig2enc](https://ocrmypdf.readthedocs.io/en/latest/jbig2.html) encoder.**
|
||||||
[jbig2enc](https://ocrmypdf.readthedocs.io/en/latest/jbig2.html)
|
This will reduce the size of generated PDF documents. You'll most likely need to compile this yourself, because this
|
||||||
encoder. This will reduce the size of generated PDF documents.
|
software has been patented until around 2017 and binary packages are not available for most distributions.
|
||||||
You'll most likely need to compile this by yourself, because this
|
|
||||||
software has been patented until around 2017 and binary packages are
|
|
||||||
not available for most distributions.
|
|
||||||
|
|
||||||
15. Optional: If using the NLTK machine learning processing (see
|
**Optional: download the NLTK data**
|
||||||
[`PAPERLESS_ENABLE_NLTK`](configuration.md#PAPERLESS_ENABLE_NLTK) for details),
|
If using the NLTK machine-learning processing (see [`PAPERLESS_ENABLE_NLTK`](configuration.md#PAPERLESS_ENABLE_NLTK) for details),
|
||||||
download the NLTK data for the Snowball
|
download the NLTK data for the Snowball Stemmer, Stopwords and Punkt tokenizer to `/usr/share/nltk_data`. Refer to the [NLTK
|
||||||
Stemmer, Stopwords and Punkt tokenizer to `/usr/share/nltk_data`. Refer to the [NLTK
|
instructions](https://www.nltk.org/data.html) for details on how to download the data.
|
||||||
instructions](https://www.nltk.org/data.html) for details on how to
|
|
||||||
download the data.
|
|
||||||
|
|
||||||
# Migrating to Paperless-ngx
|
#### After installation
|
||||||
|
|
||||||
Migration is possible both from Paperless-ng or directly from the
|
Your Paperless-ngx instance should now be accessible at `http://localhost:8000` (or similar, depending on your configuration).
|
||||||
'original' Paperless.
|
When you first access the web interface you will be prompted to create a [superuser](usage.md#superusers) account.
|
||||||
|
|
||||||
## Migrating from Paperless-ng
|
## Build the Docker image yourself {#docker_build data-toc-label="Building the Docker image"}
|
||||||
|
|
||||||
Paperless-ngx is meant to be a drop-in replacement for Paperless-ng and
|
Building the Docker image yourself is typically used for development, but it can also be used for production
|
||||||
thus upgrading should be trivial for most users, especially when using
|
if you want to customize the image. See [Building the Docker image](development.md#docker_build) in the
|
||||||
docker. However, as with any major change, it is recommended to take a
|
development documentation.
|
||||||
|
|
||||||
|
## Migrating to Paperless-ngx
|
||||||
|
|
||||||
|
You can migrate to Paperless-ngx from Paperless-ng or from the original
|
||||||
|
Paperless project.
|
||||||
|
|
||||||
|
<h3 id="migration_ng">Migrating from Paperless-ng</h3>
|
||||||
|
|
||||||
|
Paperless-ngx is meant to be a drop-in replacement for Paperless-ng, and
|
||||||
|
upgrading should be trivial for most users, especially when using
|
||||||
|
Docker. However, as with any major change, it is recommended to take a
|
||||||
full backup first. Once you are ready, simply change the docker image to
|
full backup first. Once you are ready, simply change the docker image to
|
||||||
point to the new source. E.g. if using Docker Compose, edit
|
point to the new source. For example, if using Docker Compose, edit
|
||||||
`docker-compose.yml` and change:
|
`docker-compose.yml` and change:
|
||||||
|
|
||||||
```
|
```
|
||||||
@@ -501,66 +477,64 @@ to
|
|||||||
image: ghcr.io/paperless-ngx/paperless-ngx:latest
|
image: ghcr.io/paperless-ngx/paperless-ngx:latest
|
||||||
```
|
```
|
||||||
|
|
||||||
and then run `docker compose up -d` which will pull the new image
|
and then run `docker compose up -d`, which will pull the new image and
|
||||||
recreate the container. That's it!
|
recreate the container. That's it.
|
||||||
|
|
||||||
Users who installed with the bare-metal route should also update their
|
Users who installed with the bare-metal route should also update their
|
||||||
Git clone to point to `https://github.com/paperless-ngx/paperless-ngx`,
|
Git clone to point to `https://github.com/paperless-ngx/paperless-ngx`,
|
||||||
e.g. using the command
|
for example using:
|
||||||
`git remote set-url origin https://github.com/paperless-ngx/paperless-ngx`
|
`git remote set-url origin https://github.com/paperless-ngx/paperless-ngx`
|
||||||
and then pull the latest version.
|
and then pull the latest version.
|
||||||
|
|
||||||
## Migrating from Paperless
|
<h3 id="migration_paperless">Migrating from Paperless</h3>
|
||||||
|
|
||||||
At its core, paperless-ngx is still paperless and fully compatible.
|
At its core, Paperless-ngx is still Paperless and fully compatible.
|
||||||
However, some things have changed under the hood, so you need to adapt
|
However, some things have changed under the hood, so you need to adapt
|
||||||
your setup depending on how you installed paperless.
|
your setup depending on how you installed Paperless.
|
||||||
|
|
||||||
This setup describes how to update an existing paperless Docker
|
This section describes how to update an existing Paperless Docker
|
||||||
installation. The important things to keep in mind are as follows:
|
installation. Keep these points in mind:
|
||||||
|
|
||||||
- Read the [changelog](changelog.md) and
|
- Read the [changelog](changelog.md) and
|
||||||
take note of breaking changes.
|
take note of breaking changes.
|
||||||
- You should decide if you want to stick with SQLite or want to
|
- Decide whether to stay on SQLite or migrate to PostgreSQL.
|
||||||
migrate your database to PostgreSQL. See [documentation](#sqlite_to_psql)
|
Both work fine with Paperless-ngx.
|
||||||
for details on
|
However, if you already have a database server running
|
||||||
how to move your data from SQLite to PostgreSQL. Both work fine with
|
for other services, you might as well use it for Paperless as well.
|
||||||
paperless. However, if you already have a database server running
|
- The task scheduler of Paperless, which is used to execute periodic
|
||||||
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
|
tasks such as email checking and maintenance, requires a
|
||||||
[redis](https://redis.io/) message broker instance. The
|
[Redis](https://redis.io/) message broker instance. The
|
||||||
Docker Compose route takes care of that.
|
Docker Compose route takes care of that.
|
||||||
- The layout of the folder structure for your documents and data
|
- The layout of the folder structure for your documents and data
|
||||||
remains the same, so you can just plug your old docker volumes into
|
remains the same, so you can plug your old Docker volumes into
|
||||||
paperless-ngx and expect it to find everything where it should be.
|
paperless-ngx and expect it to find everything where it should be.
|
||||||
|
|
||||||
Migration to paperless-ngx is then performed in a few simple steps:
|
Migration to Paperless-ngx is then performed in a few simple steps:
|
||||||
|
|
||||||
1. Stop paperless.
|
1. Stop Paperless.
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cd /path/to/current/paperless
|
cd /path/to/current/paperless
|
||||||
docker compose down
|
docker compose down
|
||||||
```
|
```
|
||||||
|
|
||||||
2. Do a backup for two purposes: If something goes wrong, you still
|
2. Create a backup for two reasons: if something goes wrong, you still
|
||||||
have your data. Second, if you don't like paperless-ngx, you can
|
have your data; and if you don't like paperless-ngx, you can
|
||||||
switch back to paperless.
|
switch back to Paperless.
|
||||||
|
|
||||||
3. Download the latest release of paperless-ngx. You can either go with
|
3. Download the latest release of Paperless-ngx. You can either use
|
||||||
the Docker Compose files from
|
the Docker Compose files from
|
||||||
[here](https://github.com/paperless-ngx/paperless-ngx/tree/main/docker/compose)
|
[here](https://github.com/paperless-ngx/paperless-ngx/tree/main/docker/compose)
|
||||||
or clone the repository to build the image yourself (see
|
or clone the repository to build the image yourself (see
|
||||||
[above](#docker_build)). You can
|
[development docs](development.md#docker_build)). You can either replace your current paperless
|
||||||
either replace your current paperless folder or put paperless-ngx in
|
folder or put Paperless-ngx in
|
||||||
a different location.
|
a different location.
|
||||||
|
|
||||||
!!! warning
|
!!! warning
|
||||||
|
|
||||||
Paperless-ngx includes a `.env` file. This will set the project name
|
Paperless-ngx includes a `.env` file. This will set the project name
|
||||||
for docker compose to `paperless`, which will also define the name
|
for Docker Compose to `paperless`, which will also define the
|
||||||
of the volumes by paperless-ngx. However, if you experience that
|
volume names created by Paperless-ngx. However, if you notice that
|
||||||
paperless-ngx is not using your old paperless volumes, verify the
|
paperless-ngx is not using your old paperless volumes, verify the
|
||||||
names of your volumes with
|
names of your volumes with
|
||||||
|
|
||||||
@@ -576,10 +550,10 @@ Migration to paperless-ngx is then performed in a few simple steps:
|
|||||||
after you migrated your existing SQLite database.
|
after you migrated your existing SQLite database.
|
||||||
|
|
||||||
5. Adjust `docker-compose.yml` and `docker-compose.env` to your needs.
|
5. Adjust `docker-compose.yml` and `docker-compose.env` to your needs.
|
||||||
See [Docker setup](#docker) details on
|
See [Docker setup](#docker) for details on
|
||||||
which edits are advised.
|
which edits are recommended.
|
||||||
|
|
||||||
6. [Update paperless.](administration.md#updating)
|
6. Follow the update procedure in [Update paperless](administration.md#updating).
|
||||||
|
|
||||||
7. In order to find your existing documents with the new search
|
7. In order to find your existing documents with the new search
|
||||||
feature, you need to invoke a one-time operation that will create
|
feature, you need to invoke a one-time operation that will create
|
||||||
@@ -590,136 +564,99 @@ Migration to paperless-ngx is then performed in a few simple steps:
|
|||||||
```
|
```
|
||||||
|
|
||||||
This will migrate your database and create the search index. After
|
This will migrate your database and create the search index. After
|
||||||
that, paperless will take care of maintaining the index by itself.
|
that, Paperless-ngx will maintain the index automatically.
|
||||||
|
|
||||||
8. Start paperless-ngx.
|
8. Start Paperless-ngx.
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
docker compose up -d
|
docker compose up -d
|
||||||
```
|
```
|
||||||
|
|
||||||
This will run paperless in the background and automatically start it
|
This will run Paperless-ngx in the background and automatically start it
|
||||||
on system boot.
|
on system boot.
|
||||||
|
|
||||||
9. Paperless installed a permanent redirect to `admin/` in your
|
9. Paperless may have installed a permanent redirect to `admin/` in your
|
||||||
browser. This redirect is still in place and prevents access to the
|
browser. This redirect is still in place and prevents access to the
|
||||||
new UI. Clear your browsing cache in order to fix this.
|
new UI. Clear your browser cache to fix this.
|
||||||
|
|
||||||
10. Optionally, follow the instructions below to migrate your existing
|
10. Optionally, follow the instructions below to migrate your existing
|
||||||
data to PostgreSQL.
|
data to PostgreSQL.
|
||||||
|
|
||||||
## Migrating from LinuxServer.io Docker Image
|
<h3 id="migration_lsio">Migrating from LinuxServer.io Docker Image</h3>
|
||||||
|
|
||||||
As with any upgrades and large changes, it is highly recommended to
|
As with any upgrade or large change, it is highly recommended to
|
||||||
create a backup before starting. This assumes the image was running
|
create a backup before starting. This assumes the image was running
|
||||||
using Docker Compose, but the instructions are translatable to Docker
|
using Docker Compose, but the instructions are translatable to Docker
|
||||||
commands as well.
|
commands as well.
|
||||||
|
|
||||||
1. Stop and remove the paperless container
|
1. Stop and remove the Paperless container.
|
||||||
2. If using an external database, stop the container
|
2. If using an external database, stop that container.
|
||||||
3. Update Redis configuration
|
3. Update Redis configuration.
|
||||||
|
|
||||||
1. If `REDIS_URL` is already set, change it to [`PAPERLESS_REDIS`](configuration.md#PAPERLESS_REDIS)
|
1. If `REDIS_URL` is already set, change it to [`PAPERLESS_REDIS`](configuration.md#PAPERLESS_REDIS)
|
||||||
and continue to step 4.
|
and continue to step 4.
|
||||||
|
|
||||||
1. Otherwise, in the `docker-compose.yml` add a new service for
|
1. Otherwise, add a new Redis service in `docker-compose.yml`,
|
||||||
Redis, following [the example compose
|
following [the example compose
|
||||||
files](https://github.com/paperless-ngx/paperless-ngx/tree/main/docker/compose)
|
files](https://github.com/paperless-ngx/paperless-ngx/tree/main/docker/compose)
|
||||||
|
|
||||||
1. Set the environment variable [`PAPERLESS_REDIS`](configuration.md#PAPERLESS_REDIS) so it points to
|
1. Set the environment variable [`PAPERLESS_REDIS`](configuration.md#PAPERLESS_REDIS) so it points to
|
||||||
the new Redis container
|
the new Redis container.
|
||||||
|
|
||||||
4. Update user mapping
|
4. Update user mapping.
|
||||||
|
|
||||||
1. If set, change the environment variable `PUID` to `USERMAP_UID`
|
1. If set, change the environment variable `PUID` to `USERMAP_UID`.
|
||||||
|
|
||||||
1. If set, change the environment variable `PGID` to `USERMAP_GID`
|
1. If set, change the environment variable `PGID` to `USERMAP_GID`.
|
||||||
|
|
||||||
5. Update configuration paths
|
5. Update configuration paths.
|
||||||
|
|
||||||
1. Set the environment variable [`PAPERLESS_DATA_DIR`](configuration.md#PAPERLESS_DATA_DIR) to `/config`
|
1. Set the environment variable [`PAPERLESS_DATA_DIR`](configuration.md#PAPERLESS_DATA_DIR) to `/config`.
|
||||||
|
|
||||||
6. Update media paths
|
6. Update media paths.
|
||||||
|
|
||||||
1. Set the environment variable [`PAPERLESS_MEDIA_ROOT`](configuration.md#PAPERLESS_MEDIA_ROOT) to
|
1. Set the environment variable [`PAPERLESS_MEDIA_ROOT`](configuration.md#PAPERLESS_MEDIA_ROOT) to
|
||||||
`/data/media`
|
`/data/media`.
|
||||||
|
|
||||||
7. Update timezone
|
7. Update timezone.
|
||||||
|
|
||||||
1. Set the environment variable [`PAPERLESS_TIME_ZONE`](configuration.md#PAPERLESS_TIME_ZONE) to the same
|
1. Set the environment variable [`PAPERLESS_TIME_ZONE`](configuration.md#PAPERLESS_TIME_ZONE) to the same
|
||||||
value as `TZ`
|
value as `TZ`.
|
||||||
|
|
||||||
8. Modify the `image:` to point to
|
8. Modify `image:` to point to
|
||||||
`ghcr.io/paperless-ngx/paperless-ngx:latest` or a specific version
|
`ghcr.io/paperless-ngx/paperless-ngx:latest` or a specific version
|
||||||
if preferred.
|
if preferred.
|
||||||
9. Start the containers as before, using `docker compose`.
|
9. Start the containers as before, using `docker compose`.
|
||||||
|
|
||||||
## Moving data from SQLite to PostgreSQL or MySQL/MariaDB {#sqlite_to_psql}
|
## Running Paperless-ngx on less powerful devices {#less-powerful-devices data-toc-label="Less Powerful Devices"}
|
||||||
|
|
||||||
The best way to migrate between database types is to perform an [export](administration.md#exporter) and then
|
Paperless runs on Raspberry Pi. Some tasks can be slow on lower-powered
|
||||||
[import](administration.md#importer) into a clean installation of Paperless-ngx.
|
hardware, but a few settings can improve performance:
|
||||||
|
|
||||||
## Moving back to Paperless
|
|
||||||
|
|
||||||
Lets say you migrated to Paperless-ngx and used it for a while, but
|
|
||||||
decided that you don't like it and want to move back (If you do, send
|
|
||||||
me a mail about what part you didn't like!), you can totally do that
|
|
||||||
with a few simple steps.
|
|
||||||
|
|
||||||
Paperless-ngx modified the database schema slightly, however, these
|
|
||||||
changes can be reverted while keeping your current data, so that your
|
|
||||||
current data will be compatible with original Paperless. Thumbnails
|
|
||||||
were also changed from PNG to WEBP format and will need to be
|
|
||||||
re-generated.
|
|
||||||
|
|
||||||
Execute this:
|
|
||||||
|
|
||||||
```shell-session
|
|
||||||
$ cd /path/to/paperless
|
|
||||||
$ docker compose run --rm webserver migrate documents 0023
|
|
||||||
```
|
|
||||||
|
|
||||||
Or without docker:
|
|
||||||
|
|
||||||
```shell-session
|
|
||||||
$ cd /path/to/paperless/src
|
|
||||||
$ python3 manage.py migrate documents 0023
|
|
||||||
```
|
|
||||||
|
|
||||||
After regenerating thumbnails, you'll need to clear your cookies
|
|
||||||
(Paperless-ngx comes with updated dependencies that do cookie-processing
|
|
||||||
differently) and probably your cache as well.
|
|
||||||
|
|
||||||
# Considerations for less powerful devices {#less-powerful-devices}
|
|
||||||
|
|
||||||
Paperless runs on Raspberry Pi. However, some things are rather slow on
|
|
||||||
the Pi and configuring some options in paperless can help improve
|
|
||||||
performance immensely:
|
|
||||||
|
|
||||||
- Stick with SQLite to save some resources. See [troubleshooting](troubleshooting.md#log-reports-creating-paperlesstask-failed)
|
- Stick with SQLite to save some resources. See [troubleshooting](troubleshooting.md#log-reports-creating-paperlesstask-failed)
|
||||||
if you encounter issues with SQLite locking.
|
if you encounter issues with SQLite locking.
|
||||||
- If you do not need the filesystem-based consumer, consider disabling it
|
- If you do not need the filesystem-based consumer, consider disabling it
|
||||||
entirely by setting [`PAPERLESS_CONSUMER_DISABLE`](configuration.md#PAPERLESS_CONSUMER_DISABLE) to `true`.
|
entirely by setting [`PAPERLESS_CONSUMER_DISABLE`](configuration.md#PAPERLESS_CONSUMER_DISABLE) to `true`.
|
||||||
- Consider setting [`PAPERLESS_OCR_PAGES`](configuration.md#PAPERLESS_OCR_PAGES) to 1, so that paperless will
|
- Consider setting [`PAPERLESS_OCR_PAGES`](configuration.md#PAPERLESS_OCR_PAGES) to 1, so that Paperless
|
||||||
only OCR the first page of your documents. In most cases, this page
|
OCRs only the first page of your documents. In most cases, this page
|
||||||
contains enough information to be able to find it.
|
contains enough information to be able to find it.
|
||||||
- [`PAPERLESS_TASK_WORKERS`](configuration.md#PAPERLESS_TASK_WORKERS) and [`PAPERLESS_THREADS_PER_WORKER`](configuration.md#PAPERLESS_THREADS_PER_WORKER) are
|
- [`PAPERLESS_TASK_WORKERS`](configuration.md#PAPERLESS_TASK_WORKERS) and [`PAPERLESS_THREADS_PER_WORKER`](configuration.md#PAPERLESS_THREADS_PER_WORKER) are
|
||||||
configured to use all cores. The Raspberry Pi models 3 and up have 4
|
configured to use all cores. The Raspberry Pi models 3 and up have 4
|
||||||
cores, meaning that paperless will use 2 workers and 2 threads per
|
cores, meaning that Paperless will use 2 workers and 2 threads per
|
||||||
worker. This may result in sluggish response times during
|
worker. This may result in sluggish response times during
|
||||||
consumption, so you might want to lower these settings (example: 2
|
consumption, so you might want to lower these settings (example: 2
|
||||||
workers and 1 thread to always have some computing power left for
|
workers and 1 thread to always have some computing power left for
|
||||||
other tasks).
|
other tasks).
|
||||||
- Keep [`PAPERLESS_OCR_MODE`](configuration.md#PAPERLESS_OCR_MODE) at its default value `skip` and consider
|
- Keep [`PAPERLESS_OCR_MODE`](configuration.md#PAPERLESS_OCR_MODE) at its default value `skip` and consider
|
||||||
OCR'ing your documents before feeding them into paperless. Some
|
OCRing your documents before feeding them into Paperless. Some
|
||||||
scanners are able to do this!
|
scanners are able to do this!
|
||||||
- Set [`PAPERLESS_OCR_SKIP_ARCHIVE_FILE`](configuration.md#PAPERLESS_OCR_SKIP_ARCHIVE_FILE) to `with_text` to skip archive
|
- Set [`PAPERLESS_OCR_SKIP_ARCHIVE_FILE`](configuration.md#PAPERLESS_OCR_SKIP_ARCHIVE_FILE) to `with_text` to skip archive
|
||||||
file generation for already ocr'ed documents, or `always` to skip it
|
file generation for already OCRed documents, or `always` to skip it
|
||||||
for all documents.
|
for all documents.
|
||||||
- If you want to perform OCR on the device, consider using
|
- If you want to perform OCR on the device, consider using
|
||||||
`PAPERLESS_OCR_CLEAN=none`. This will speed up OCR times and use
|
`PAPERLESS_OCR_CLEAN=none`. This will speed up OCR times and use
|
||||||
less memory at the expense of slightly worse OCR results.
|
less memory at the expense of slightly worse OCR results.
|
||||||
- If using docker, consider setting [`PAPERLESS_WEBSERVER_WORKERS`](configuration.md#PAPERLESS_WEBSERVER_WORKERS) to 1. This will save some memory.
|
- If using Docker, consider setting [`PAPERLESS_WEBSERVER_WORKERS`](configuration.md#PAPERLESS_WEBSERVER_WORKERS) to 1. This will save some memory.
|
||||||
- Consider setting [`PAPERLESS_ENABLE_NLTK`](configuration.md#PAPERLESS_ENABLE_NLTK) to false, to disable the
|
- Consider setting [`PAPERLESS_ENABLE_NLTK`](configuration.md#PAPERLESS_ENABLE_NLTK) to false, to disable the
|
||||||
more advanced language processing, which can take more memory and
|
more advanced language processing, which can take more memory and
|
||||||
processing time.
|
processing time.
|
||||||
@@ -731,17 +668,19 @@ For details, refer to [configuration](configuration.md).
|
|||||||
Updating the
|
Updating the
|
||||||
[automatic matching algorithm](advanced_usage.md#automatic-matching) takes quite a bit of time. However, the update mechanism
|
[automatic matching algorithm](advanced_usage.md#automatic-matching) takes quite a bit of time. However, the update mechanism
|
||||||
checks if your data has changed before doing the heavy lifting. If you
|
checks if your data has changed before doing the heavy lifting. If you
|
||||||
experience the algorithm taking too much cpu time, consider changing the
|
experience the algorithm taking too much CPU time, consider changing the
|
||||||
schedule in the admin interface to daily. You can also manually invoke
|
schedule in the admin interface to daily. You can also manually invoke
|
||||||
the task by changing the date and time of the next run to today/now.
|
the task by changing the date and time of the next run to today/now.
|
||||||
|
|
||||||
The actual matching of the algorithm is fast and works on Raspberry Pi
|
The actual matching of the algorithm is fast and works on Raspberry Pi
|
||||||
as well as on any other device.
|
as well as on any other device.
|
||||||
|
|
||||||
# Using nginx as a reverse proxy {#nginx}
|
## Additional considerations
|
||||||
|
|
||||||
Please see [the wiki](https://github.com/paperless-ngx/paperless-ngx/wiki/Using-a-Reverse-Proxy-with-Paperless-ngx#nginx) for user-maintained documentation of using nginx with Paperless-ngx.
|
**Using a reverse proxy with Paperless-ngx**
|
||||||
|
|
||||||
# Enhancing security {#security}
|
Please see [the wiki](https://github.com/paperless-ngx/paperless-ngx/wiki/Using-a-Reverse-Proxy-with-Paperless-ngx#nginx) for user-maintained documentation on using nginx with Paperless-ngx.
|
||||||
|
|
||||||
Please see [the wiki](https://github.com/paperless-ngx/paperless-ngx/wiki/Using-Security-Tools-with-Paperless-ngx) for user-maintained documentation of how to configure security tools like Fail2ban with Paperless-ngx.
|
**Enhancing security**
|
||||||
|
|
||||||
|
Please see [the wiki](https://github.com/paperless-ngx/paperless-ngx/wiki/Using-Security-Tools-with-Paperless-ngx) for user-maintained documentation on configuring security tools like Fail2ban with Paperless-ngx.
|
||||||
+9
-2
@@ -348,6 +348,11 @@ permissions can be granted to limit access to certain parts of the UI (and corre
|
|||||||
|
|
||||||
Superusers can access all parts of the front and backend application as well as any and all objects. Superuser status can only be granted by another superuser.
|
Superusers can access all parts of the front and backend application as well as any and all objects. Superuser status can only be granted by another superuser.
|
||||||
|
|
||||||
|
!!! tip
|
||||||
|
|
||||||
|
Because superuser accounts can see all objects and documents, you may want to use a regular account for day-to-day use. Additional superuser accounts can
|
||||||
|
be created via [cli](administration.md#create-superuser) or granted superuser status from an existing superuser account.
|
||||||
|
|
||||||
#### Admin Status
|
#### Admin Status
|
||||||
|
|
||||||
Admin status (Django 'staff status') grants access to viewing the paperless logs and the system status dialog
|
Admin status (Django 'staff status') grants access to viewing the paperless logs and the system status dialog
|
||||||
@@ -376,7 +381,7 @@ still have "object-level" permissions.
|
|||||||
| StoragePath | Add, edit, delete or view Storage Paths. |
|
| StoragePath | Add, edit, delete or view Storage Paths. |
|
||||||
| Tag | Add, edit, delete or view Tags. |
|
| Tag | Add, edit, delete or view Tags. |
|
||||||
| UISettings | Add, edit, delete or view the UI settings that are used by the web app.<br/>:warning: **Users that will access the web UI must be granted at least _View_ permissions.** |
|
| UISettings | Add, edit, delete or view the UI settings that are used by the web app.<br/>:warning: **Users that will access the web UI must be granted at least _View_ permissions.** |
|
||||||
| User | Add, edit, delete or view Users. |
|
| User | Add, edit, delete or view other user accounts via Settings > Users & Groups and `/api/users/`. These permissions are not needed for users to edit their own profile via "My Profile" or `/api/profile/`. |
|
||||||
| Workflow | Add, edit, delete or view Workflows.<br/>Note that Workflows are global; all users who can access workflows see the same set. Workflows have other permission implications — see [Workflow permissions](#workflow-permissions). |
|
| Workflow | Add, edit, delete or view Workflows.<br/>Note that Workflows are global; all users who can access workflows see the same set. Workflows have other permission implications — see [Workflow permissions](#workflow-permissions). |
|
||||||
|
|
||||||
#### Detailed Explanation of Object Permissions {#object-permissions}
|
#### Detailed Explanation of Object Permissions {#object-permissions}
|
||||||
@@ -387,6 +392,8 @@ still have "object-level" permissions.
|
|||||||
| View | Confers the ability to view (not edit) a document, tag, etc.<br/>Users without 'view' (or higher) permissions will be shown _'Private'_ in place of the object name for example when viewing a document with a tag for which the user doesn't have permissions. |
|
| View | Confers the ability to view (not edit) a document, tag, etc.<br/>Users without 'view' (or higher) permissions will be shown _'Private'_ in place of the object name for example when viewing a document with a tag for which the user doesn't have permissions. |
|
||||||
| Edit | Confers the ability to edit (and view) a document, tag, etc. |
|
| Edit | Confers the ability to edit (and view) a document, tag, etc. |
|
||||||
|
|
||||||
|
For related metadata such as tags, correspondents, document types, and storage paths, object visibility and document assignment are intentionally distinct. A user may still retain or submit a known object ID when editing a document even if that related object is displayed as _Private_ or omitted from search and selection results. This allows documents to preserve existing assignments that the current user cannot necessarily inspect in detail.
|
||||||
|
|
||||||
### Password reset
|
### Password reset
|
||||||
|
|
||||||
In order to enable the password reset feature you will need to setup an SMTP backend, see
|
In order to enable the password reset feature you will need to setup an SMTP backend, see
|
||||||
@@ -565,7 +572,7 @@ applied. You can use the following placeholders in the template with any trigger
|
|||||||
- `{{added_day}}`: added day
|
- `{{added_day}}`: added day
|
||||||
- `{{added_time}}`: added time in HH:MM format
|
- `{{added_time}}`: added time in HH:MM format
|
||||||
- `{{original_filename}}`: original file name without extension
|
- `{{original_filename}}`: original file name without extension
|
||||||
- `{{filename}}`: current file name without extension
|
- `{{filename}}`: current file name without extension (for "added" workflows this may not be final yet, you can use `{{original_filename}}`)
|
||||||
- `{{doc_title}}`: current document title
|
- `{{doc_title}}`: current document title
|
||||||
|
|
||||||
The following placeholders are only available for "added" or "updated" triggers
|
The following placeholders are only available for "added" or "updated" triggers
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
[project]
|
[project]
|
||||||
name = "paperless-ngx"
|
name = "paperless-ngx"
|
||||||
version = "2.20.8"
|
version = "2.20.14"
|
||||||
description = "A community-supported supercharged document management system: scan, index and archive all your physical documents"
|
description = "A community-supported supercharged document management system: scan, index and archive all your physical documents"
|
||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
requires-python = ">=3.10"
|
requires-python = ">=3.10"
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "paperless-ngx-ui",
|
"name": "paperless-ngx-ui",
|
||||||
"version": "2.20.8",
|
"version": "2.20.14",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"preinstall": "npx only-allow pnpm",
|
"preinstall": "npx only-allow pnpm",
|
||||||
"ng": "ng",
|
"ng": "ng",
|
||||||
|
|||||||
@@ -19,13 +19,18 @@
|
|||||||
<div class="col">
|
<div class="col">
|
||||||
<div class="card bg-light">
|
<div class="card bg-light">
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
<div class="card-title">
|
<div class="card-title d-flex align-items-center">
|
||||||
<h6>
|
<h6 class="mb-0">
|
||||||
{{option.title}}
|
{{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>
|
|
||||||
</h6>
|
</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>
|
||||||
<div class="mb-n3">
|
<div class="mb-n3">
|
||||||
@switch (option.type) {
|
@switch (option.type) {
|
||||||
|
|||||||
@@ -144,4 +144,18 @@ describe('ConfigComponent', () => {
|
|||||||
component.uploadFile(new File([], 'test.png'), 'app_logo')
|
component.uploadFile(new File([], 'test.png'), 'app_logo')
|
||||||
expect(initSpy).toHaveBeenCalled()
|
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()
|
||||||
|
})
|
||||||
})
|
})
|
||||||
@@ -208,4 +208,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)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -26,6 +26,7 @@ import {
|
|||||||
} from 'src/app/data/matching-model'
|
} from 'src/app/data/matching-model'
|
||||||
import { Tag } from 'src/app/data/tag'
|
import { Tag } from 'src/app/data/tag'
|
||||||
import { SETTINGS_KEYS } from 'src/app/data/ui-settings'
|
import { SETTINGS_KEYS } from 'src/app/data/ui-settings'
|
||||||
|
import { PermissionsService } from 'src/app/services/permissions.service'
|
||||||
import { TagService } from 'src/app/services/rest/tag.service'
|
import { TagService } from 'src/app/services/rest/tag.service'
|
||||||
import { UserService } from 'src/app/services/rest/user.service'
|
import { UserService } from 'src/app/services/rest/user.service'
|
||||||
import { SettingsService } from 'src/app/services/settings.service'
|
import { SettingsService } from 'src/app/services/settings.service'
|
||||||
@@ -87,6 +88,7 @@ describe('EditDialogComponent', () => {
|
|||||||
let component: TestComponent
|
let component: TestComponent
|
||||||
let fixture: ComponentFixture<TestComponent>
|
let fixture: ComponentFixture<TestComponent>
|
||||||
let tagService: TagService
|
let tagService: TagService
|
||||||
|
let permissionsService: PermissionsService
|
||||||
let settingsService: SettingsService
|
let settingsService: SettingsService
|
||||||
let activeModal: NgbActiveModal
|
let activeModal: NgbActiveModal
|
||||||
let httpTestingController: HttpTestingController
|
let httpTestingController: HttpTestingController
|
||||||
@@ -118,8 +120,10 @@ describe('EditDialogComponent', () => {
|
|||||||
}).compileComponents()
|
}).compileComponents()
|
||||||
|
|
||||||
tagService = TestBed.inject(TagService)
|
tagService = TestBed.inject(TagService)
|
||||||
|
permissionsService = TestBed.inject(PermissionsService)
|
||||||
settingsService = TestBed.inject(SettingsService)
|
settingsService = TestBed.inject(SettingsService)
|
||||||
settingsService.currentUser = currentUser
|
settingsService.currentUser = currentUser
|
||||||
|
permissionsService.initialize([], currentUser as any)
|
||||||
activeModal = TestBed.inject(NgbActiveModal)
|
activeModal = TestBed.inject(NgbActiveModal)
|
||||||
httpTestingController = TestBed.inject(HttpTestingController)
|
httpTestingController = TestBed.inject(HttpTestingController)
|
||||||
|
|
||||||
@@ -226,6 +230,25 @@ describe('EditDialogComponent', () => {
|
|||||||
expect(updateSpy).toHaveBeenCalled()
|
expect(updateSpy).toHaveBeenCalled()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('should not submit owner or permissions for non-owner edits', () => {
|
||||||
|
component.object = tag
|
||||||
|
component.dialogMode = EditDialogMode.EDIT
|
||||||
|
component.ngOnInit()
|
||||||
|
|
||||||
|
component.objectForm.get('name').setValue('Updated tag')
|
||||||
|
component.save()
|
||||||
|
|
||||||
|
const req = httpTestingController.expectOne(
|
||||||
|
`${environment.apiBaseUrl}tags/${tag.id}/`
|
||||||
|
)
|
||||||
|
expect(req.request.method).toEqual('PUT')
|
||||||
|
expect(req.request.body.name).toEqual('Updated tag')
|
||||||
|
expect(req.request.body.owner).toEqual(tag.owner)
|
||||||
|
expect(req.request.body.set_permissions).toBeUndefined()
|
||||||
|
|
||||||
|
req.flush({})
|
||||||
|
})
|
||||||
|
|
||||||
it('should create an object on save in edit mode', () => {
|
it('should create an object on save in edit mode', () => {
|
||||||
const createSpy = jest.spyOn(tagService, 'create')
|
const createSpy = jest.spyOn(tagService, 'create')
|
||||||
component.dialogMode = EditDialogMode.CREATE
|
component.dialogMode = EditDialogMode.CREATE
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import { ObjectWithId } from 'src/app/data/object-with-id'
|
|||||||
import { ObjectWithPermissions } from 'src/app/data/object-with-permissions'
|
import { ObjectWithPermissions } from 'src/app/data/object-with-permissions'
|
||||||
import { SETTINGS_KEYS } from 'src/app/data/ui-settings'
|
import { SETTINGS_KEYS } from 'src/app/data/ui-settings'
|
||||||
import { User } from 'src/app/data/user'
|
import { User } from 'src/app/data/user'
|
||||||
|
import { PermissionsService } from 'src/app/services/permissions.service'
|
||||||
import { AbstractPaperlessService } from 'src/app/services/rest/abstract-paperless-service'
|
import { AbstractPaperlessService } from 'src/app/services/rest/abstract-paperless-service'
|
||||||
import { UserService } from 'src/app/services/rest/user.service'
|
import { UserService } from 'src/app/services/rest/user.service'
|
||||||
import { SettingsService } from 'src/app/services/settings.service'
|
import { SettingsService } from 'src/app/services/settings.service'
|
||||||
@@ -42,6 +43,7 @@ export abstract class EditDialogComponent<
|
|||||||
protected activeModal = inject(NgbActiveModal)
|
protected activeModal = inject(NgbActiveModal)
|
||||||
protected userService = inject(UserService)
|
protected userService = inject(UserService)
|
||||||
protected settingsService = inject(SettingsService)
|
protected settingsService = inject(SettingsService)
|
||||||
|
protected permissionsService = inject(PermissionsService)
|
||||||
|
|
||||||
users: User[]
|
users: User[]
|
||||||
|
|
||||||
@@ -69,10 +71,6 @@ export abstract class EditDialogComponent<
|
|||||||
|
|
||||||
ngOnInit(): void {
|
ngOnInit(): void {
|
||||||
if (this.object != null && this.dialogMode !== EditDialogMode.CREATE) {
|
if (this.object != null && this.dialogMode !== EditDialogMode.CREATE) {
|
||||||
if ((this.object as ObjectWithPermissions).permissions) {
|
|
||||||
this.object['set_permissions'] = this.object['permissions']
|
|
||||||
}
|
|
||||||
|
|
||||||
this.object['permissions_form'] = {
|
this.object['permissions_form'] = {
|
||||||
owner: (this.object as ObjectWithPermissions).owner,
|
owner: (this.object as ObjectWithPermissions).owner,
|
||||||
set_permissions: (this.object as ObjectWithPermissions).permissions,
|
set_permissions: (this.object as ObjectWithPermissions).permissions,
|
||||||
@@ -151,18 +149,28 @@ export abstract class EditDialogComponent<
|
|||||||
return Object.assign({}, this.objectForm.value)
|
return Object.assign({}, this.objectForm.value)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
protected shouldSubmitPermissions(): boolean {
|
||||||
|
return (
|
||||||
|
this.dialogMode === EditDialogMode.CREATE ||
|
||||||
|
this.permissionsService.currentUserOwnsObject(this.object)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
save() {
|
save() {
|
||||||
this.error = null
|
this.error = null
|
||||||
const formValues = this.getFormValues()
|
const formValues = this.getFormValues()
|
||||||
const permissionsObject: PermissionsFormObject =
|
const permissionsObject: PermissionsFormObject =
|
||||||
this.objectForm.get('permissions_form')?.value
|
this.objectForm.get('permissions_form')?.value
|
||||||
if (permissionsObject) {
|
if (permissionsObject && this.shouldSubmitPermissions()) {
|
||||||
formValues.owner = permissionsObject.owner
|
formValues.owner = permissionsObject.owner
|
||||||
formValues.set_permissions = permissionsObject.set_permissions
|
formValues.set_permissions = permissionsObject.set_permissions
|
||||||
delete formValues.permissions_form
|
|
||||||
}
|
}
|
||||||
|
delete formValues.permissions_form
|
||||||
|
|
||||||
var newObject = Object.assign(Object.assign({}, this.object), formValues)
|
var newObject = Object.assign(Object.assign({}, this.object), formValues)
|
||||||
|
if (!this.shouldSubmitPermissions()) {
|
||||||
|
delete newObject['set_permissions']
|
||||||
|
}
|
||||||
var serverResponse: Observable<T>
|
var serverResponse: Observable<T>
|
||||||
switch (this.dialogMode) {
|
switch (this.dialogMode) {
|
||||||
case EditDialogMode.CREATE:
|
case EditDialogMode.CREATE:
|
||||||
|
|||||||
-2
@@ -9,7 +9,6 @@ import { first } from 'rxjs'
|
|||||||
import { EditDialogComponent } from 'src/app/components/common/edit-dialog/edit-dialog.component'
|
import { EditDialogComponent } from 'src/app/components/common/edit-dialog/edit-dialog.component'
|
||||||
import { Group } from 'src/app/data/group'
|
import { Group } from 'src/app/data/group'
|
||||||
import { User } from 'src/app/data/user'
|
import { User } from 'src/app/data/user'
|
||||||
import { PermissionsService } from 'src/app/services/permissions.service'
|
|
||||||
import { GroupService } from 'src/app/services/rest/group.service'
|
import { GroupService } from 'src/app/services/rest/group.service'
|
||||||
import { UserService } from 'src/app/services/rest/user.service'
|
import { UserService } from 'src/app/services/rest/user.service'
|
||||||
import { SettingsService } from 'src/app/services/settings.service'
|
import { SettingsService } from 'src/app/services/settings.service'
|
||||||
@@ -39,7 +38,6 @@ export class UserEditDialogComponent
|
|||||||
implements OnInit
|
implements OnInit
|
||||||
{
|
{
|
||||||
private toastService = inject(ToastService)
|
private toastService = inject(ToastService)
|
||||||
private permissionsService = inject(PermissionsService)
|
|
||||||
private groupsService: GroupService
|
private groupsService: GroupService
|
||||||
|
|
||||||
groups: Group[]
|
groups: Group[]
|
||||||
|
|||||||
+53
@@ -631,6 +631,59 @@ describe('FilterableDropdownComponent & FilterableDropdownSelectionModel', () =>
|
|||||||
])
|
])
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('deselecting a parent clears selected descendants', () => {
|
||||||
|
const root: Tag = { id: 100, name: 'Root Tag' }
|
||||||
|
const child: Tag = { id: 101, name: 'Child Tag', parent: root.id }
|
||||||
|
const grandchild: Tag = {
|
||||||
|
id: 102,
|
||||||
|
name: 'Grandchild Tag',
|
||||||
|
parent: child.id,
|
||||||
|
}
|
||||||
|
const other: Tag = { id: 103, name: 'Other Tag' }
|
||||||
|
|
||||||
|
selectionModel.items = [root, child, grandchild, other]
|
||||||
|
selectionModel.set(root.id, ToggleableItemState.Selected, false)
|
||||||
|
selectionModel.set(child.id, ToggleableItemState.Selected, false)
|
||||||
|
selectionModel.set(grandchild.id, ToggleableItemState.Selected, false)
|
||||||
|
selectionModel.set(other.id, ToggleableItemState.Selected, false)
|
||||||
|
|
||||||
|
selectionModel.toggle(root.id, false)
|
||||||
|
|
||||||
|
expect(selectionModel.getSelectedItems()).toEqual([other])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('un-excluding a parent clears excluded descendants', () => {
|
||||||
|
const root: Tag = { id: 110, name: 'Root Tag' }
|
||||||
|
const child: Tag = { id: 111, name: 'Child Tag', parent: root.id }
|
||||||
|
const other: Tag = { id: 112, name: 'Other Tag' }
|
||||||
|
|
||||||
|
selectionModel.items = [root, child, other]
|
||||||
|
selectionModel.set(root.id, ToggleableItemState.Excluded, false)
|
||||||
|
selectionModel.set(child.id, ToggleableItemState.Excluded, false)
|
||||||
|
selectionModel.set(other.id, ToggleableItemState.Excluded, false)
|
||||||
|
|
||||||
|
selectionModel.exclude(root.id, false)
|
||||||
|
|
||||||
|
expect(selectionModel.getExcludedItems()).toEqual([other])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('excluding a selected parent clears selected descendants', () => {
|
||||||
|
const root: Tag = { id: 120, name: 'Root Tag' }
|
||||||
|
const child: Tag = { id: 121, name: 'Child Tag', parent: root.id }
|
||||||
|
const other: Tag = { id: 122, name: 'Other Tag' }
|
||||||
|
|
||||||
|
selectionModel.manyToOne = true
|
||||||
|
selectionModel.items = [root, child, other]
|
||||||
|
selectionModel.set(root.id, ToggleableItemState.Selected, false)
|
||||||
|
selectionModel.set(child.id, ToggleableItemState.Selected, false)
|
||||||
|
selectionModel.set(other.id, ToggleableItemState.Selected, false)
|
||||||
|
|
||||||
|
selectionModel.exclude(root.id, false)
|
||||||
|
|
||||||
|
expect(selectionModel.getExcludedItems()).toEqual([root])
|
||||||
|
expect(selectionModel.getSelectedItems()).toEqual([other])
|
||||||
|
})
|
||||||
|
|
||||||
it('resorts items immediately when document count sorting enabled', () => {
|
it('resorts items immediately when document count sorting enabled', () => {
|
||||||
const apple: Tag = { id: 55, name: 'Apple' }
|
const apple: Tag = { id: 55, name: 'Apple' }
|
||||||
const zebra: Tag = { id: 56, name: 'Zebra' }
|
const zebra: Tag = { id: 56, name: 'Zebra' }
|
||||||
|
|||||||
@@ -231,6 +231,7 @@ export class FilterableDropdownSelectionModel {
|
|||||||
state == ToggleableItemState.Excluded
|
state == ToggleableItemState.Excluded
|
||||||
) {
|
) {
|
||||||
this.temporarySelectionStates.delete(id)
|
this.temporarySelectionStates.delete(id)
|
||||||
|
this.clearDescendantSelections(id)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!id) {
|
if (!id) {
|
||||||
@@ -257,6 +258,7 @@ export class FilterableDropdownSelectionModel {
|
|||||||
|
|
||||||
if (this.manyToOne || this.singleSelect) {
|
if (this.manyToOne || this.singleSelect) {
|
||||||
this.temporarySelectionStates.set(id, ToggleableItemState.Excluded)
|
this.temporarySelectionStates.set(id, ToggleableItemState.Excluded)
|
||||||
|
this.clearDescendantSelections(id)
|
||||||
|
|
||||||
if (this.singleSelect) {
|
if (this.singleSelect) {
|
||||||
for (let key of this.temporarySelectionStates.keys()) {
|
for (let key of this.temporarySelectionStates.keys()) {
|
||||||
@@ -277,9 +279,15 @@ export class FilterableDropdownSelectionModel {
|
|||||||
newState = ToggleableItemState.NotSelected
|
newState = ToggleableItemState.NotSelected
|
||||||
}
|
}
|
||||||
this.temporarySelectionStates.set(id, newState)
|
this.temporarySelectionStates.set(id, newState)
|
||||||
|
if (newState == ToggleableItemState.Excluded) {
|
||||||
|
this.clearDescendantSelections(id)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} else if (!id || state == ToggleableItemState.Excluded) {
|
} else if (!id || state == ToggleableItemState.Excluded) {
|
||||||
this.temporarySelectionStates.delete(id)
|
this.temporarySelectionStates.delete(id)
|
||||||
|
if (id) {
|
||||||
|
this.clearDescendantSelections(id)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (fireEvent) {
|
if (fireEvent) {
|
||||||
@@ -291,6 +299,33 @@ export class FilterableDropdownSelectionModel {
|
|||||||
return this.selectionStates.get(id) || ToggleableItemState.NotSelected
|
return this.selectionStates.get(id) || ToggleableItemState.NotSelected
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private clearDescendantSelections(id: number) {
|
||||||
|
for (const descendantID of this.getDescendantIDs(id)) {
|
||||||
|
this.temporarySelectionStates.delete(descendantID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private getDescendantIDs(id: number): number[] {
|
||||||
|
const descendants: number[] = []
|
||||||
|
const queue: number[] = [id]
|
||||||
|
|
||||||
|
while (queue.length) {
|
||||||
|
const parentID = queue.shift()
|
||||||
|
for (const item of this._items) {
|
||||||
|
if (
|
||||||
|
typeof item?.id === 'number' &&
|
||||||
|
typeof (item as any)['parent'] === 'number' &&
|
||||||
|
(item as any)['parent'] === parentID
|
||||||
|
) {
|
||||||
|
descendants.push(item.id)
|
||||||
|
queue.push(item.id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return descendants
|
||||||
|
}
|
||||||
|
|
||||||
get logicalOperator(): LogicalOperator {
|
get logicalOperator(): LogicalOperator {
|
||||||
return this.temporaryLogicalOperator
|
return this.temporaryLogicalOperator
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -205,6 +205,20 @@ describe('TagsComponent', () => {
|
|||||||
expect(component.value).toEqual([2, 1])
|
expect(component.value).toEqual([2, 1])
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('should not duplicate parents when adding sibling nested tags', () => {
|
||||||
|
const root: Tag = { id: 1, name: 'root' }
|
||||||
|
const parent: Tag = { id: 2, name: 'parent', parent: 1 }
|
||||||
|
const leafA: Tag = { id: 3, name: 'leaf-a', parent: 2 }
|
||||||
|
const leafB: Tag = { id: 4, name: 'leaf-b', parent: 2 }
|
||||||
|
component.tags = [root, parent, leafA, leafB]
|
||||||
|
|
||||||
|
component.value = []
|
||||||
|
component.addTag(3)
|
||||||
|
component.addTag(4)
|
||||||
|
|
||||||
|
expect(component.value).toEqual([3, 2, 1, 4])
|
||||||
|
})
|
||||||
|
|
||||||
it('should return ancestors from root to parent using getParentChain', () => {
|
it('should return ancestors from root to parent using getParentChain', () => {
|
||||||
const root: Tag = { id: 1, name: 'root' }
|
const root: Tag = { id: 1, name: 'root' }
|
||||||
const mid: Tag = { id: 2, name: 'mid', parent: 1 }
|
const mid: Tag = { id: 2, name: 'mid', parent: 1 }
|
||||||
|
|||||||
@@ -153,11 +153,13 @@ export class TagsComponent implements OnInit, ControlValueAccessor {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public onAdd(tag: Tag) {
|
public onAdd(tag: Tag) {
|
||||||
if (tag.parent) {
|
if (tag?.parent) {
|
||||||
// add all parents recursively
|
// add all parents recursively
|
||||||
const parent = this.getTag(tag.parent)
|
const parent = this.getTag(tag.parent)
|
||||||
this.value = [...this.value, parent.id]
|
if (parent && !this.value.includes(parent.id)) {
|
||||||
this.onAdd(parent)
|
this.value = [...this.value, parent.id]
|
||||||
|
this.onAdd(parent)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -15,7 +15,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
@if (document && displayFields?.includes(DisplayField.TAGS)) {
|
@if (document && displayFields?.includes(DisplayField.TAGS)) {
|
||||||
<div class="tags d-flex flex-column text-end position-absolute me-1 fs-6">
|
<div class="tags d-flex flex-column text-end position-absolute me-1 fs-6" [class.tags-no-wrap]="document.tags.length > 3">
|
||||||
@for (tagID of tagIDs; track tagID) {
|
@for (tagID of tagIDs; track tagID) {
|
||||||
<pngx-tag [tagID]="tagID" (click)="clickTag.emit(tagID);$event.stopPropagation()" [clickable]="true" linkTitle="Toggle tag filter" i18n-linkTitle></pngx-tag>
|
<pngx-tag [tagID]="tagID" (click)="clickTag.emit(tagID);$event.stopPropagation()" [clickable]="true" linkTitle="Toggle tag filter" i18n-linkTitle></pngx-tag>
|
||||||
}
|
}
|
||||||
|
|||||||
+10
@@ -72,4 +72,14 @@ a {
|
|||||||
max-width: 80%;
|
max-width: 80%;
|
||||||
row-gap: .2rem;
|
row-gap: .2rem;
|
||||||
line-height: 1;
|
line-height: 1;
|
||||||
|
|
||||||
|
&.tags-no-wrap {
|
||||||
|
::ng-deep .badge {
|
||||||
|
display: inline-block;
|
||||||
|
max-width: 100%;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
+10
@@ -82,6 +82,16 @@ describe('DocumentCardSmallComponent', () => {
|
|||||||
).toHaveLength(6)
|
).toHaveLength(6)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('should clear hidden tag counter when tag count falls below the limit', () => {
|
||||||
|
expect(component.moreTags).toEqual(3)
|
||||||
|
|
||||||
|
component.document.tags = [1, 2, 3, 4, 5, 6]
|
||||||
|
fixture.detectChanges()
|
||||||
|
|
||||||
|
expect(component.moreTags).toBeNull()
|
||||||
|
expect(fixture.nativeElement.textContent).not.toContain('+ 3')
|
||||||
|
})
|
||||||
|
|
||||||
it('should try to close the preview on mouse leave', () => {
|
it('should try to close the preview on mouse leave', () => {
|
||||||
component.popupPreview = {
|
component.popupPreview = {
|
||||||
close: jest.fn(),
|
close: jest.fn(),
|
||||||
|
|||||||
+1
@@ -126,6 +126,7 @@ export class DocumentCardSmallComponent
|
|||||||
this.moreTags = this.document.tags.length - (limit - 1)
|
this.moreTags = this.document.tags.length - (limit - 1)
|
||||||
return this.document.tags.slice(0, limit - 1)
|
return this.document.tags.slice(0, limit - 1)
|
||||||
} else {
|
} else {
|
||||||
|
this.moreTags = null
|
||||||
return this.document.tags
|
return this.document.tags
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -62,9 +62,9 @@
|
|||||||
|
|
||||||
@if (!loading) {
|
@if (!loading) {
|
||||||
<div class="d-flex mb-2">
|
<div class="d-flex mb-2">
|
||||||
@if (collectionSize > 0) {
|
@if (displayCollectionSize > 0) {
|
||||||
<div>
|
<div>
|
||||||
<ng-container i18n>{collectionSize, plural, =1 {One {{typeName}}} other {{{collectionSize || 0}} total {{typeNamePlural}}}}</ng-container>
|
<ng-container i18n>{displayCollectionSize, plural, =1 {One {{typeName}}} other {{{displayCollectionSize || 0}} total {{typeNamePlural}}}}</ng-container>
|
||||||
@if (selectedObjects.size > 0) {
|
@if (selectedObjects.size > 0) {
|
||||||
({{selectedObjects.size}} selected)
|
({{selectedObjects.size}} selected)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -229,7 +229,7 @@ describe('ManagementListComponent', () => {
|
|||||||
expect(reloadSpy).toHaveBeenCalled()
|
expect(reloadSpy).toHaveBeenCalled()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should use the all list length for collection size when provided', fakeAsync(() => {
|
it('should use API count for pagination and all ids for displayed total', fakeAsync(() => {
|
||||||
jest.spyOn(tagService, 'listFiltered').mockReturnValueOnce(
|
jest.spyOn(tagService, 'listFiltered').mockReturnValueOnce(
|
||||||
of({
|
of({
|
||||||
count: 1,
|
count: 1,
|
||||||
@@ -241,7 +241,8 @@ describe('ManagementListComponent', () => {
|
|||||||
component.reloadData()
|
component.reloadData()
|
||||||
tick(100)
|
tick(100)
|
||||||
|
|
||||||
expect(component.collectionSize).toBe(3)
|
expect(component.collectionSize).toBe(1)
|
||||||
|
expect(component.displayCollectionSize).toBe(3)
|
||||||
}))
|
}))
|
||||||
|
|
||||||
it('should support quick filter for objects', () => {
|
it('should support quick filter for objects', () => {
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ import {
|
|||||||
MatchingModel,
|
MatchingModel,
|
||||||
} from 'src/app/data/matching-model'
|
} from 'src/app/data/matching-model'
|
||||||
import { ObjectWithPermissions } from 'src/app/data/object-with-permissions'
|
import { ObjectWithPermissions } from 'src/app/data/object-with-permissions'
|
||||||
|
import { Results } from 'src/app/data/results'
|
||||||
import {
|
import {
|
||||||
SortableDirective,
|
SortableDirective,
|
||||||
SortEvent,
|
SortEvent,
|
||||||
@@ -88,6 +89,7 @@ export abstract class ManagementListComponent<T extends MatchingModel>
|
|||||||
public page = 1
|
public page = 1
|
||||||
|
|
||||||
public collectionSize = 0
|
public collectionSize = 0
|
||||||
|
public displayCollectionSize = 0
|
||||||
|
|
||||||
public sortField: string
|
public sortField: string
|
||||||
public sortReverse: boolean
|
public sortReverse: boolean
|
||||||
@@ -141,6 +143,14 @@ export abstract class ManagementListComponent<T extends MatchingModel>
|
|||||||
return data
|
return data
|
||||||
}
|
}
|
||||||
|
|
||||||
|
protected getCollectionSize(results: Results<T>): number {
|
||||||
|
return results.all?.length ?? results.count
|
||||||
|
}
|
||||||
|
|
||||||
|
protected getDisplayCollectionSize(results: Results<T>): number {
|
||||||
|
return this.getCollectionSize(results)
|
||||||
|
}
|
||||||
|
|
||||||
getDocumentCount(object: MatchingModel): number {
|
getDocumentCount(object: MatchingModel): number {
|
||||||
return (
|
return (
|
||||||
object.document_count ??
|
object.document_count ??
|
||||||
@@ -171,7 +181,8 @@ export abstract class ManagementListComponent<T extends MatchingModel>
|
|||||||
tap((c) => {
|
tap((c) => {
|
||||||
this.unfilteredData = c.results
|
this.unfilteredData = c.results
|
||||||
this.data = this.filterData(c.results)
|
this.data = this.filterData(c.results)
|
||||||
this.collectionSize = c.all?.length ?? c.count
|
this.collectionSize = this.getCollectionSize(c)
|
||||||
|
this.displayCollectionSize = this.getDisplayCollectionSize(c)
|
||||||
}),
|
}),
|
||||||
delay(100)
|
delay(100)
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import {
|
|||||||
} from '@ng-bootstrap/ng-bootstrap'
|
} from '@ng-bootstrap/ng-bootstrap'
|
||||||
import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons'
|
import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons'
|
||||||
import { FILTER_HAS_TAGS_ALL } from 'src/app/data/filter-rule-type'
|
import { FILTER_HAS_TAGS_ALL } from 'src/app/data/filter-rule-type'
|
||||||
|
import { Results } from 'src/app/data/results'
|
||||||
import { Tag } from 'src/app/data/tag'
|
import { Tag } from 'src/app/data/tag'
|
||||||
import { IfPermissionsDirective } from 'src/app/directives/if-permissions.directive'
|
import { IfPermissionsDirective } from 'src/app/directives/if-permissions.directive'
|
||||||
import { SortableDirective } from 'src/app/directives/sortable.directive'
|
import { SortableDirective } from 'src/app/directives/sortable.directive'
|
||||||
@@ -77,6 +78,16 @@ export class TagListComponent extends ManagementListComponent<Tag> {
|
|||||||
return data.filter((tag) => !tag.parent || !availableIds.has(tag.parent))
|
return data.filter((tag) => !tag.parent || !availableIds.has(tag.parent))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
protected override getCollectionSize(results: Results<Tag>): number {
|
||||||
|
// Tag list pages are requested with is_root=true (when unfiltered), so
|
||||||
|
// pagination must follow root count even though `all` includes descendants
|
||||||
|
return results.count
|
||||||
|
}
|
||||||
|
|
||||||
|
protected override getDisplayCollectionSize(results: Results<Tag>): number {
|
||||||
|
return super.getCollectionSize(results)
|
||||||
|
}
|
||||||
|
|
||||||
protected override getSelectableIDs(tags: Tag[]): number[] {
|
protected override getSelectableIDs(tags: Tag[]): number[] {
|
||||||
const ids: number[] = []
|
const ids: number[] = []
|
||||||
for (const tag of tags.filter(Boolean)) {
|
for (const tag of tags.filter(Boolean)) {
|
||||||
|
|||||||
@@ -0,0 +1,154 @@
|
|||||||
|
import { HttpErrorResponse, HttpRequest } from '@angular/common/http'
|
||||||
|
import { TestBed } from '@angular/core/testing'
|
||||||
|
import { throwError } from 'rxjs'
|
||||||
|
import * as navUtils from '../utils/navigation'
|
||||||
|
import { AuthExpiryInterceptor } from './auth-expiry.interceptor'
|
||||||
|
|
||||||
|
describe('AuthExpiryInterceptor', () => {
|
||||||
|
let interceptor: AuthExpiryInterceptor
|
||||||
|
let dateNowSpy: jest.SpiedFunction<typeof Date.now>
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
TestBed.configureTestingModule({
|
||||||
|
providers: [AuthExpiryInterceptor],
|
||||||
|
})
|
||||||
|
|
||||||
|
interceptor = TestBed.inject(AuthExpiryInterceptor)
|
||||||
|
dateNowSpy = jest.spyOn(Date, 'now').mockReturnValue(1000)
|
||||||
|
})
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
jest.restoreAllMocks()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('reloads when an API request returns 401', () => {
|
||||||
|
const reloadSpy = jest
|
||||||
|
.spyOn(navUtils, 'locationReload')
|
||||||
|
.mockImplementation(() => {})
|
||||||
|
|
||||||
|
interceptor
|
||||||
|
.intercept(new HttpRequest('GET', '/api/documents/'), {
|
||||||
|
handle: (_request) =>
|
||||||
|
throwError(
|
||||||
|
() =>
|
||||||
|
new HttpErrorResponse({
|
||||||
|
status: 401,
|
||||||
|
url: '/api/documents/',
|
||||||
|
})
|
||||||
|
),
|
||||||
|
})
|
||||||
|
.subscribe({
|
||||||
|
error: () => undefined,
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(reloadSpy).toHaveBeenCalledTimes(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does not reload for non-401 errors', () => {
|
||||||
|
const reloadSpy = jest
|
||||||
|
.spyOn(navUtils, 'locationReload')
|
||||||
|
.mockImplementation(() => {})
|
||||||
|
|
||||||
|
interceptor
|
||||||
|
.intercept(new HttpRequest('GET', '/api/documents/'), {
|
||||||
|
handle: (_request) =>
|
||||||
|
throwError(
|
||||||
|
() =>
|
||||||
|
new HttpErrorResponse({
|
||||||
|
status: 500,
|
||||||
|
url: '/api/documents/',
|
||||||
|
})
|
||||||
|
),
|
||||||
|
})
|
||||||
|
.subscribe({
|
||||||
|
error: () => undefined,
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(reloadSpy).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does not reload for non-api 401 responses', () => {
|
||||||
|
const reloadSpy = jest
|
||||||
|
.spyOn(navUtils, 'locationReload')
|
||||||
|
.mockImplementation(() => {})
|
||||||
|
|
||||||
|
interceptor
|
||||||
|
.intercept(new HttpRequest('GET', '/accounts/profile/'), {
|
||||||
|
handle: (_request) =>
|
||||||
|
throwError(
|
||||||
|
() =>
|
||||||
|
new HttpErrorResponse({
|
||||||
|
status: 401,
|
||||||
|
url: '/accounts/profile/',
|
||||||
|
})
|
||||||
|
),
|
||||||
|
})
|
||||||
|
.subscribe({
|
||||||
|
error: () => undefined,
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(reloadSpy).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('reloads only once even with multiple API 401 responses', () => {
|
||||||
|
const reloadSpy = jest
|
||||||
|
.spyOn(navUtils, 'locationReload')
|
||||||
|
.mockImplementation(() => {})
|
||||||
|
|
||||||
|
const request = new HttpRequest('GET', '/api/documents/')
|
||||||
|
const handler = {
|
||||||
|
handle: (_request) =>
|
||||||
|
throwError(
|
||||||
|
() =>
|
||||||
|
new HttpErrorResponse({
|
||||||
|
status: 401,
|
||||||
|
url: '/api/documents/',
|
||||||
|
})
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
|
interceptor.intercept(request, handler).subscribe({
|
||||||
|
error: () => undefined,
|
||||||
|
})
|
||||||
|
interceptor.intercept(request, handler).subscribe({
|
||||||
|
error: () => undefined,
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(reloadSpy).toHaveBeenCalledTimes(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('retries reload after cooldown for repeated API 401 responses', () => {
|
||||||
|
const reloadSpy = jest
|
||||||
|
.spyOn(navUtils, 'locationReload')
|
||||||
|
.mockImplementation(() => {})
|
||||||
|
|
||||||
|
dateNowSpy
|
||||||
|
.mockReturnValueOnce(1000)
|
||||||
|
.mockReturnValueOnce(2500)
|
||||||
|
.mockReturnValueOnce(3501)
|
||||||
|
|
||||||
|
const request = new HttpRequest('GET', '/api/documents/')
|
||||||
|
const handler = {
|
||||||
|
handle: (_request) =>
|
||||||
|
throwError(
|
||||||
|
() =>
|
||||||
|
new HttpErrorResponse({
|
||||||
|
status: 401,
|
||||||
|
url: '/api/documents/',
|
||||||
|
})
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
|
interceptor.intercept(request, handler).subscribe({
|
||||||
|
error: () => undefined,
|
||||||
|
})
|
||||||
|
interceptor.intercept(request, handler).subscribe({
|
||||||
|
error: () => undefined,
|
||||||
|
})
|
||||||
|
interceptor.intercept(request, handler).subscribe({
|
||||||
|
error: () => undefined,
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(reloadSpy).toHaveBeenCalledTimes(2)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
import {
|
||||||
|
HttpErrorResponse,
|
||||||
|
HttpEvent,
|
||||||
|
HttpHandler,
|
||||||
|
HttpInterceptor,
|
||||||
|
HttpRequest,
|
||||||
|
} from '@angular/common/http'
|
||||||
|
import { Injectable } from '@angular/core'
|
||||||
|
import { catchError, Observable, throwError } from 'rxjs'
|
||||||
|
import { locationReload } from '../utils/navigation'
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class AuthExpiryInterceptor implements HttpInterceptor {
|
||||||
|
private lastReloadAttempt = Number.NEGATIVE_INFINITY
|
||||||
|
|
||||||
|
intercept(
|
||||||
|
request: HttpRequest<unknown>,
|
||||||
|
next: HttpHandler
|
||||||
|
): Observable<HttpEvent<unknown>> {
|
||||||
|
return next.handle(request).pipe(
|
||||||
|
catchError((error: unknown) => {
|
||||||
|
if (
|
||||||
|
error instanceof HttpErrorResponse &&
|
||||||
|
error.status === 401 &&
|
||||||
|
request.url.includes('/api/')
|
||||||
|
) {
|
||||||
|
const now = Date.now()
|
||||||
|
if (now - this.lastReloadAttempt >= 2000) {
|
||||||
|
this.lastReloadAttempt = now
|
||||||
|
locationReload()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return throwError(() => error)
|
||||||
|
})
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -6,7 +6,7 @@ export const environment = {
|
|||||||
apiVersion: '9', // match src/paperless/settings.py
|
apiVersion: '9', // match src/paperless/settings.py
|
||||||
appTitle: 'Paperless-ngx',
|
appTitle: 'Paperless-ngx',
|
||||||
tag: 'prod',
|
tag: 'prod',
|
||||||
version: '2.20.8',
|
version: '2.20.14',
|
||||||
webSocketHost: window.location.host,
|
webSocketHost: window.location.host,
|
||||||
webSocketProtocol: window.location.protocol == 'https:' ? 'wss:' : 'ws:',
|
webSocketProtocol: window.location.protocol == 'https:' ? 'wss:' : 'ws:',
|
||||||
webSocketBaseUrl: base_url.pathname + 'ws/',
|
webSocketBaseUrl: base_url.pathname + 'ws/',
|
||||||
|
|||||||
@@ -147,6 +147,7 @@ import { DirtyDocGuard } from './app/guards/dirty-doc.guard'
|
|||||||
import { DirtySavedViewGuard } from './app/guards/dirty-saved-view.guard'
|
import { DirtySavedViewGuard } from './app/guards/dirty-saved-view.guard'
|
||||||
import { PermissionsGuard } from './app/guards/permissions.guard'
|
import { PermissionsGuard } from './app/guards/permissions.guard'
|
||||||
import { ApiVersionInterceptor } from './app/interceptors/api-version.interceptor'
|
import { ApiVersionInterceptor } from './app/interceptors/api-version.interceptor'
|
||||||
|
import { AuthExpiryInterceptor } from './app/interceptors/auth-expiry.interceptor'
|
||||||
import { CsrfInterceptor } from './app/interceptors/csrf.interceptor'
|
import { CsrfInterceptor } from './app/interceptors/csrf.interceptor'
|
||||||
import { DocumentTitlePipe } from './app/pipes/document-title.pipe'
|
import { DocumentTitlePipe } from './app/pipes/document-title.pipe'
|
||||||
import { FilterPipe } from './app/pipes/filter.pipe'
|
import { FilterPipe } from './app/pipes/filter.pipe'
|
||||||
@@ -390,6 +391,11 @@ bootstrapApplication(AppComponent, {
|
|||||||
useClass: ApiVersionInterceptor,
|
useClass: ApiVersionInterceptor,
|
||||||
multi: true,
|
multi: true,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
provide: HTTP_INTERCEPTORS,
|
||||||
|
useClass: AuthExpiryInterceptor,
|
||||||
|
multi: true,
|
||||||
|
},
|
||||||
FilterPipe,
|
FilterPipe,
|
||||||
DocumentTitlePipe,
|
DocumentTitlePipe,
|
||||||
{ provide: NgbDateAdapter, useClass: ISODateAdapter },
|
{ provide: NgbDateAdapter, useClass: ISODateAdapter },
|
||||||
|
|||||||
@@ -149,6 +149,15 @@ $form-check-radio-checked-bg-image-dark: url("data:image/svg+xml,<svg xmlns='htt
|
|||||||
background-color: var(--pngx-body-color-accent);
|
background-color: var(--pngx-body-color-accent);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.list-group-item-action:not(.active):active {
|
||||||
|
--bs-list-group-action-active-color: var(--bs-body-color);
|
||||||
|
--bs-list-group-action-active-bg: var(--pngx-bg-darker);
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-control:hover::file-selector-button {
|
||||||
|
background-color:var(--pngx-bg-dark) !important
|
||||||
|
}
|
||||||
|
|
||||||
.search-container {
|
.search-container {
|
||||||
input, input:focus, i-bs[name="search"] , ::placeholder {
|
input, input:focus, i-bs[name="search"] , ::placeholder {
|
||||||
color: var(--pngx-primary-text-contrast) !important;
|
color: var(--pngx-primary-text-contrast) !important;
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ from documents.classifier import load_classifier
|
|||||||
from documents.data_models import ConsumableDocument
|
from documents.data_models import ConsumableDocument
|
||||||
from documents.data_models import DocumentMetadataOverrides
|
from documents.data_models import DocumentMetadataOverrides
|
||||||
from documents.file_handling import create_source_path_directory
|
from documents.file_handling import create_source_path_directory
|
||||||
|
from documents.file_handling import generate_filename
|
||||||
from documents.file_handling import generate_unique_filename
|
from documents.file_handling import generate_unique_filename
|
||||||
from documents.loggers import LoggingMixin
|
from documents.loggers import LoggingMixin
|
||||||
from documents.models import Correspondent
|
from documents.models import Correspondent
|
||||||
@@ -493,7 +494,19 @@ class ConsumerPlugin(
|
|||||||
# After everything is in the database, copy the files into
|
# After everything is in the database, copy the files into
|
||||||
# place. If this fails, we'll also rollback the transaction.
|
# place. If this fails, we'll also rollback the transaction.
|
||||||
with FileLock(settings.MEDIA_LOCK):
|
with FileLock(settings.MEDIA_LOCK):
|
||||||
document.filename = generate_unique_filename(document)
|
generated_filename = generate_unique_filename(document)
|
||||||
|
if (
|
||||||
|
len(str(generated_filename))
|
||||||
|
> Document.MAX_STORED_FILENAME_LENGTH
|
||||||
|
):
|
||||||
|
self.log.warning(
|
||||||
|
"Generated source filename exceeds db path limit, falling back to default naming",
|
||||||
|
)
|
||||||
|
generated_filename = generate_filename(
|
||||||
|
document,
|
||||||
|
use_format=False,
|
||||||
|
)
|
||||||
|
document.filename = generated_filename
|
||||||
create_source_path_directory(document.source_path)
|
create_source_path_directory(document.source_path)
|
||||||
|
|
||||||
self._write(
|
self._write(
|
||||||
@@ -511,10 +524,23 @@ class ConsumerPlugin(
|
|||||||
)
|
)
|
||||||
|
|
||||||
if archive_path and Path(archive_path).is_file():
|
if archive_path and Path(archive_path).is_file():
|
||||||
document.archive_filename = generate_unique_filename(
|
generated_archive_filename = generate_unique_filename(
|
||||||
document,
|
document,
|
||||||
archive_filename=True,
|
archive_filename=True,
|
||||||
)
|
)
|
||||||
|
if (
|
||||||
|
len(str(generated_archive_filename))
|
||||||
|
> Document.MAX_STORED_FILENAME_LENGTH
|
||||||
|
):
|
||||||
|
self.log.warning(
|
||||||
|
"Generated archive filename exceeds db path limit, falling back to default naming",
|
||||||
|
)
|
||||||
|
generated_archive_filename = generate_filename(
|
||||||
|
document,
|
||||||
|
archive_filename=True,
|
||||||
|
use_format=False,
|
||||||
|
)
|
||||||
|
document.archive_filename = generated_archive_filename
|
||||||
create_source_path_directory(document.archive_path)
|
create_source_path_directory(document.archive_path)
|
||||||
self._write(
|
self._write(
|
||||||
document.storage_type,
|
document.storage_type,
|
||||||
|
|||||||
@@ -128,17 +128,21 @@ def generate_filename(
|
|||||||
counter=0,
|
counter=0,
|
||||||
append_gpg=True,
|
append_gpg=True,
|
||||||
archive_filename=False,
|
archive_filename=False,
|
||||||
|
use_format=True,
|
||||||
) -> Path:
|
) -> Path:
|
||||||
base_path: Path | None = None
|
base_path: Path | None = None
|
||||||
|
|
||||||
# Determine the source of the format string
|
# Determine the source of the format string
|
||||||
if doc.storage_path is not None:
|
if use_format:
|
||||||
filename_format = doc.storage_path.path
|
if doc.storage_path is not None:
|
||||||
elif settings.FILENAME_FORMAT is not None:
|
filename_format = doc.storage_path.path
|
||||||
# Maybe convert old to new style
|
elif settings.FILENAME_FORMAT is not None:
|
||||||
filename_format = convert_format_str_to_template_format(
|
# Maybe convert old to new style
|
||||||
settings.FILENAME_FORMAT,
|
filename_format = convert_format_str_to_template_format(
|
||||||
)
|
settings.FILENAME_FORMAT,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
filename_format = None
|
||||||
else:
|
else:
|
||||||
filename_format = None
|
filename_format = None
|
||||||
|
|
||||||
|
|||||||
@@ -470,7 +470,14 @@ class DelayedFullTextQuery(DelayedQuery):
|
|||||||
try:
|
try:
|
||||||
corrected = self.searcher.correct_query(q, q_str)
|
corrected = self.searcher.correct_query(q, q_str)
|
||||||
if corrected.string != q_str:
|
if corrected.string != q_str:
|
||||||
suggested_correction = corrected.string
|
corrected_results = self.searcher.search(
|
||||||
|
corrected.query,
|
||||||
|
limit=1,
|
||||||
|
filter=MappedDocIdSet(self.filter_queryset, self.searcher.ixreader),
|
||||||
|
scored=False,
|
||||||
|
)
|
||||||
|
if len(corrected_results) > 0:
|
||||||
|
suggested_correction = corrected.string
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.info(
|
logger.info(
|
||||||
"Error while correcting query %s: %s",
|
"Error while correcting query %s: %s",
|
||||||
|
|||||||
@@ -160,6 +160,7 @@ class Document(SoftDeleteModel, ModelWithOwner):
|
|||||||
(STORAGE_TYPE_UNENCRYPTED, _("Unencrypted")),
|
(STORAGE_TYPE_UNENCRYPTED, _("Unencrypted")),
|
||||||
(STORAGE_TYPE_GPG, _("Encrypted with GNU Privacy Guard")),
|
(STORAGE_TYPE_GPG, _("Encrypted with GNU Privacy Guard")),
|
||||||
)
|
)
|
||||||
|
MAX_STORED_FILENAME_LENGTH: Final[int] = 1024
|
||||||
|
|
||||||
correspondent = models.ForeignKey(
|
correspondent = models.ForeignKey(
|
||||||
Correspondent,
|
Correspondent,
|
||||||
@@ -267,7 +268,7 @@ class Document(SoftDeleteModel, ModelWithOwner):
|
|||||||
|
|
||||||
filename = models.FilePathField(
|
filename = models.FilePathField(
|
||||||
_("filename"),
|
_("filename"),
|
||||||
max_length=1024,
|
max_length=MAX_STORED_FILENAME_LENGTH,
|
||||||
editable=False,
|
editable=False,
|
||||||
default=None,
|
default=None,
|
||||||
unique=True,
|
unique=True,
|
||||||
@@ -277,7 +278,7 @@ class Document(SoftDeleteModel, ModelWithOwner):
|
|||||||
|
|
||||||
archive_filename = models.FilePathField(
|
archive_filename = models.FilePathField(
|
||||||
_("archive filename"),
|
_("archive filename"),
|
||||||
max_length=1024,
|
max_length=MAX_STORED_FILENAME_LENGTH,
|
||||||
editable=False,
|
editable=False,
|
||||||
default=None,
|
default=None,
|
||||||
unique=True,
|
unique=True,
|
||||||
@@ -287,7 +288,7 @@ class Document(SoftDeleteModel, ModelWithOwner):
|
|||||||
|
|
||||||
original_filename = models.CharField(
|
original_filename = models.CharField(
|
||||||
_("original filename"),
|
_("original filename"),
|
||||||
max_length=1024,
|
max_length=MAX_STORED_FILENAME_LENGTH,
|
||||||
editable=False,
|
editable=False,
|
||||||
default=None,
|
default=None,
|
||||||
unique=False,
|
unique=False,
|
||||||
|
|||||||
@@ -75,6 +75,7 @@ from documents.parsers import is_mime_type_supported
|
|||||||
from documents.permissions import get_document_count_filter_for_user
|
from documents.permissions import get_document_count_filter_for_user
|
||||||
from documents.permissions import get_groups_with_only_permission
|
from documents.permissions import get_groups_with_only_permission
|
||||||
from documents.permissions import get_objects_for_user_owner_aware
|
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.permissions import set_permissions_for_object
|
||||||
from documents.regex import validate_regex_pattern
|
from documents.regex import validate_regex_pattern
|
||||||
from documents.templating.filepath import validate_filepath_template_and_render
|
from documents.templating.filepath import validate_filepath_template_and_render
|
||||||
@@ -852,6 +853,25 @@ class ReadWriteSerializerMethodField(serializers.SerializerMethodField):
|
|||||||
return {self.field_name: data}
|
return {self.field_name: data}
|
||||||
|
|
||||||
|
|
||||||
|
def validate_documentlink_targets(user, doc_ids):
|
||||||
|
if Document.objects.filter(id__in=doc_ids).count() != len(doc_ids):
|
||||||
|
raise serializers.ValidationError(
|
||||||
|
"Some documents in value don't exist or were specified twice.",
|
||||||
|
)
|
||||||
|
|
||||||
|
if user is None:
|
||||||
|
return
|
||||||
|
|
||||||
|
target_documents = Document.objects.filter(id__in=doc_ids).select_related("owner")
|
||||||
|
if not all(
|
||||||
|
has_perms_owner_aware(user, "change_document", document)
|
||||||
|
for document in target_documents
|
||||||
|
):
|
||||||
|
raise PermissionDenied(
|
||||||
|
_("Insufficient permissions."),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class CustomFieldInstanceSerializer(serializers.ModelSerializer):
|
class CustomFieldInstanceSerializer(serializers.ModelSerializer):
|
||||||
field = serializers.PrimaryKeyRelatedField(queryset=CustomField.objects.all())
|
field = serializers.PrimaryKeyRelatedField(queryset=CustomField.objects.all())
|
||||||
value = ReadWriteSerializerMethodField(allow_null=True)
|
value = ReadWriteSerializerMethodField(allow_null=True)
|
||||||
@@ -942,12 +962,13 @@ class CustomFieldInstanceSerializer(serializers.ModelSerializer):
|
|||||||
"Value must be a list",
|
"Value must be a list",
|
||||||
)
|
)
|
||||||
doc_ids = data["value"]
|
doc_ids = data["value"]
|
||||||
if Document.objects.filter(id__in=doc_ids).count() != len(
|
request = self.context.get("request")
|
||||||
data["value"],
|
validate_documentlink_targets(
|
||||||
):
|
getattr(request, "user", None) if request is not None else None,
|
||||||
raise serializers.ValidationError(
|
doc_ids,
|
||||||
"Some documents in value don't exist or were specified twice.",
|
)
|
||||||
)
|
elif field.data_type == CustomField.FieldDataType.DATE:
|
||||||
|
data["value"] = serializers.DateField().to_internal_value(data["value"])
|
||||||
|
|
||||||
return data
|
return data
|
||||||
|
|
||||||
@@ -1497,6 +1518,19 @@ class BulkEditSerializer(
|
|||||||
f"Some custom fields in {name} don't exist or were specified twice.",
|
f"Some custom fields in {name} don't exist or were specified twice.",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
if isinstance(custom_fields, dict):
|
||||||
|
custom_field_map = CustomField.objects.in_bulk(ids)
|
||||||
|
for raw_field_id, value in custom_fields.items():
|
||||||
|
field = custom_field_map.get(int(raw_field_id))
|
||||||
|
if (
|
||||||
|
field is not None
|
||||||
|
and field.data_type == CustomField.FieldDataType.DOCUMENTLINK
|
||||||
|
and value is not None
|
||||||
|
):
|
||||||
|
if not isinstance(value, list):
|
||||||
|
raise serializers.ValidationError("Value must be a list")
|
||||||
|
validate_documentlink_targets(self.user, value)
|
||||||
|
|
||||||
def validate_method(self, method):
|
def validate_method(self, method):
|
||||||
if method == "set_correspondent":
|
if method == "set_correspondent":
|
||||||
return bulk_edit.set_correspondent
|
return bulk_edit.set_correspondent
|
||||||
@@ -2179,6 +2213,17 @@ class ShareLinkSerializer(OwnedObjectSerializer):
|
|||||||
validated_data["slug"] = get_random_string(50)
|
validated_data["slug"] = get_random_string(50)
|
||||||
return super().create(validated_data)
|
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 BulkEditObjectsSerializer(SerializerWithPerms, SetPermissionsMixin):
|
class BulkEditObjectsSerializer(SerializerWithPerms, SetPermissionsMixin):
|
||||||
objects = serializers.ListField(
|
objects = serializers.ListField(
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import hashlib
|
||||||
import logging
|
import logging
|
||||||
import shutil
|
import shutil
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
@@ -391,6 +392,14 @@ class CannotMoveFilesException(Exception):
|
|||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def _path_matches_checksum(path: Path, checksum: str | None) -> bool:
|
||||||
|
if checksum is None or not path.is_file():
|
||||||
|
return False
|
||||||
|
|
||||||
|
with path.open("rb") as f:
|
||||||
|
return hashlib.md5(f.read()).hexdigest() == checksum
|
||||||
|
|
||||||
|
|
||||||
def _filename_template_uses_custom_fields(doc: Document) -> bool:
|
def _filename_template_uses_custom_fields(doc: Document) -> bool:
|
||||||
template = None
|
template = None
|
||||||
if doc.storage_path is not None:
|
if doc.storage_path is not None:
|
||||||
@@ -460,8 +469,24 @@ def update_filename_and_move_files(
|
|||||||
|
|
||||||
old_filename = instance.filename
|
old_filename = instance.filename
|
||||||
old_source_path = instance.source_path
|
old_source_path = instance.source_path
|
||||||
|
move_original = False
|
||||||
|
original_already_moved = False
|
||||||
|
|
||||||
|
old_archive_filename = instance.archive_filename
|
||||||
|
old_archive_path = instance.archive_path
|
||||||
|
move_archive = False
|
||||||
|
archive_already_moved = False
|
||||||
|
|
||||||
candidate_filename = generate_filename(instance)
|
candidate_filename = generate_filename(instance)
|
||||||
|
if len(str(candidate_filename)) > Document.MAX_STORED_FILENAME_LENGTH:
|
||||||
|
msg = (
|
||||||
|
f"Document {instance!s}: Generated filename exceeds db path "
|
||||||
|
f"limit ({len(str(candidate_filename))} > "
|
||||||
|
f"{Document.MAX_STORED_FILENAME_LENGTH}): {candidate_filename!s}"
|
||||||
|
)
|
||||||
|
logger.warning(msg)
|
||||||
|
raise CannotMoveFilesException(msg)
|
||||||
|
|
||||||
candidate_source_path = (
|
candidate_source_path = (
|
||||||
settings.ORIGINALS_DIR / candidate_filename
|
settings.ORIGINALS_DIR / candidate_filename
|
||||||
).resolve()
|
).resolve()
|
||||||
@@ -471,20 +496,34 @@ def update_filename_and_move_files(
|
|||||||
candidate_source_path.exists()
|
candidate_source_path.exists()
|
||||||
and candidate_source_path != old_source_path
|
and candidate_source_path != old_source_path
|
||||||
):
|
):
|
||||||
# Only fall back to unique search when there is an actual conflict
|
if not old_source_path.is_file() and _path_matches_checksum(
|
||||||
new_filename = generate_unique_filename(instance)
|
candidate_source_path,
|
||||||
|
instance.checksum,
|
||||||
|
):
|
||||||
|
new_filename = candidate_filename
|
||||||
|
original_already_moved = True
|
||||||
|
else:
|
||||||
|
# Only fall back to unique search when there is an actual conflict
|
||||||
|
new_filename = generate_unique_filename(instance)
|
||||||
else:
|
else:
|
||||||
new_filename = candidate_filename
|
new_filename = candidate_filename
|
||||||
|
|
||||||
# Need to convert to string to be able to save it to the db
|
# Need to convert to string to be able to save it to the db
|
||||||
instance.filename = str(new_filename)
|
instance.filename = str(new_filename)
|
||||||
move_original = old_filename != instance.filename
|
move_original = (
|
||||||
|
old_filename != instance.filename and not original_already_moved
|
||||||
old_archive_filename = instance.archive_filename
|
)
|
||||||
old_archive_path = instance.archive_path
|
|
||||||
|
|
||||||
if instance.has_archive_version:
|
if instance.has_archive_version:
|
||||||
archive_candidate = generate_filename(instance, archive_filename=True)
|
archive_candidate = generate_filename(instance, archive_filename=True)
|
||||||
|
if len(str(archive_candidate)) > Document.MAX_STORED_FILENAME_LENGTH:
|
||||||
|
msg = (
|
||||||
|
f"Document {instance!s}: Generated archive filename exceeds "
|
||||||
|
f"db path limit ({len(str(archive_candidate))} > "
|
||||||
|
f"{Document.MAX_STORED_FILENAME_LENGTH}): {archive_candidate!s}"
|
||||||
|
)
|
||||||
|
logger.warning(msg)
|
||||||
|
raise CannotMoveFilesException(msg)
|
||||||
archive_candidate_path = (
|
archive_candidate_path = (
|
||||||
settings.ARCHIVE_DIR / archive_candidate
|
settings.ARCHIVE_DIR / archive_candidate
|
||||||
).resolve()
|
).resolve()
|
||||||
@@ -494,24 +533,38 @@ def update_filename_and_move_files(
|
|||||||
archive_candidate_path.exists()
|
archive_candidate_path.exists()
|
||||||
and archive_candidate_path != old_archive_path
|
and archive_candidate_path != old_archive_path
|
||||||
):
|
):
|
||||||
new_archive_filename = generate_unique_filename(
|
if not old_archive_path.is_file() and _path_matches_checksum(
|
||||||
instance,
|
archive_candidate_path,
|
||||||
archive_filename=True,
|
instance.archive_checksum,
|
||||||
)
|
):
|
||||||
|
new_archive_filename = archive_candidate
|
||||||
|
archive_already_moved = True
|
||||||
|
else:
|
||||||
|
new_archive_filename = generate_unique_filename(
|
||||||
|
instance,
|
||||||
|
archive_filename=True,
|
||||||
|
)
|
||||||
else:
|
else:
|
||||||
new_archive_filename = archive_candidate
|
new_archive_filename = archive_candidate
|
||||||
|
|
||||||
instance.archive_filename = str(new_archive_filename)
|
instance.archive_filename = str(new_archive_filename)
|
||||||
|
|
||||||
move_archive = old_archive_filename != instance.archive_filename
|
move_archive = (
|
||||||
|
old_archive_filename != instance.archive_filename
|
||||||
|
and not archive_already_moved
|
||||||
|
)
|
||||||
else:
|
else:
|
||||||
move_archive = False
|
move_archive = False
|
||||||
|
|
||||||
if not move_original and not move_archive:
|
if not move_original and not move_archive:
|
||||||
# Just update modified. Also, don't save() here to prevent infinite recursion.
|
updates = {"modified": timezone.now()}
|
||||||
Document.objects.filter(pk=instance.pk).update(
|
if old_filename != instance.filename:
|
||||||
modified=timezone.now(),
|
updates["filename"] = instance.filename
|
||||||
)
|
if old_archive_filename != instance.archive_filename:
|
||||||
|
updates["archive_filename"] = instance.archive_filename
|
||||||
|
|
||||||
|
# Don't save() here to prevent infinite recursion.
|
||||||
|
Document.objects.filter(pk=instance.pk).update(**updates)
|
||||||
return
|
return
|
||||||
|
|
||||||
if move_original:
|
if move_original:
|
||||||
@@ -765,7 +818,6 @@ def run_workflows(
|
|||||||
# Refresh this so the matching data is fresh and instance fields are re-freshed
|
# Refresh this so the matching data is fresh and instance fields are re-freshed
|
||||||
# Otherwise, this instance might be behind and overwrite the work another process did
|
# Otherwise, this instance might be behind and overwrite the work another process did
|
||||||
document.refresh_from_db()
|
document.refresh_from_db()
|
||||||
doc_tag_ids = list(document.tags.values_list("pk", flat=True))
|
|
||||||
|
|
||||||
if matching.document_matches_workflow(document, workflow, trigger_type):
|
if matching.document_matches_workflow(document, workflow, trigger_type):
|
||||||
action: WorkflowAction
|
action: WorkflowAction
|
||||||
@@ -783,14 +835,13 @@ def run_workflows(
|
|||||||
apply_assignment_to_document(
|
apply_assignment_to_document(
|
||||||
action,
|
action,
|
||||||
document,
|
document,
|
||||||
doc_tag_ids,
|
|
||||||
logging_group,
|
logging_group,
|
||||||
)
|
)
|
||||||
elif action.type == WorkflowAction.WorkflowActionType.REMOVAL:
|
elif action.type == WorkflowAction.WorkflowActionType.REMOVAL:
|
||||||
if use_overrides and overrides:
|
if use_overrides and overrides:
|
||||||
apply_removal_to_overrides(action, overrides)
|
apply_removal_to_overrides(action, overrides)
|
||||||
else:
|
else:
|
||||||
apply_removal_to_document(action, document, doc_tag_ids)
|
apply_removal_to_document(action, document)
|
||||||
elif action.type == WorkflowAction.WorkflowActionType.EMAIL:
|
elif action.type == WorkflowAction.WorkflowActionType.EMAIL:
|
||||||
context = build_workflow_action_context(document, overrides)
|
context = build_workflow_action_context(document, overrides)
|
||||||
execute_email_action(
|
execute_email_action(
|
||||||
@@ -814,9 +865,25 @@ def run_workflows(
|
|||||||
if not use_overrides:
|
if not use_overrides:
|
||||||
# limit title to 128 characters
|
# limit title to 128 characters
|
||||||
document.title = document.title[:128]
|
document.title = document.title[:128]
|
||||||
# save first before setting tags
|
# Save only the fields that workflow actions can set directly.
|
||||||
document.save()
|
# Deliberately excludes filename and archive_filename — those are
|
||||||
document.tags.set(doc_tag_ids)
|
# managed exclusively by update_filename_and_move_files via the
|
||||||
|
# post_save signal. Writing stale in-memory values here would revert
|
||||||
|
# a concurrent update_filename_and_move_files DB write, leaving the
|
||||||
|
# DB pointing at the old path while the file is already at the new
|
||||||
|
# one (see: https://github.com/paperless-ngx/paperless-ngx/issues/12386).
|
||||||
|
# modified has auto_now=True but is not auto-added when update_fields
|
||||||
|
# is specified, so it must be listed explicitly.
|
||||||
|
document.save(
|
||||||
|
update_fields=[
|
||||||
|
"title",
|
||||||
|
"correspondent",
|
||||||
|
"document_type",
|
||||||
|
"storage_path",
|
||||||
|
"owner",
|
||||||
|
"modified",
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
WorkflowRun.objects.create(
|
WorkflowRun.objects.create(
|
||||||
workflow=workflow,
|
workflow=workflow,
|
||||||
|
|||||||
@@ -79,6 +79,23 @@ class PlaceholderString(str):
|
|||||||
NO_VALUE_PLACEHOLDER = PlaceholderString("-none-")
|
NO_VALUE_PLACEHOLDER = PlaceholderString("-none-")
|
||||||
|
|
||||||
|
|
||||||
|
class MatchingModelContext:
|
||||||
|
"""
|
||||||
|
Safe template context for related objects.
|
||||||
|
|
||||||
|
Keeps legacy behavior where including the object ina template yields the related object's
|
||||||
|
name as a string, while still exposing limited attributes.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, *, id: int, name: str, path: str | None = None):
|
||||||
|
self.id = id
|
||||||
|
self.name = name
|
||||||
|
self.path = path
|
||||||
|
|
||||||
|
def __str__(self) -> str:
|
||||||
|
return self.name
|
||||||
|
|
||||||
|
|
||||||
_template_environment.undefined = _LogStrictUndefined
|
_template_environment.undefined = _LogStrictUndefined
|
||||||
|
|
||||||
_template_environment.filters["get_cf_value"] = get_cf_value
|
_template_environment.filters["get_cf_value"] = get_cf_value
|
||||||
@@ -221,19 +238,26 @@ def get_safe_document_context(
|
|||||||
else None,
|
else None,
|
||||||
"tags": [{"name": tag.name, "id": tag.id} for tag in tags],
|
"tags": [{"name": tag.name, "id": tag.id} for tag in tags],
|
||||||
"correspondent": (
|
"correspondent": (
|
||||||
{"name": document.correspondent.name, "id": document.correspondent.id}
|
MatchingModelContext(
|
||||||
|
name=document.correspondent.name,
|
||||||
|
id=document.correspondent.id,
|
||||||
|
)
|
||||||
if document.correspondent
|
if document.correspondent
|
||||||
else None
|
else None
|
||||||
),
|
),
|
||||||
"document_type": (
|
"document_type": (
|
||||||
{"name": document.document_type.name, "id": document.document_type.id}
|
MatchingModelContext(
|
||||||
|
name=document.document_type.name,
|
||||||
|
id=document.document_type.id,
|
||||||
|
)
|
||||||
if document.document_type
|
if document.document_type
|
||||||
else None
|
else None
|
||||||
),
|
),
|
||||||
"storage_path": {
|
"storage_path": MatchingModelContext(
|
||||||
"path": document.storage_path.path,
|
name=document.storage_path.name,
|
||||||
"id": document.storage_path.id,
|
path=document.storage_path.path,
|
||||||
}
|
id=document.storage_path.id,
|
||||||
|
)
|
||||||
if document.storage_path
|
if document.storage_path
|
||||||
else None,
|
else None,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -262,6 +262,50 @@ class TestBulkEditAPI(DirectoriesMixin, APITestCase):
|
|||||||
self.assertEqual(kwargs["add_custom_fields"], [self.cf1.id])
|
self.assertEqual(kwargs["add_custom_fields"], [self.cf1.id])
|
||||||
self.assertEqual(kwargs["remove_custom_fields"], [self.cf2.id])
|
self.assertEqual(kwargs["remove_custom_fields"], [self.cf2.id])
|
||||||
|
|
||||||
|
@mock.patch("documents.serialisers.bulk_edit.modify_custom_fields")
|
||||||
|
def test_api_modify_custom_fields_documentlink_forbidden_for_unpermitted_target(
|
||||||
|
self,
|
||||||
|
m,
|
||||||
|
):
|
||||||
|
self.setup_mock(m, "modify_custom_fields")
|
||||||
|
user = User.objects.create_user(username="doc-owner")
|
||||||
|
user.user_permissions.add(Permission.objects.get(codename="change_document"))
|
||||||
|
other_user = User.objects.create_user(username="other-user")
|
||||||
|
source_doc = Document.objects.create(
|
||||||
|
checksum="source",
|
||||||
|
title="Source",
|
||||||
|
owner=user,
|
||||||
|
)
|
||||||
|
target_doc = Document.objects.create(
|
||||||
|
checksum="target",
|
||||||
|
title="Target",
|
||||||
|
owner=other_user,
|
||||||
|
)
|
||||||
|
doclink_field = CustomField.objects.create(
|
||||||
|
name="doclink",
|
||||||
|
data_type=CustomField.FieldDataType.DOCUMENTLINK,
|
||||||
|
)
|
||||||
|
|
||||||
|
self.client.force_authenticate(user=user)
|
||||||
|
|
||||||
|
response = self.client.post(
|
||||||
|
"/api/documents/bulk_edit/",
|
||||||
|
json.dumps(
|
||||||
|
{
|
||||||
|
"documents": [source_doc.id],
|
||||||
|
"method": "modify_custom_fields",
|
||||||
|
"parameters": {
|
||||||
|
"add_custom_fields": {doclink_field.id: [target_doc.id]},
|
||||||
|
"remove_custom_fields": [],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
),
|
||||||
|
content_type="application/json",
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN)
|
||||||
|
m.assert_not_called()
|
||||||
|
|
||||||
@mock.patch("documents.serialisers.bulk_edit.modify_custom_fields")
|
@mock.patch("documents.serialisers.bulk_edit.modify_custom_fields")
|
||||||
def test_api_modify_custom_fields_with_values(self, m):
|
def test_api_modify_custom_fields_with_values(self, m):
|
||||||
self.setup_mock(m, "modify_custom_fields")
|
self.setup_mock(m, "modify_custom_fields")
|
||||||
@@ -773,6 +817,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")
|
@mock.patch("documents.serialisers.bulk_edit.set_permissions")
|
||||||
def test_set_permissions(self, m):
|
def test_set_permissions(self, m):
|
||||||
self.setup_mock(m, "set_permissions")
|
self.setup_mock(m, "set_permissions")
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ from unittest.mock import ANY
|
|||||||
from django.contrib.auth.models import Permission
|
from django.contrib.auth.models import Permission
|
||||||
from django.contrib.auth.models import User
|
from django.contrib.auth.models import User
|
||||||
from django.test import override_settings
|
from django.test import override_settings
|
||||||
|
from guardian.shortcuts import assign_perm
|
||||||
from rest_framework import status
|
from rest_framework import status
|
||||||
from rest_framework.test import APITestCase
|
from rest_framework.test import APITestCase
|
||||||
|
|
||||||
@@ -1247,6 +1248,100 @@ class TestCustomFieldsAPI(DirectoriesMixin, APITestCase):
|
|||||||
self.assertEqual(resp.status_code, status.HTTP_200_OK)
|
self.assertEqual(resp.status_code, status.HTTP_200_OK)
|
||||||
self.assertEqual(doc5.custom_fields.first().value, [1])
|
self.assertEqual(doc5.custom_fields.first().value, [1])
|
||||||
|
|
||||||
|
def test_documentlink_patch_requires_change_permission_on_target_documents(self):
|
||||||
|
source_owner = User.objects.create_user(username="source-owner")
|
||||||
|
source_owner.user_permissions.add(
|
||||||
|
Permission.objects.get(codename="change_document"),
|
||||||
|
)
|
||||||
|
other_user = User.objects.create_user(username="other-user")
|
||||||
|
|
||||||
|
source_doc = Document.objects.create(
|
||||||
|
title="Source",
|
||||||
|
checksum="source",
|
||||||
|
mime_type="application/pdf",
|
||||||
|
owner=source_owner,
|
||||||
|
)
|
||||||
|
target_doc = Document.objects.create(
|
||||||
|
title="Target",
|
||||||
|
checksum="target",
|
||||||
|
mime_type="application/pdf",
|
||||||
|
owner=other_user,
|
||||||
|
)
|
||||||
|
custom_field_doclink = CustomField.objects.create(
|
||||||
|
name="Test Custom Field Doc Link",
|
||||||
|
data_type=CustomField.FieldDataType.DOCUMENTLINK,
|
||||||
|
)
|
||||||
|
|
||||||
|
self.client.force_authenticate(user=source_owner)
|
||||||
|
|
||||||
|
resp = self.client.patch(
|
||||||
|
f"/api/documents/{source_doc.id}/",
|
||||||
|
data={
|
||||||
|
"custom_fields": [
|
||||||
|
{
|
||||||
|
"field": custom_field_doclink.id,
|
||||||
|
"value": [target_doc.id],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
format="json",
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(resp.status_code, status.HTTP_403_FORBIDDEN)
|
||||||
|
self.assertEqual(
|
||||||
|
CustomFieldInstance.objects.filter(field=custom_field_doclink).count(),
|
||||||
|
0,
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_documentlink_patch_allowed_with_change_permission_on_target_documents(
|
||||||
|
self,
|
||||||
|
):
|
||||||
|
source_owner = User.objects.create_user(username="source-owner")
|
||||||
|
source_owner.user_permissions.add(
|
||||||
|
Permission.objects.get(codename="change_document"),
|
||||||
|
)
|
||||||
|
other_user = User.objects.create_user(username="other-user")
|
||||||
|
|
||||||
|
source_doc = Document.objects.create(
|
||||||
|
title="Source",
|
||||||
|
checksum="source",
|
||||||
|
mime_type="application/pdf",
|
||||||
|
owner=source_owner,
|
||||||
|
)
|
||||||
|
target_doc = Document.objects.create(
|
||||||
|
title="Target",
|
||||||
|
checksum="target",
|
||||||
|
mime_type="application/pdf",
|
||||||
|
owner=other_user,
|
||||||
|
)
|
||||||
|
custom_field_doclink = CustomField.objects.create(
|
||||||
|
name="Test Custom Field Doc Link",
|
||||||
|
data_type=CustomField.FieldDataType.DOCUMENTLINK,
|
||||||
|
)
|
||||||
|
|
||||||
|
assign_perm("change_document", source_owner, target_doc)
|
||||||
|
self.client.force_authenticate(user=source_owner)
|
||||||
|
|
||||||
|
resp = self.client.patch(
|
||||||
|
f"/api/documents/{source_doc.id}/",
|
||||||
|
data={
|
||||||
|
"custom_fields": [
|
||||||
|
{
|
||||||
|
"field": custom_field_doclink.id,
|
||||||
|
"value": [target_doc.id],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
format="json",
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(resp.status_code, status.HTTP_200_OK)
|
||||||
|
target_doc.refresh_from_db()
|
||||||
|
self.assertEqual(
|
||||||
|
target_doc.custom_fields.get(field=custom_field_doclink).value,
|
||||||
|
[source_doc.id],
|
||||||
|
)
|
||||||
|
|
||||||
def test_custom_field_filters(self):
|
def test_custom_field_filters(self):
|
||||||
custom_field_string = CustomField.objects.create(
|
custom_field_string = CustomField.objects.create(
|
||||||
name="Test Custom Field String",
|
name="Test Custom Field String",
|
||||||
@@ -1330,3 +1425,41 @@ class TestCustomFieldsAPI(DirectoriesMixin, APITestCase):
|
|||||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||||
results = response.data["results"]
|
results = response.data["results"]
|
||||||
self.assertEqual(results[0]["document_count"], 0)
|
self.assertEqual(results[0]["document_count"], 0)
|
||||||
|
|
||||||
|
def test_patch_document_invalid_date_custom_field_returns_validation_error(self):
|
||||||
|
"""
|
||||||
|
GIVEN:
|
||||||
|
- A date custom field
|
||||||
|
- A document
|
||||||
|
WHEN:
|
||||||
|
- Patching the document with a date string in the wrong format
|
||||||
|
THEN:
|
||||||
|
- HTTP 400 is returned instead of an internal server error
|
||||||
|
- No custom field instance is created
|
||||||
|
"""
|
||||||
|
cf_date = CustomField.objects.create(
|
||||||
|
name="datefield",
|
||||||
|
data_type=CustomField.FieldDataType.DATE,
|
||||||
|
)
|
||||||
|
doc = Document.objects.create(
|
||||||
|
title="Doc",
|
||||||
|
checksum="123",
|
||||||
|
mime_type="application/pdf",
|
||||||
|
)
|
||||||
|
|
||||||
|
response = self.client.patch(
|
||||||
|
f"/api/documents/{doc.pk}/",
|
||||||
|
{
|
||||||
|
"custom_fields": [
|
||||||
|
{
|
||||||
|
"field": cf_date.pk,
|
||||||
|
"value": "10.03.2026",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
format="json",
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
|
||||||
|
self.assertIn("custom_fields", response.data)
|
||||||
|
self.assertEqual(CustomFieldInstance.objects.count(), 0)
|
||||||
@@ -1087,6 +1087,43 @@ class TestDocumentApi(DirectoriesMixin, DocumentConsumeDelayMixin, APITestCase):
|
|||||||
self.assertEqual(len(response.data["all"]), 50)
|
self.assertEqual(len(response.data["all"]), 50)
|
||||||
self.assertCountEqual(response.data["all"], [d.id for d in docs])
|
self.assertCountEqual(response.data["all"], [d.id for d in docs])
|
||||||
|
|
||||||
|
def test_default_ordering_uses_id_as_tiebreaker(self):
|
||||||
|
"""
|
||||||
|
GIVEN:
|
||||||
|
- Documents sharing the same created date
|
||||||
|
WHEN:
|
||||||
|
- API request for documents without an explicit ordering
|
||||||
|
THEN:
|
||||||
|
- Results are correctly ordered by created > id
|
||||||
|
"""
|
||||||
|
older_doc = Document.objects.create(
|
||||||
|
checksum="older",
|
||||||
|
content="older",
|
||||||
|
created=date(2024, 1, 1),
|
||||||
|
)
|
||||||
|
first_same_date_doc = Document.objects.create(
|
||||||
|
checksum="same-date-1",
|
||||||
|
content="same-date-1",
|
||||||
|
created=date(2024, 1, 2),
|
||||||
|
)
|
||||||
|
second_same_date_doc = Document.objects.create(
|
||||||
|
checksum="same-date-2",
|
||||||
|
content="same-date-2",
|
||||||
|
created=date(2024, 1, 2),
|
||||||
|
)
|
||||||
|
|
||||||
|
response = self.client.get("/api/documents/")
|
||||||
|
|
||||||
|
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||||
|
self.assertEqual(
|
||||||
|
[result["id"] for result in response.data["results"]],
|
||||||
|
[
|
||||||
|
second_same_date_doc.id,
|
||||||
|
first_same_date_doc.id,
|
||||||
|
older_doc.id,
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
def test_statistics(self):
|
def test_statistics(self):
|
||||||
doc1 = Document.objects.create(
|
doc1 = Document.objects.create(
|
||||||
title="none1",
|
title="none1",
|
||||||
@@ -2905,6 +2942,106 @@ class TestDocumentApi(DirectoriesMixin, DocumentConsumeDelayMixin, APITestCase):
|
|||||||
)
|
)
|
||||||
self.assertEqual(resp.status_code, status.HTTP_200_OK)
|
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_share_link_update_methods_not_allowed(self):
|
||||||
|
"""
|
||||||
|
GIVEN:
|
||||||
|
- An existing share link
|
||||||
|
WHEN:
|
||||||
|
- PUT and PATCH requests are made to its detail endpoint
|
||||||
|
THEN:
|
||||||
|
- The API rejects them with 405 and the link is unchanged
|
||||||
|
"""
|
||||||
|
doc = Document.objects.create(
|
||||||
|
title="test",
|
||||||
|
mime_type="application/pdf",
|
||||||
|
content="share link content",
|
||||||
|
)
|
||||||
|
expiration = timezone.now() + timedelta(days=7)
|
||||||
|
create_resp = self.client.post(
|
||||||
|
"/api/share_links/",
|
||||||
|
data={
|
||||||
|
"document": doc.pk,
|
||||||
|
"expiration": expiration.isoformat(),
|
||||||
|
"file_version": ShareLink.FileVersion.ORIGINAL,
|
||||||
|
},
|
||||||
|
format="json",
|
||||||
|
)
|
||||||
|
self.assertEqual(create_resp.status_code, status.HTTP_201_CREATED)
|
||||||
|
share_link_id = create_resp.data["id"]
|
||||||
|
|
||||||
|
patch_resp = self.client.patch(
|
||||||
|
f"/api/share_links/{share_link_id}/",
|
||||||
|
data={
|
||||||
|
"expiration": None,
|
||||||
|
"file_version": ShareLink.FileVersion.ARCHIVE,
|
||||||
|
},
|
||||||
|
format="json",
|
||||||
|
)
|
||||||
|
self.assertEqual(patch_resp.status_code, status.HTTP_405_METHOD_NOT_ALLOWED)
|
||||||
|
|
||||||
|
put_resp = self.client.put(
|
||||||
|
f"/api/share_links/{share_link_id}/",
|
||||||
|
data={
|
||||||
|
"document": doc.pk,
|
||||||
|
"expiration": None,
|
||||||
|
"file_version": ShareLink.FileVersion.ARCHIVE,
|
||||||
|
},
|
||||||
|
format="json",
|
||||||
|
)
|
||||||
|
self.assertEqual(put_resp.status_code, status.HTTP_405_METHOD_NOT_ALLOWED)
|
||||||
|
|
||||||
|
share_link = ShareLink.objects.get(pk=share_link_id)
|
||||||
|
self.assertEqual(share_link.file_version, ShareLink.FileVersion.ORIGINAL)
|
||||||
|
self.assertIsNotNone(share_link.expiration)
|
||||||
|
|
||||||
def test_next_asn(self):
|
def test_next_asn(self):
|
||||||
"""
|
"""
|
||||||
GIVEN:
|
GIVEN:
|
||||||
|
|||||||
@@ -888,6 +888,19 @@ class TestApiUser(DirectoriesMixin, APITestCase):
|
|||||||
|
|
||||||
self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN)
|
self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN)
|
||||||
|
|
||||||
|
response = self.client.post(
|
||||||
|
f"{self.ENDPOINT}",
|
||||||
|
json.dumps(
|
||||||
|
{
|
||||||
|
"username": "user4",
|
||||||
|
"is_superuser": "true",
|
||||||
|
},
|
||||||
|
),
|
||||||
|
content_type="application/json",
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN)
|
||||||
|
|
||||||
self.client.force_authenticate(user2)
|
self.client.force_authenticate(user2)
|
||||||
|
|
||||||
response = self.client.patch(
|
response = self.client.patch(
|
||||||
@@ -920,6 +933,65 @@ class TestApiUser(DirectoriesMixin, APITestCase):
|
|||||||
returned_user1 = User.objects.get(pk=user1.pk)
|
returned_user1 = User.objects.get(pk=user1.pk)
|
||||||
self.assertEqual(returned_user1.is_superuser, False)
|
self.assertEqual(returned_user1.is_superuser, False)
|
||||||
|
|
||||||
|
def test_only_superusers_can_create_or_alter_staff_status(self):
|
||||||
|
"""
|
||||||
|
GIVEN:
|
||||||
|
- Existing user account
|
||||||
|
WHEN:
|
||||||
|
- API request is made to add a user account with staff status
|
||||||
|
- API request is made to change staff status
|
||||||
|
THEN:
|
||||||
|
- Only superusers can change staff status
|
||||||
|
"""
|
||||||
|
|
||||||
|
user1 = User.objects.create_user(username="user1")
|
||||||
|
user1.user_permissions.add(*Permission.objects.all())
|
||||||
|
user2 = User.objects.create_superuser(username="user2")
|
||||||
|
|
||||||
|
self.client.force_authenticate(user1)
|
||||||
|
|
||||||
|
response = self.client.patch(
|
||||||
|
f"{self.ENDPOINT}{user1.pk}/",
|
||||||
|
json.dumps(
|
||||||
|
{
|
||||||
|
"is_staff": "true",
|
||||||
|
},
|
||||||
|
),
|
||||||
|
content_type="application/json",
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN)
|
||||||
|
|
||||||
|
response = self.client.post(
|
||||||
|
f"{self.ENDPOINT}",
|
||||||
|
json.dumps(
|
||||||
|
{
|
||||||
|
"username": "user3",
|
||||||
|
"is_staff": 1,
|
||||||
|
},
|
||||||
|
),
|
||||||
|
content_type="application/json",
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN)
|
||||||
|
|
||||||
|
self.client.force_authenticate(user2)
|
||||||
|
|
||||||
|
response = self.client.patch(
|
||||||
|
f"{self.ENDPOINT}{user1.pk}/",
|
||||||
|
json.dumps(
|
||||||
|
{
|
||||||
|
"is_staff": True,
|
||||||
|
},
|
||||||
|
),
|
||||||
|
content_type="application/json",
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||||
|
|
||||||
|
returned_user1 = User.objects.get(pk=user1.pk)
|
||||||
|
self.assertEqual(returned_user1.is_staff, True)
|
||||||
|
|
||||||
|
|
||||||
class TestApiGroup(DirectoriesMixin, APITestCase):
|
class TestApiGroup(DirectoriesMixin, APITestCase):
|
||||||
ENDPOINT = "/api/groups/"
|
ENDPOINT = "/api/groups/"
|
||||||
|
|||||||
@@ -702,6 +702,40 @@ class TestDocumentSearchApi(DirectoriesMixin, APITestCase):
|
|||||||
|
|
||||||
self.assertEqual(correction, None)
|
self.assertEqual(correction, None)
|
||||||
|
|
||||||
|
def test_search_spelling_suggestion_suppressed_for_private_terms(self):
|
||||||
|
owner = User.objects.create_user("owner")
|
||||||
|
attacker = User.objects.create_user("attacker")
|
||||||
|
attacker.user_permissions.add(
|
||||||
|
Permission.objects.get(codename="view_document"),
|
||||||
|
)
|
||||||
|
|
||||||
|
with AsyncWriter(index.open_index()) as writer:
|
||||||
|
for i in range(55):
|
||||||
|
private_doc = Document.objects.create(
|
||||||
|
checksum=f"p{i}",
|
||||||
|
pk=100 + i,
|
||||||
|
title=f"Private Document {i + 1}",
|
||||||
|
content=f"treasury document {i + 1}",
|
||||||
|
owner=owner,
|
||||||
|
)
|
||||||
|
visible_doc = Document.objects.create(
|
||||||
|
checksum=f"v{i}",
|
||||||
|
pk=200 + i,
|
||||||
|
title=f"Visible Document {i + 1}",
|
||||||
|
content=f"public ledger {i + 1}",
|
||||||
|
owner=attacker,
|
||||||
|
)
|
||||||
|
index.update_document(writer, private_doc)
|
||||||
|
index.update_document(writer, visible_doc)
|
||||||
|
|
||||||
|
self.client.force_authenticate(user=attacker)
|
||||||
|
|
||||||
|
response = self.client.get("/api/documents/?query=treasurx")
|
||||||
|
|
||||||
|
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||||
|
self.assertEqual(response.data["count"], 0)
|
||||||
|
self.assertIsNone(response.data["corrected_query"])
|
||||||
|
|
||||||
@mock.patch(
|
@mock.patch(
|
||||||
"whoosh.searching.Searcher.correct_query",
|
"whoosh.searching.Searcher.correct_query",
|
||||||
side_effect=Exception("Test error"),
|
side_effect=Exception("Test error"),
|
||||||
@@ -772,6 +806,58 @@ class TestDocumentSearchApi(DirectoriesMixin, APITestCase):
|
|||||||
self.assertEqual(results[0]["id"], d3.id)
|
self.assertEqual(results[0]["id"], d3.id)
|
||||||
self.assertEqual(results[1]["id"], d1.id)
|
self.assertEqual(results[1]["id"], d1.id)
|
||||||
|
|
||||||
|
def test_search_more_like_requires_view_permission_on_seed_document(self):
|
||||||
|
"""
|
||||||
|
GIVEN:
|
||||||
|
- A user can search documents they own
|
||||||
|
- Another user's private document exists with similar content
|
||||||
|
WHEN:
|
||||||
|
- The user requests more-like-this for the private seed document
|
||||||
|
THEN:
|
||||||
|
- The request is rejected
|
||||||
|
"""
|
||||||
|
owner = User.objects.create_user("owner")
|
||||||
|
attacker = User.objects.create_user("attacker")
|
||||||
|
attacker.user_permissions.add(
|
||||||
|
Permission.objects.get(codename="view_document"),
|
||||||
|
)
|
||||||
|
|
||||||
|
private_seed = Document.objects.create(
|
||||||
|
title="private bank statement",
|
||||||
|
content="quarterly treasury bank statement wire transfer",
|
||||||
|
checksum="seed",
|
||||||
|
owner=owner,
|
||||||
|
pk=10,
|
||||||
|
)
|
||||||
|
visible_doc = Document.objects.create(
|
||||||
|
title="attacker-visible match",
|
||||||
|
content="quarterly treasury bank statement wire transfer summary",
|
||||||
|
checksum="visible",
|
||||||
|
owner=attacker,
|
||||||
|
pk=11,
|
||||||
|
)
|
||||||
|
other_doc = Document.objects.create(
|
||||||
|
title="unrelated",
|
||||||
|
content="completely different topic",
|
||||||
|
checksum="other",
|
||||||
|
owner=attacker,
|
||||||
|
pk=12,
|
||||||
|
)
|
||||||
|
|
||||||
|
with AsyncWriter(index.open_index()) as writer:
|
||||||
|
index.update_document(writer, private_seed)
|
||||||
|
index.update_document(writer, visible_doc)
|
||||||
|
index.update_document(writer, other_doc)
|
||||||
|
|
||||||
|
self.client.force_authenticate(user=attacker)
|
||||||
|
|
||||||
|
response = self.client.get(
|
||||||
|
f"/api/documents/?more_like_id={private_seed.id}",
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN)
|
||||||
|
self.assertEqual(response.content, b"Insufficient permissions.")
|
||||||
|
|
||||||
def test_search_filtering(self):
|
def test_search_filtering(self):
|
||||||
t = Tag.objects.create(name="tag")
|
t = Tag.objects.create(name="tag")
|
||||||
t2 = Tag.objects.create(name="tag2")
|
t2 = Tag.objects.create(name="tag2")
|
||||||
@@ -1357,6 +1443,83 @@ class TestDocumentSearchApi(DirectoriesMixin, APITestCase):
|
|||||||
self.assertEqual(results["custom_fields"][0]["id"], custom_field1.id)
|
self.assertEqual(results["custom_fields"][0]["id"], custom_field1.id)
|
||||||
self.assertEqual(results["workflows"][0]["id"], workflow1.id)
|
self.assertEqual(results["workflows"][0]["id"], workflow1.id)
|
||||||
|
|
||||||
|
def test_global_search_filters_owned_mail_objects(self):
|
||||||
|
user1 = User.objects.create_user("mail-search-user")
|
||||||
|
user2 = User.objects.create_user("other-mail-search-user")
|
||||||
|
user1.user_permissions.add(
|
||||||
|
Permission.objects.get(codename="view_mailaccount"),
|
||||||
|
Permission.objects.get(codename="view_mailrule"),
|
||||||
|
)
|
||||||
|
|
||||||
|
own_account = MailAccount.objects.create(
|
||||||
|
name="bank owned account",
|
||||||
|
username="owner@example.com",
|
||||||
|
password="secret",
|
||||||
|
imap_server="imap.owner.example.com",
|
||||||
|
imap_port=993,
|
||||||
|
imap_security=MailAccount.ImapSecurity.SSL,
|
||||||
|
character_set="UTF-8",
|
||||||
|
owner=user1,
|
||||||
|
)
|
||||||
|
other_account = MailAccount.objects.create(
|
||||||
|
name="bank other account",
|
||||||
|
username="other@example.com",
|
||||||
|
password="secret",
|
||||||
|
imap_server="imap.other.example.com",
|
||||||
|
imap_port=993,
|
||||||
|
imap_security=MailAccount.ImapSecurity.SSL,
|
||||||
|
character_set="UTF-8",
|
||||||
|
owner=user2,
|
||||||
|
)
|
||||||
|
unowned_account = MailAccount.objects.create(
|
||||||
|
name="bank shared account",
|
||||||
|
username="shared@example.com",
|
||||||
|
password="secret",
|
||||||
|
imap_server="imap.shared.example.com",
|
||||||
|
imap_port=993,
|
||||||
|
imap_security=MailAccount.ImapSecurity.SSL,
|
||||||
|
character_set="UTF-8",
|
||||||
|
)
|
||||||
|
own_rule = MailRule.objects.create(
|
||||||
|
name="bank owned rule",
|
||||||
|
account=own_account,
|
||||||
|
action=MailRule.MailAction.MOVE,
|
||||||
|
owner=user1,
|
||||||
|
)
|
||||||
|
other_rule = MailRule.objects.create(
|
||||||
|
name="bank other rule",
|
||||||
|
account=other_account,
|
||||||
|
action=MailRule.MailAction.MOVE,
|
||||||
|
owner=user2,
|
||||||
|
)
|
||||||
|
unowned_rule = MailRule.objects.create(
|
||||||
|
name="bank shared rule",
|
||||||
|
account=unowned_account,
|
||||||
|
action=MailRule.MailAction.MOVE,
|
||||||
|
)
|
||||||
|
|
||||||
|
self.client.force_authenticate(user1)
|
||||||
|
|
||||||
|
response = self.client.get("/api/search/?query=bank")
|
||||||
|
|
||||||
|
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||||
|
self.assertCountEqual(
|
||||||
|
[account["id"] for account in response.data["mail_accounts"]],
|
||||||
|
[own_account.id, unowned_account.id],
|
||||||
|
)
|
||||||
|
self.assertCountEqual(
|
||||||
|
[rule["id"] for rule in response.data["mail_rules"]],
|
||||||
|
[own_rule.id, unowned_rule.id],
|
||||||
|
)
|
||||||
|
self.assertNotIn(
|
||||||
|
other_account.id,
|
||||||
|
[account["id"] for account in response.data["mail_accounts"]],
|
||||||
|
)
|
||||||
|
self.assertNotIn(
|
||||||
|
other_rule.id,
|
||||||
|
[rule["id"] for rule in response.data["mail_rules"]],
|
||||||
|
)
|
||||||
|
|
||||||
def test_global_search_bad_request(self):
|
def test_global_search_bad_request(self):
|
||||||
"""
|
"""
|
||||||
WHEN:
|
WHEN:
|
||||||
|
|||||||
@@ -57,11 +57,18 @@ class TestSystemStatus(APITestCase):
|
|||||||
"""
|
"""
|
||||||
response = self.client.get(self.ENDPOINT)
|
response = self.client.get(self.ENDPOINT)
|
||||||
self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED)
|
self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED)
|
||||||
|
self.assertEqual(response["WWW-Authenticate"], "Token")
|
||||||
normal_user = User.objects.create_user(username="normal_user")
|
normal_user = User.objects.create_user(username="normal_user")
|
||||||
self.client.force_login(normal_user)
|
self.client.force_login(normal_user)
|
||||||
response = self.client.get(self.ENDPOINT)
|
response = self.client.get(self.ENDPOINT)
|
||||||
self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN)
|
self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN)
|
||||||
|
|
||||||
|
def test_system_status_with_bad_basic_auth_challenges(self) -> None:
|
||||||
|
self.client.credentials(HTTP_AUTHORIZATION="Basic invalid")
|
||||||
|
response = self.client.get(self.ENDPOINT)
|
||||||
|
self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED)
|
||||||
|
self.assertEqual(response["WWW-Authenticate"], 'Basic realm="api"')
|
||||||
|
|
||||||
def test_system_status_container_detection(self):
|
def test_system_status_container_detection(self):
|
||||||
"""
|
"""
|
||||||
GIVEN:
|
GIVEN:
|
||||||
|
|||||||
@@ -633,6 +633,33 @@ class TestConsumer(
|
|||||||
|
|
||||||
self._assert_first_last_send_progress()
|
self._assert_first_last_send_progress()
|
||||||
|
|
||||||
|
@mock.patch("documents.consumer.generate_unique_filename")
|
||||||
|
def testFilenameHandlingFallsBackWhenGeneratedPathExceedsDbLimit(self, m):
|
||||||
|
m.side_effect = lambda doc, archive_filename=False: Path(
|
||||||
|
("a" * 1100 + ".pdf") if not archive_filename else ("b" * 1100 + ".pdf"),
|
||||||
|
)
|
||||||
|
|
||||||
|
with self.get_consumer(
|
||||||
|
self.get_test_file(),
|
||||||
|
DocumentMetadataOverrides(title="new docs"),
|
||||||
|
) as consumer:
|
||||||
|
consumer.run()
|
||||||
|
|
||||||
|
document = Document.objects.first()
|
||||||
|
self.assertIsNotNone(document)
|
||||||
|
assert document is not None
|
||||||
|
|
||||||
|
self.assertEqual(document.filename, f"{document.pk:07d}.pdf")
|
||||||
|
self.assertLessEqual(len(document.filename), 1024)
|
||||||
|
self.assertLessEqual(
|
||||||
|
len(document.archive_filename),
|
||||||
|
1024,
|
||||||
|
)
|
||||||
|
self.assertIsFile(document.source_path)
|
||||||
|
self.assertIsFile(document.archive_path)
|
||||||
|
|
||||||
|
self._assert_first_last_send_progress()
|
||||||
|
|
||||||
@override_settings(FILENAME_FORMAT="{correspondent}/{title}")
|
@override_settings(FILENAME_FORMAT="{correspondent}/{title}")
|
||||||
@mock.patch("documents.signals.handlers.generate_unique_filename")
|
@mock.patch("documents.signals.handlers.generate_unique_filename")
|
||||||
def testFilenameHandlingUnstableFormat(self, m):
|
def testFilenameHandlingUnstableFormat(self, m):
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import datetime
|
import datetime
|
||||||
|
import hashlib
|
||||||
import logging
|
import logging
|
||||||
import tempfile
|
import tempfile
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
@@ -166,6 +167,52 @@ class TestFileHandling(DirectoriesMixin, FileSystemAssertsMixin, TestCase):
|
|||||||
)
|
)
|
||||||
self.assertEqual(document.filename, "none/none.pdf")
|
self.assertEqual(document.filename, "none/none.pdf")
|
||||||
|
|
||||||
|
@override_settings(FILENAME_FORMAT=None)
|
||||||
|
def test_stale_save_recovers_already_moved_files(self) -> None:
|
||||||
|
old_storage_path = StoragePath.objects.create(
|
||||||
|
name="old-path",
|
||||||
|
path="old/{{title}}",
|
||||||
|
)
|
||||||
|
new_storage_path = StoragePath.objects.create(
|
||||||
|
name="new-path",
|
||||||
|
path="new/{{title}}",
|
||||||
|
)
|
||||||
|
original_bytes = b"original"
|
||||||
|
archive_bytes = b"archive"
|
||||||
|
|
||||||
|
doc = Document.objects.create(
|
||||||
|
title="document",
|
||||||
|
mime_type="application/pdf",
|
||||||
|
checksum=hashlib.md5(original_bytes).hexdigest(),
|
||||||
|
archive_checksum=hashlib.md5(archive_bytes).hexdigest(),
|
||||||
|
filename="old/document.pdf",
|
||||||
|
archive_filename="old/document.pdf",
|
||||||
|
storage_path=old_storage_path,
|
||||||
|
)
|
||||||
|
create_source_path_directory(doc.source_path)
|
||||||
|
doc.source_path.write_bytes(original_bytes)
|
||||||
|
create_source_path_directory(doc.archive_path)
|
||||||
|
doc.archive_path.write_bytes(archive_bytes)
|
||||||
|
|
||||||
|
stale_doc = Document.objects.get(pk=doc.pk)
|
||||||
|
fresh_doc = Document.objects.get(pk=doc.pk)
|
||||||
|
fresh_doc.storage_path = new_storage_path
|
||||||
|
fresh_doc.save()
|
||||||
|
doc.refresh_from_db()
|
||||||
|
self.assertEqual(doc.filename, "new/document.pdf")
|
||||||
|
self.assertEqual(doc.archive_filename, "new/document.pdf")
|
||||||
|
|
||||||
|
stale_doc.storage_path = new_storage_path
|
||||||
|
stale_doc.save()
|
||||||
|
|
||||||
|
doc.refresh_from_db()
|
||||||
|
self.assertEqual(doc.filename, "new/document.pdf")
|
||||||
|
self.assertEqual(doc.archive_filename, "new/document.pdf")
|
||||||
|
self.assertIsFile(doc.source_path)
|
||||||
|
self.assertIsFile(doc.archive_path)
|
||||||
|
self.assertIsNotFile(settings.ORIGINALS_DIR / "old" / "document.pdf")
|
||||||
|
self.assertIsNotFile(settings.ARCHIVE_DIR / "old" / "document.pdf")
|
||||||
|
|
||||||
@override_settings(FILENAME_FORMAT="{correspondent}/{correspondent}")
|
@override_settings(FILENAME_FORMAT="{correspondent}/{correspondent}")
|
||||||
def test_document_delete(self):
|
def test_document_delete(self):
|
||||||
document = Document()
|
document = Document()
|
||||||
@@ -1341,6 +1388,41 @@ class TestFilenameGeneration(DirectoriesMixin, TestCase):
|
|||||||
Path("somepath/asn-201-400/asn-3xx/Does Matter.pdf"),
|
Path("somepath/asn-201-400/asn-3xx/Does Matter.pdf"),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def test_template_related_context_keeps_legacy_string_coercion(self):
|
||||||
|
"""
|
||||||
|
GIVEN:
|
||||||
|
- A storage path template that uses related objects directly as strings
|
||||||
|
WHEN:
|
||||||
|
- Filepath for a document with this format is called
|
||||||
|
THEN:
|
||||||
|
- Related objects coerce to their names (legacy behavior)
|
||||||
|
- Explicit attribute access remains available for new templates
|
||||||
|
"""
|
||||||
|
sp = StoragePath.objects.create(
|
||||||
|
name="PARTNER",
|
||||||
|
path=(
|
||||||
|
"{{ document.storage_path|lower }} / "
|
||||||
|
"{{ document.correspondent|lower|replace('mi:', 'mieter/') }} / "
|
||||||
|
"{{ document_type|lower }} / "
|
||||||
|
"{{ title|lower }}"
|
||||||
|
),
|
||||||
|
)
|
||||||
|
doc = Document.objects.create(
|
||||||
|
title="scan_017562",
|
||||||
|
created=datetime.date(2025, 7, 2),
|
||||||
|
added=timezone.make_aware(datetime.datetime(2026, 3, 3, 11, 53, 16)),
|
||||||
|
mime_type="application/pdf",
|
||||||
|
checksum="test-checksum",
|
||||||
|
storage_path=sp,
|
||||||
|
correspondent=Correspondent.objects.create(name="mi:kochkach"),
|
||||||
|
document_type=DocumentType.objects.create(name="Mietvertrag"),
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(
|
||||||
|
generate_filename(doc),
|
||||||
|
Path("partner/mieter/kochkach/mietvertrag/scan_017562.pdf"),
|
||||||
|
)
|
||||||
|
|
||||||
@override_settings(
|
@override_settings(
|
||||||
FILENAME_FORMAT="{{creation_date}}/{{ title_name_str }}",
|
FILENAME_FORMAT="{{creation_date}}/{{ title_name_str }}",
|
||||||
)
|
)
|
||||||
@@ -1699,6 +1781,21 @@ class TestCustomFieldFilenameUpdates(
|
|||||||
self.assertTrue(Path(self.doc.source_path).is_file())
|
self.assertTrue(Path(self.doc.source_path).is_file())
|
||||||
self.assertLessEqual(m.call_count, 1)
|
self.assertLessEqual(m.call_count, 1)
|
||||||
|
|
||||||
|
@override_settings(FILENAME_FORMAT=None)
|
||||||
|
def test_overlong_storage_path_keeps_existing_filename(self):
|
||||||
|
initial_filename = generate_filename(self.doc)
|
||||||
|
Document.objects.filter(pk=self.doc.pk).update(filename=str(initial_filename))
|
||||||
|
self.doc.refresh_from_db()
|
||||||
|
Path(self.doc.source_path).parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
Path(self.doc.source_path).touch()
|
||||||
|
|
||||||
|
self.doc.storage_path = StoragePath.objects.create(path="a" * 1100)
|
||||||
|
self.doc.save()
|
||||||
|
|
||||||
|
self.doc.refresh_from_db()
|
||||||
|
self.assertEqual(Path(self.doc.filename), initial_filename)
|
||||||
|
self.assertTrue(Path(self.doc.source_path).is_file())
|
||||||
|
|
||||||
|
|
||||||
class TestPathDateLocalization:
|
class TestPathDateLocalization:
|
||||||
"""
|
"""
|
||||||
|
|||||||
@@ -147,6 +147,16 @@ class TestTagHierarchy(APITestCase):
|
|||||||
assert serializer.data # triggers serialization
|
assert serializer.data # triggers serialization
|
||||||
assert "document_count_filter" in context
|
assert "document_count_filter" in context
|
||||||
|
|
||||||
|
def test_tag_list_can_order_by_document_count_with_children(self) -> None:
|
||||||
|
self.document.tags.add(self.child)
|
||||||
|
|
||||||
|
response = self.client.get(
|
||||||
|
"/api/tags/",
|
||||||
|
{"ordering": "document_count"},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
|
||||||
def test_cannot_set_parent_to_self(self):
|
def test_cannot_set_parent_to_self(self):
|
||||||
tag = Tag.objects.create(name="Selfie")
|
tag = Tag.objects.create(name="Selfie")
|
||||||
resp = self.client.patch(
|
resp = self.client.patch(
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ from rest_framework.test import APIClient
|
|||||||
from rest_framework.test import APITestCase
|
from rest_framework.test import APITestCase
|
||||||
|
|
||||||
from documents.file_handling import create_source_path_directory
|
from documents.file_handling import create_source_path_directory
|
||||||
|
from documents.file_handling import generate_filename
|
||||||
from documents.file_handling import generate_unique_filename
|
from documents.file_handling import generate_unique_filename
|
||||||
from documents.signals.handlers import run_workflows
|
from documents.signals.handlers import run_workflows
|
||||||
from documents.workflows.webhooks import send_webhook
|
from documents.workflows.webhooks import send_webhook
|
||||||
@@ -898,6 +899,121 @@ class TestWorkflows(
|
|||||||
expected_str = f"Document matched {trigger} from {w}"
|
expected_str = f"Document matched {trigger} from {w}"
|
||||||
self.assertIn(expected_str, cm.output[0])
|
self.assertIn(expected_str, cm.output[0])
|
||||||
|
|
||||||
|
def test_workflow_assign_custom_field_keeps_storage_filename_in_sync(self) -> None:
|
||||||
|
"""
|
||||||
|
GIVEN:
|
||||||
|
- Existing document with a storage path template that depends on a custom field
|
||||||
|
- Existing workflow triggered on document update assigning that custom field
|
||||||
|
WHEN:
|
||||||
|
- Workflow runs for the document
|
||||||
|
THEN:
|
||||||
|
- The database filename remains aligned with the moved file on disk
|
||||||
|
"""
|
||||||
|
storage_path = StoragePath.objects.create(
|
||||||
|
name="workflow-custom-field-path",
|
||||||
|
path="{{ custom_fields|get_cf_value('Custom Field 1', 'none') }}/{{ title }}",
|
||||||
|
)
|
||||||
|
doc = Document.objects.create(
|
||||||
|
title="workflow custom field sync",
|
||||||
|
mime_type="application/pdf",
|
||||||
|
checksum="workflow-custom-field-sync",
|
||||||
|
storage_path=storage_path,
|
||||||
|
original_filename="workflow-custom-field-sync.pdf",
|
||||||
|
)
|
||||||
|
CustomFieldInstance.objects.create(
|
||||||
|
document=doc,
|
||||||
|
field=self.cf1,
|
||||||
|
value_text="initial",
|
||||||
|
)
|
||||||
|
|
||||||
|
generated = generate_unique_filename(doc)
|
||||||
|
destination = (settings.ORIGINALS_DIR / generated).resolve()
|
||||||
|
create_source_path_directory(destination)
|
||||||
|
shutil.copy(self.SAMPLE_DIR / "simple.pdf", destination)
|
||||||
|
Document.objects.filter(pk=doc.pk).update(filename=generated.as_posix())
|
||||||
|
doc.refresh_from_db()
|
||||||
|
|
||||||
|
trigger = WorkflowTrigger.objects.create(
|
||||||
|
type=WorkflowTrigger.WorkflowTriggerType.DOCUMENT_UPDATED,
|
||||||
|
)
|
||||||
|
action = WorkflowAction.objects.create(
|
||||||
|
type=WorkflowAction.WorkflowActionType.ASSIGNMENT,
|
||||||
|
assign_custom_fields_values={self.cf1.pk: "cars"},
|
||||||
|
)
|
||||||
|
action.assign_custom_fields.add(self.cf1.pk)
|
||||||
|
workflow = Workflow.objects.create(
|
||||||
|
name="Workflow custom field filename sync",
|
||||||
|
order=0,
|
||||||
|
)
|
||||||
|
workflow.triggers.add(trigger)
|
||||||
|
workflow.actions.add(action)
|
||||||
|
workflow.save()
|
||||||
|
|
||||||
|
run_workflows(WorkflowTrigger.WorkflowTriggerType.DOCUMENT_UPDATED, doc)
|
||||||
|
|
||||||
|
doc.refresh_from_db()
|
||||||
|
expected_filename = generate_filename(doc)
|
||||||
|
self.assertEqual(Path(doc.filename), expected_filename)
|
||||||
|
self.assertTrue(doc.source_path.is_file())
|
||||||
|
|
||||||
|
def test_workflow_document_updated_does_not_overwrite_filename(self) -> None:
|
||||||
|
"""
|
||||||
|
GIVEN:
|
||||||
|
- A document whose filename has been updated in the DB by a concurrent
|
||||||
|
bulk_update_documents task (simulating update_filename_and_move_files
|
||||||
|
completing and writing the new filename to the DB)
|
||||||
|
- A stale in-memory document instance still holding the old filename
|
||||||
|
- An active DOCUMENT_UPDATED workflow
|
||||||
|
WHEN:
|
||||||
|
- run_workflows is called with the stale in-memory instance
|
||||||
|
(as would happen in the second concurrent bulk_update_documents task)
|
||||||
|
THEN:
|
||||||
|
- The DB filename is NOT overwritten with the stale in-memory value
|
||||||
|
(regression test for GH #12386 — the race window between
|
||||||
|
refresh_from_db and document.save in run_workflows)
|
||||||
|
"""
|
||||||
|
trigger = WorkflowTrigger.objects.create(
|
||||||
|
type=WorkflowTrigger.WorkflowTriggerType.DOCUMENT_UPDATED,
|
||||||
|
)
|
||||||
|
action = WorkflowAction.objects.create(
|
||||||
|
type=WorkflowAction.WorkflowActionType.ASSIGNMENT,
|
||||||
|
assign_title="Updated by workflow",
|
||||||
|
)
|
||||||
|
workflow = Workflow.objects.create(name="Race condition test workflow", order=0)
|
||||||
|
workflow.triggers.add(trigger)
|
||||||
|
workflow.actions.add(action)
|
||||||
|
workflow.save()
|
||||||
|
|
||||||
|
doc = Document.objects.create(
|
||||||
|
title="race condition test",
|
||||||
|
mime_type="application/pdf",
|
||||||
|
checksum="racecondition123",
|
||||||
|
original_filename="old.pdf",
|
||||||
|
filename="old/path/old.pdf",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Simulate BUD-1 completing update_filename_and_move_files:
|
||||||
|
# the DB now holds the new filename while BUD-2's in-memory instance is stale.
|
||||||
|
new_filename = "new/path/new.pdf"
|
||||||
|
Document.global_objects.filter(pk=doc.pk).update(filename=new_filename)
|
||||||
|
|
||||||
|
# The stale instance still has filename="old/path/old.pdf" in memory.
|
||||||
|
# Mock refresh_from_db so the stale value persists through run_workflows,
|
||||||
|
# replicating the race window between refresh and save.
|
||||||
|
# Mock update_filename_and_move_files to prevent file-not-found errors
|
||||||
|
# since we are only testing DB state here.
|
||||||
|
with (
|
||||||
|
mock.patch(
|
||||||
|
"documents.signals.handlers.update_filename_and_move_files",
|
||||||
|
),
|
||||||
|
mock.patch.object(Document, "refresh_from_db"),
|
||||||
|
):
|
||||||
|
run_workflows(WorkflowTrigger.WorkflowTriggerType.DOCUMENT_UPDATED, doc)
|
||||||
|
|
||||||
|
# The DB filename must not have been reverted to the stale old value.
|
||||||
|
doc.refresh_from_db()
|
||||||
|
self.assertEqual(doc.filename, new_filename)
|
||||||
|
|
||||||
def test_document_added_workflow(self):
|
def test_document_added_workflow(self):
|
||||||
trigger = WorkflowTrigger.objects.create(
|
trigger = WorkflowTrigger.objects.create(
|
||||||
type=WorkflowTrigger.WorkflowTriggerType.DOCUMENT_ADDED,
|
type=WorkflowTrigger.WorkflowTriggerType.DOCUMENT_ADDED,
|
||||||
@@ -3396,6 +3512,124 @@ class TestWorkflows(
|
|||||||
as_json=False,
|
as_json=False,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@mock.patch("documents.signals.handlers.execute_webhook_action")
|
||||||
|
def test_workflow_webhook_action_does_not_overwrite_concurrent_tags(
|
||||||
|
self,
|
||||||
|
mock_execute_webhook_action,
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
GIVEN:
|
||||||
|
- A document updated workflow with only a webhook action
|
||||||
|
- A tag update that happens after run_workflows
|
||||||
|
WHEN:
|
||||||
|
- The workflow runs
|
||||||
|
THEN:
|
||||||
|
- The concurrent tag update is preserved
|
||||||
|
"""
|
||||||
|
trigger = WorkflowTrigger.objects.create(
|
||||||
|
type=WorkflowTrigger.WorkflowTriggerType.DOCUMENT_UPDATED,
|
||||||
|
)
|
||||||
|
webhook_action = WorkflowActionWebhook.objects.create(
|
||||||
|
use_params=False,
|
||||||
|
body="Test message: {{doc_url}}",
|
||||||
|
url="http://paperless-ngx.com",
|
||||||
|
include_document=False,
|
||||||
|
)
|
||||||
|
action = WorkflowAction.objects.create(
|
||||||
|
type=WorkflowAction.WorkflowActionType.WEBHOOK,
|
||||||
|
webhook=webhook_action,
|
||||||
|
)
|
||||||
|
w = Workflow.objects.create(
|
||||||
|
name="Webhook workflow",
|
||||||
|
order=0,
|
||||||
|
)
|
||||||
|
w.triggers.add(trigger)
|
||||||
|
w.actions.add(action)
|
||||||
|
w.save()
|
||||||
|
|
||||||
|
inbox_tag = Tag.objects.create(name="inbox")
|
||||||
|
error_tag = Tag.objects.create(name="error")
|
||||||
|
doc = Document.objects.create(
|
||||||
|
title="sample test",
|
||||||
|
correspondent=self.c,
|
||||||
|
original_filename="sample.pdf",
|
||||||
|
)
|
||||||
|
doc.tags.add(inbox_tag)
|
||||||
|
|
||||||
|
def add_error_tag(*args, **kwargs):
|
||||||
|
Document.objects.get(pk=doc.pk).tags.add(error_tag)
|
||||||
|
|
||||||
|
mock_execute_webhook_action.side_effect = add_error_tag
|
||||||
|
|
||||||
|
run_workflows(WorkflowTrigger.WorkflowTriggerType.DOCUMENT_UPDATED, doc)
|
||||||
|
|
||||||
|
doc.refresh_from_db()
|
||||||
|
self.assertCountEqual(doc.tags.all(), [inbox_tag, error_tag])
|
||||||
|
|
||||||
|
@mock.patch("documents.signals.handlers.execute_webhook_action")
|
||||||
|
def test_workflow_tag_actions_do_not_overwrite_concurrent_tags(
|
||||||
|
self,
|
||||||
|
mock_execute_webhook_action,
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
GIVEN:
|
||||||
|
- A document updated workflow that clears tags and assigns an inbox tag
|
||||||
|
- A later tag update that happens before the workflow finishes
|
||||||
|
WHEN:
|
||||||
|
- The workflow runs
|
||||||
|
THEN:
|
||||||
|
- The later tag update is preserved
|
||||||
|
"""
|
||||||
|
trigger = WorkflowTrigger.objects.create(
|
||||||
|
type=WorkflowTrigger.WorkflowTriggerType.DOCUMENT_UPDATED,
|
||||||
|
)
|
||||||
|
removal_action = WorkflowAction.objects.create(
|
||||||
|
type=WorkflowAction.WorkflowActionType.REMOVAL,
|
||||||
|
remove_all_tags=True,
|
||||||
|
)
|
||||||
|
assign_action = WorkflowAction.objects.create(
|
||||||
|
assign_owner=self.user2,
|
||||||
|
)
|
||||||
|
assign_action.assign_tags.add(self.t1)
|
||||||
|
webhook_action = WorkflowActionWebhook.objects.create(
|
||||||
|
use_params=False,
|
||||||
|
body="Test message: {{doc_url}}",
|
||||||
|
url="http://paperless-ngx.com",
|
||||||
|
include_document=False,
|
||||||
|
)
|
||||||
|
notify_action = WorkflowAction.objects.create(
|
||||||
|
type=WorkflowAction.WorkflowActionType.WEBHOOK,
|
||||||
|
webhook=webhook_action,
|
||||||
|
)
|
||||||
|
w = Workflow.objects.create(
|
||||||
|
name="Workflow tag race",
|
||||||
|
order=0,
|
||||||
|
)
|
||||||
|
w.triggers.add(trigger)
|
||||||
|
w.actions.add(removal_action)
|
||||||
|
w.actions.add(assign_action)
|
||||||
|
w.actions.add(notify_action)
|
||||||
|
w.save()
|
||||||
|
|
||||||
|
doc = Document.objects.create(
|
||||||
|
title="sample test",
|
||||||
|
correspondent=self.c,
|
||||||
|
original_filename="sample.pdf",
|
||||||
|
owner=self.user3,
|
||||||
|
)
|
||||||
|
doc.tags.add(self.t2, self.t3)
|
||||||
|
|
||||||
|
def add_error_tag(*args, **kwargs):
|
||||||
|
Document.objects.get(pk=doc.pk).tags.add(self.t2)
|
||||||
|
|
||||||
|
mock_execute_webhook_action.side_effect = add_error_tag
|
||||||
|
|
||||||
|
run_workflows(WorkflowTrigger.WorkflowTriggerType.DOCUMENT_UPDATED, doc)
|
||||||
|
|
||||||
|
doc.refresh_from_db()
|
||||||
|
self.assertEqual(doc.owner, self.user2)
|
||||||
|
self.assertCountEqual(doc.tags.all(), [self.t1, self.t2])
|
||||||
|
|
||||||
@override_settings(
|
@override_settings(
|
||||||
PAPERLESS_URL="http://localhost:8000",
|
PAPERLESS_URL="http://localhost:8000",
|
||||||
)
|
)
|
||||||
|
|||||||
+55
-10
@@ -49,6 +49,7 @@ from django.utils import timezone
|
|||||||
from django.utils.decorators import method_decorator
|
from django.utils.decorators import method_decorator
|
||||||
from django.utils.timezone import make_aware
|
from django.utils.timezone import make_aware
|
||||||
from django.utils.translation import get_language
|
from django.utils.translation import get_language
|
||||||
|
from django.utils.translation import gettext_lazy as _
|
||||||
from django.views import View
|
from django.views import View
|
||||||
from django.views.decorators.cache import cache_control
|
from django.views.decorators.cache import cache_control
|
||||||
from django.views.decorators.http import condition
|
from django.views.decorators.http import condition
|
||||||
@@ -70,10 +71,12 @@ from rest_framework import parsers
|
|||||||
from rest_framework import serializers
|
from rest_framework import serializers
|
||||||
from rest_framework.decorators import action
|
from rest_framework.decorators import action
|
||||||
from rest_framework.exceptions import NotFound
|
from rest_framework.exceptions import NotFound
|
||||||
|
from rest_framework.exceptions import PermissionDenied
|
||||||
from rest_framework.exceptions import ValidationError
|
from rest_framework.exceptions import ValidationError
|
||||||
from rest_framework.filters import OrderingFilter
|
from rest_framework.filters import OrderingFilter
|
||||||
from rest_framework.filters import SearchFilter
|
from rest_framework.filters import SearchFilter
|
||||||
from rest_framework.generics import GenericAPIView
|
from rest_framework.generics import GenericAPIView
|
||||||
|
from rest_framework.mixins import CreateModelMixin
|
||||||
from rest_framework.mixins import DestroyModelMixin
|
from rest_framework.mixins import DestroyModelMixin
|
||||||
from rest_framework.mixins import ListModelMixin
|
from rest_framework.mixins import ListModelMixin
|
||||||
from rest_framework.mixins import RetrieveModelMixin
|
from rest_framework.mixins import RetrieveModelMixin
|
||||||
@@ -487,13 +490,13 @@ class TagViewSet(PermissionsAwareDocumentCountMixin, ModelViewSet):
|
|||||||
user = getattr(getattr(self, "request", None), "user", None)
|
user = getattr(getattr(self, "request", None), "user", None)
|
||||||
children_source = list(
|
children_source = list(
|
||||||
annotate_document_count_for_related_queryset(
|
annotate_document_count_for_related_queryset(
|
||||||
Tag.objects.filter(pk__in=descendant_pks | {t.pk for t in all_tags})
|
Tag.objects.filter(
|
||||||
.select_related("owner")
|
pk__in=descendant_pks | {t.pk for t in all_tags},
|
||||||
.order_by(*ordering),
|
).select_related("owner"),
|
||||||
through_model=self.document_count_through,
|
through_model=self.document_count_through,
|
||||||
related_object_field=self.document_count_source_field,
|
related_object_field=self.document_count_source_field,
|
||||||
user=user,
|
user=user,
|
||||||
),
|
).order_by(*ordering),
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
children_source = all_tags
|
children_source = all_tags
|
||||||
@@ -783,7 +786,7 @@ class DocumentViewSet(
|
|||||||
def get_queryset(self):
|
def get_queryset(self):
|
||||||
return (
|
return (
|
||||||
Document.objects.distinct()
|
Document.objects.distinct()
|
||||||
.order_by("-created")
|
.order_by("-created", "-id")
|
||||||
.annotate(num_notes=Count("notes"))
|
.annotate(num_notes=Count("notes"))
|
||||||
.select_related("correspondent", "storage_path", "document_type", "owner")
|
.select_related("correspondent", "storage_path", "document_type", "owner")
|
||||||
.prefetch_related("tags", "custom_fields", "notes")
|
.prefetch_related("tags", "custom_fields", "notes")
|
||||||
@@ -1038,6 +1041,7 @@ class DocumentViewSet(
|
|||||||
methods=["get", "post", "delete"],
|
methods=["get", "post", "delete"],
|
||||||
detail=True,
|
detail=True,
|
||||||
permission_classes=[PaperlessNotePermissions],
|
permission_classes=[PaperlessNotePermissions],
|
||||||
|
pagination_class=None,
|
||||||
filter_backends=[],
|
filter_backends=[],
|
||||||
)
|
)
|
||||||
def notes(self, request, pk=None):
|
def notes(self, request, pk=None):
|
||||||
@@ -1368,11 +1372,28 @@ class UnifiedSearchViewSet(DocumentViewSet):
|
|||||||
filtered_queryset = super().filter_queryset(queryset)
|
filtered_queryset = super().filter_queryset(queryset)
|
||||||
|
|
||||||
if self._is_search_request():
|
if self._is_search_request():
|
||||||
from documents import index
|
|
||||||
|
|
||||||
if "query" in self.request.query_params:
|
if "query" in self.request.query_params:
|
||||||
|
from documents import index
|
||||||
|
|
||||||
query_class = index.DelayedFullTextQuery
|
query_class = index.DelayedFullTextQuery
|
||||||
elif "more_like_id" in self.request.query_params:
|
elif "more_like_id" in self.request.query_params:
|
||||||
|
try:
|
||||||
|
more_like_doc_id = int(self.request.query_params["more_like_id"])
|
||||||
|
more_like_doc = Document.objects.select_related("owner").get(
|
||||||
|
pk=more_like_doc_id,
|
||||||
|
)
|
||||||
|
except (TypeError, ValueError, Document.DoesNotExist):
|
||||||
|
raise PermissionDenied(_("Invalid more_like_id"))
|
||||||
|
|
||||||
|
if not has_perms_owner_aware(
|
||||||
|
self.request.user,
|
||||||
|
"view_document",
|
||||||
|
more_like_doc,
|
||||||
|
):
|
||||||
|
raise PermissionDenied(_("Insufficient permissions."))
|
||||||
|
|
||||||
|
from documents import index
|
||||||
|
|
||||||
query_class = index.DelayedMoreLikeThisQuery
|
query_class = index.DelayedMoreLikeThisQuery
|
||||||
else:
|
else:
|
||||||
raise ValueError
|
raise ValueError
|
||||||
@@ -1408,6 +1429,8 @@ class UnifiedSearchViewSet(DocumentViewSet):
|
|||||||
return response
|
return response
|
||||||
except NotFound:
|
except NotFound:
|
||||||
raise
|
raise
|
||||||
|
except PermissionDenied as e:
|
||||||
|
return HttpResponseForbidden(str(e.detail))
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning(f"An error occurred listing search results: {e!s}")
|
logger.warning(f"An error occurred listing search results: {e!s}")
|
||||||
return HttpResponseBadRequest(
|
return HttpResponseBadRequest(
|
||||||
@@ -1850,6 +1873,13 @@ class SelectionDataView(GenericAPIView):
|
|||||||
serializer.is_valid(raise_exception=True)
|
serializer.is_valid(raise_exception=True)
|
||||||
|
|
||||||
ids = serializer.validated_data.get("documents")
|
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(
|
correspondents = Correspondent.objects.annotate(
|
||||||
document_count=Count(
|
document_count=Count(
|
||||||
@@ -2104,13 +2134,21 @@ class GlobalSearchView(PassUserMixin):
|
|||||||
)
|
)
|
||||||
groups = groups[:OBJECT_LIMIT]
|
groups = groups[:OBJECT_LIMIT]
|
||||||
mail_rules = (
|
mail_rules = (
|
||||||
MailRule.objects.filter(name__icontains=query)
|
get_objects_for_user_owner_aware(
|
||||||
|
request.user,
|
||||||
|
"view_mailrule",
|
||||||
|
MailRule,
|
||||||
|
).filter(name__icontains=query)
|
||||||
if request.user.has_perm("paperless_mail.view_mailrule")
|
if request.user.has_perm("paperless_mail.view_mailrule")
|
||||||
else []
|
else []
|
||||||
)
|
)
|
||||||
mail_rules = mail_rules[:OBJECT_LIMIT]
|
mail_rules = mail_rules[:OBJECT_LIMIT]
|
||||||
mail_accounts = (
|
mail_accounts = (
|
||||||
MailAccount.objects.filter(name__icontains=query)
|
get_objects_for_user_owner_aware(
|
||||||
|
request.user,
|
||||||
|
"view_mailaccount",
|
||||||
|
MailAccount,
|
||||||
|
).filter(name__icontains=query)
|
||||||
if request.user.has_perm("paperless_mail.view_mailaccount")
|
if request.user.has_perm("paperless_mail.view_mailaccount")
|
||||||
else []
|
else []
|
||||||
)
|
)
|
||||||
@@ -2665,7 +2703,14 @@ class TasksViewSet(ReadOnlyModelViewSet):
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
class ShareLinkViewSet(ModelViewSet, PassUserMixin):
|
class ShareLinkViewSet(
|
||||||
|
PassUserMixin,
|
||||||
|
CreateModelMixin,
|
||||||
|
RetrieveModelMixin,
|
||||||
|
DestroyModelMixin,
|
||||||
|
ListModelMixin,
|
||||||
|
GenericViewSet,
|
||||||
|
):
|
||||||
model = ShareLink
|
model = ShareLink
|
||||||
|
|
||||||
queryset = ShareLink.objects.all()
|
queryset = ShareLink.objects.all()
|
||||||
|
|||||||
@@ -16,7 +16,6 @@ logger = logging.getLogger("paperless.workflows.mutations")
|
|||||||
def apply_assignment_to_document(
|
def apply_assignment_to_document(
|
||||||
action: WorkflowAction,
|
action: WorkflowAction,
|
||||||
document: Document,
|
document: Document,
|
||||||
doc_tag_ids: list[int],
|
|
||||||
logging_group,
|
logging_group,
|
||||||
):
|
):
|
||||||
"""
|
"""
|
||||||
@@ -25,12 +24,7 @@ def apply_assignment_to_document(
|
|||||||
action: WorkflowAction, annotated with 'has_assign_*' boolean fields
|
action: WorkflowAction, annotated with 'has_assign_*' boolean fields
|
||||||
"""
|
"""
|
||||||
if action.has_assign_tags:
|
if action.has_assign_tags:
|
||||||
tag_ids_to_add: set[int] = set()
|
document.add_nested_tags(action.assign_tags.all())
|
||||||
for tag in action.assign_tags.all():
|
|
||||||
tag_ids_to_add.add(tag.pk)
|
|
||||||
tag_ids_to_add.update(int(pk) for pk in tag.get_ancestors_pks())
|
|
||||||
|
|
||||||
doc_tag_ids[:] = list(set(doc_tag_ids) | tag_ids_to_add)
|
|
||||||
|
|
||||||
if action.assign_correspondent:
|
if action.assign_correspondent:
|
||||||
document.correspondent = action.assign_correspondent
|
document.correspondent = action.assign_correspondent
|
||||||
@@ -197,7 +191,6 @@ def apply_assignment_to_overrides(
|
|||||||
def apply_removal_to_document(
|
def apply_removal_to_document(
|
||||||
action: WorkflowAction,
|
action: WorkflowAction,
|
||||||
document: Document,
|
document: Document,
|
||||||
doc_tag_ids: list[int],
|
|
||||||
):
|
):
|
||||||
"""
|
"""
|
||||||
Apply removal actions to a Document instance.
|
Apply removal actions to a Document instance.
|
||||||
@@ -206,14 +199,15 @@ def apply_removal_to_document(
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
if action.remove_all_tags:
|
if action.remove_all_tags:
|
||||||
doc_tag_ids.clear()
|
document.tags.clear()
|
||||||
else:
|
else:
|
||||||
tag_ids_to_remove: set[int] = set()
|
tag_ids_to_remove: set[int] = set()
|
||||||
for tag in action.remove_tags.all():
|
for tag in action.remove_tags.all():
|
||||||
tag_ids_to_remove.add(tag.pk)
|
tag_ids_to_remove.add(tag.pk)
|
||||||
tag_ids_to_remove.update(int(pk) for pk in tag.get_descendants_pks())
|
tag_ids_to_remove.update(int(pk) for pk in tag.get_descendants_pks())
|
||||||
|
|
||||||
doc_tag_ids[:] = [t for t in doc_tag_ids if t not in tag_ids_to_remove]
|
if tag_ids_to_remove:
|
||||||
|
document.tags.remove(*tag_ids_to_remove)
|
||||||
|
|
||||||
if action.remove_all_correspondents or (
|
if action.remove_all_correspondents or (
|
||||||
document.correspondent
|
document.correspondent
|
||||||
|
|||||||
@@ -83,3 +83,11 @@ class PaperlessBasicAuthentication(authentication.BasicAuthentication):
|
|||||||
raise exceptions.AuthenticationFailed("MFA required")
|
raise exceptions.AuthenticationFailed("MFA required")
|
||||||
|
|
||||||
return user_tuple
|
return user_tuple
|
||||||
|
|
||||||
|
def authenticate_header(self, request):
|
||||||
|
auth_header = request.META.get("HTTP_AUTHORIZATION", "")
|
||||||
|
if auth_header.lower().startswith("basic "):
|
||||||
|
return super().authenticate_header(request)
|
||||||
|
|
||||||
|
# Still 401 for anonymous API access
|
||||||
|
return authentication.TokenAuthentication.keyword
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
from typing import Final
|
from typing import Final
|
||||||
|
|
||||||
__version__: Final[tuple[int, int, int]] = (2, 20, 8)
|
__version__: Final[tuple[int, int, int]] = (2, 20, 14)
|
||||||
# Version string like X.Y.Z
|
# Version string like X.Y.Z
|
||||||
__full_version_str__: Final[str] = ".".join(map(str, __version__))
|
__full_version_str__: Final[str] = ".".join(map(str, __version__))
|
||||||
# Version string like X.Y
|
# Version string like X.Y
|
||||||
|
|||||||
+47
-6
@@ -25,6 +25,8 @@ from drf_spectacular.utils import extend_schema_view
|
|||||||
from rest_framework.authtoken.models import Token
|
from rest_framework.authtoken.models import Token
|
||||||
from rest_framework.authtoken.views import ObtainAuthToken
|
from rest_framework.authtoken.views import ObtainAuthToken
|
||||||
from rest_framework.decorators import action
|
from rest_framework.decorators import action
|
||||||
|
from rest_framework.exceptions import ValidationError
|
||||||
|
from rest_framework.fields import BooleanField
|
||||||
from rest_framework.filters import OrderingFilter
|
from rest_framework.filters import OrderingFilter
|
||||||
from rest_framework.generics import GenericAPIView
|
from rest_framework.generics import GenericAPIView
|
||||||
from rest_framework.pagination import PageNumberPagination
|
from rest_framework.pagination import PageNumberPagination
|
||||||
@@ -103,6 +105,7 @@ class FaviconView(View):
|
|||||||
|
|
||||||
|
|
||||||
class UserViewSet(ModelViewSet):
|
class UserViewSet(ModelViewSet):
|
||||||
|
_BOOL_NOT_PROVIDED = object()
|
||||||
model = User
|
model = User
|
||||||
|
|
||||||
queryset = User.objects.exclude(
|
queryset = User.objects.exclude(
|
||||||
@@ -116,27 +119,65 @@ class UserViewSet(ModelViewSet):
|
|||||||
filterset_class = UserFilterSet
|
filterset_class = UserFilterSet
|
||||||
ordering_fields = ("username",)
|
ordering_fields = ("username",)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _parse_requested_bool(data, key: str):
|
||||||
|
if key not in data:
|
||||||
|
return UserViewSet._BOOL_NOT_PROVIDED
|
||||||
|
try:
|
||||||
|
return BooleanField().to_internal_value(data.get(key))
|
||||||
|
except ValidationError:
|
||||||
|
# Let serializer validation report invalid values as 400 responses
|
||||||
|
return UserViewSet._BOOL_NOT_PROVIDED
|
||||||
|
|
||||||
def create(self, request, *args, **kwargs):
|
def create(self, request, *args, **kwargs):
|
||||||
if not request.user.is_superuser and request.data.get("is_superuser") is True:
|
requested_is_superuser = self._parse_requested_bool(
|
||||||
return HttpResponseForbidden(
|
request.data,
|
||||||
"Superuser status can only be granted by a superuser",
|
"is_superuser",
|
||||||
)
|
)
|
||||||
|
requested_is_staff = self._parse_requested_bool(request.data, "is_staff")
|
||||||
|
|
||||||
|
if not request.user.is_superuser:
|
||||||
|
if requested_is_superuser is True:
|
||||||
|
return HttpResponseForbidden(
|
||||||
|
"Superuser status can only be granted by a superuser",
|
||||||
|
)
|
||||||
|
if requested_is_staff is True:
|
||||||
|
return HttpResponseForbidden(
|
||||||
|
"Staff status can only be granted by a superuser",
|
||||||
|
)
|
||||||
|
|
||||||
return super().create(request, *args, **kwargs)
|
return super().create(request, *args, **kwargs)
|
||||||
|
|
||||||
def update(self, request, *args, **kwargs):
|
def update(self, request, *args, **kwargs):
|
||||||
user_to_update: User = self.get_object()
|
user_to_update: User = self.get_object()
|
||||||
|
|
||||||
if not request.user.is_superuser and user_to_update.is_superuser:
|
if not request.user.is_superuser and user_to_update.is_superuser:
|
||||||
return HttpResponseForbidden(
|
return HttpResponseForbidden(
|
||||||
"Superusers can only be modified by other superusers",
|
"Superusers can only be modified by other superusers",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
requested_is_superuser = self._parse_requested_bool(
|
||||||
|
request.data,
|
||||||
|
"is_superuser",
|
||||||
|
)
|
||||||
|
requested_is_staff = self._parse_requested_bool(request.data, "is_staff")
|
||||||
|
|
||||||
if (
|
if (
|
||||||
not request.user.is_superuser
|
not request.user.is_superuser
|
||||||
and request.data.get("is_superuser") is not None
|
and requested_is_superuser is not self._BOOL_NOT_PROVIDED
|
||||||
and request.data.get("is_superuser") != user_to_update.is_superuser
|
and requested_is_superuser != user_to_update.is_superuser
|
||||||
):
|
):
|
||||||
return HttpResponseForbidden(
|
return HttpResponseForbidden(
|
||||||
"Superuser status can only be changed by a superuser",
|
"Superuser status can only be changed by a superuser",
|
||||||
)
|
)
|
||||||
|
if (
|
||||||
|
not request.user.is_superuser
|
||||||
|
and requested_is_staff is not self._BOOL_NOT_PROVIDED
|
||||||
|
and requested_is_staff != user_to_update.is_staff
|
||||||
|
):
|
||||||
|
return HttpResponseForbidden(
|
||||||
|
"Staff status can only be changed by a superuser",
|
||||||
|
)
|
||||||
return super().update(request, *args, **kwargs)
|
return super().update(request, *args, **kwargs)
|
||||||
|
|
||||||
@extend_schema(
|
@extend_schema(
|
||||||
|
|||||||
@@ -472,6 +472,7 @@ class MailAccountHandler(LoggingMixin):
|
|||||||
name=name,
|
name=name,
|
||||||
defaults={
|
defaults={
|
||||||
"match": name,
|
"match": name,
|
||||||
|
"matching_algorithm": Correspondent.MATCH_LITERAL,
|
||||||
},
|
},
|
||||||
)[0]
|
)[0]
|
||||||
except DatabaseError as e:
|
except DatabaseError as e:
|
||||||
|
|||||||
@@ -1,5 +1,9 @@
|
|||||||
|
from django.utils.translation import gettext as _
|
||||||
from rest_framework import serializers
|
from rest_framework import serializers
|
||||||
|
from rest_framework.exceptions import PermissionDenied
|
||||||
|
|
||||||
|
from documents.permissions import get_objects_for_user_owner_aware
|
||||||
|
from documents.permissions import has_perms_owner_aware
|
||||||
from documents.serialisers import CorrespondentField
|
from documents.serialisers import CorrespondentField
|
||||||
from documents.serialisers import DocumentTypeField
|
from documents.serialisers import DocumentTypeField
|
||||||
from documents.serialisers import OwnedObjectSerializer
|
from documents.serialisers import OwnedObjectSerializer
|
||||||
@@ -56,7 +60,18 @@ class MailAccountSerializer(OwnedObjectSerializer):
|
|||||||
|
|
||||||
class AccountField(serializers.PrimaryKeyRelatedField):
|
class AccountField(serializers.PrimaryKeyRelatedField):
|
||||||
def get_queryset(self):
|
def get_queryset(self):
|
||||||
return MailAccount.objects.all().order_by("-id")
|
user = getattr(self.context.get("request"), "user", None)
|
||||||
|
if user is None:
|
||||||
|
user = getattr(self.root, "user", None)
|
||||||
|
|
||||||
|
if user is None:
|
||||||
|
return MailAccount.objects.none()
|
||||||
|
|
||||||
|
return get_objects_for_user_owner_aware(
|
||||||
|
user,
|
||||||
|
"change_mailaccount",
|
||||||
|
MailAccount,
|
||||||
|
).order_by("-id")
|
||||||
|
|
||||||
|
|
||||||
class MailRuleSerializer(OwnedObjectSerializer):
|
class MailRuleSerializer(OwnedObjectSerializer):
|
||||||
@@ -127,6 +142,18 @@ class MailRuleSerializer(OwnedObjectSerializer):
|
|||||||
|
|
||||||
return attrs
|
return attrs
|
||||||
|
|
||||||
|
def validate_account(self, account):
|
||||||
|
if self.user is not None and has_perms_owner_aware(
|
||||||
|
self.user,
|
||||||
|
"change_mailaccount",
|
||||||
|
account,
|
||||||
|
):
|
||||||
|
return account
|
||||||
|
|
||||||
|
raise PermissionDenied(
|
||||||
|
_("Insufficient permissions."),
|
||||||
|
)
|
||||||
|
|
||||||
def validate_maximum_age(self, value):
|
def validate_maximum_age(self, value):
|
||||||
if value > 36500: # ~100 years
|
if value > 36500: # ~100 years
|
||||||
raise serializers.ValidationError("Maximum mail age is unreasonably large.")
|
raise serializers.ValidationError("Maximum mail age is unreasonably large.")
|
||||||
|
|||||||
@@ -632,6 +632,132 @@ class TestAPIMailRules(DirectoriesMixin, APITestCase):
|
|||||||
self.assertEqual(returned_rule1.name, "Updated Name 1")
|
self.assertEqual(returned_rule1.name, "Updated Name 1")
|
||||||
self.assertEqual(returned_rule1.action, MailRule.MailAction.DELETE)
|
self.assertEqual(returned_rule1.action, MailRule.MailAction.DELETE)
|
||||||
|
|
||||||
|
def test_create_mail_rule_scopes_accounts(self):
|
||||||
|
other_user = User.objects.create_user(username="mail-owner")
|
||||||
|
foreign_account = MailAccount.objects.create(
|
||||||
|
name="ForeignEmail",
|
||||||
|
username="username1",
|
||||||
|
password="password1",
|
||||||
|
imap_server="server.example.com",
|
||||||
|
imap_port=443,
|
||||||
|
imap_security=MailAccount.ImapSecurity.SSL,
|
||||||
|
character_set="UTF-8",
|
||||||
|
owner=other_user,
|
||||||
|
)
|
||||||
|
|
||||||
|
response = self.client.post(
|
||||||
|
self.ENDPOINT,
|
||||||
|
data={
|
||||||
|
"name": "Rule1",
|
||||||
|
"account": foreign_account.pk,
|
||||||
|
"folder": "INBOX",
|
||||||
|
"filter_from": "from@example.com",
|
||||||
|
"maximum_age": 30,
|
||||||
|
"action": MailRule.MailAction.MARK_READ,
|
||||||
|
"assign_title_from": MailRule.TitleSource.FROM_SUBJECT,
|
||||||
|
"assign_correspondent_from": MailRule.CorrespondentSource.FROM_NOTHING,
|
||||||
|
"order": 0,
|
||||||
|
"attachment_type": MailRule.AttachmentProcessing.ATTACHMENTS_ONLY,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
missing_response = self.client.post(
|
||||||
|
self.ENDPOINT,
|
||||||
|
data={
|
||||||
|
"name": "Rule1",
|
||||||
|
"account": foreign_account.pk + 1000,
|
||||||
|
"folder": "INBOX",
|
||||||
|
"filter_from": "from@example.com",
|
||||||
|
"maximum_age": 30,
|
||||||
|
"action": MailRule.MailAction.MARK_READ,
|
||||||
|
"assign_title_from": MailRule.TitleSource.FROM_SUBJECT,
|
||||||
|
"assign_correspondent_from": MailRule.CorrespondentSource.FROM_NOTHING,
|
||||||
|
"order": 0,
|
||||||
|
"attachment_type": MailRule.AttachmentProcessing.ATTACHMENTS_ONLY,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
|
||||||
|
self.assertEqual(missing_response.status_code, status.HTTP_400_BAD_REQUEST)
|
||||||
|
self.assertEqual(response.data["account"][0].code, "does_not_exist")
|
||||||
|
self.assertEqual(missing_response.data["account"][0].code, "does_not_exist")
|
||||||
|
self.assertEqual(MailRule.objects.count(), 0)
|
||||||
|
|
||||||
|
def test_create_mail_rule_allowed_for_granted_account_change_permission(self):
|
||||||
|
other_user = User.objects.create_user(username="mail-owner")
|
||||||
|
foreign_account = MailAccount.objects.create(
|
||||||
|
name="ForeignEmail",
|
||||||
|
username="username1",
|
||||||
|
password="password1",
|
||||||
|
imap_server="server.example.com",
|
||||||
|
imap_port=443,
|
||||||
|
imap_security=MailAccount.ImapSecurity.SSL,
|
||||||
|
character_set="UTF-8",
|
||||||
|
owner=other_user,
|
||||||
|
)
|
||||||
|
assign_perm("change_mailaccount", self.user, foreign_account)
|
||||||
|
|
||||||
|
response = self.client.post(
|
||||||
|
self.ENDPOINT,
|
||||||
|
data={
|
||||||
|
"name": "Rule1",
|
||||||
|
"account": foreign_account.pk,
|
||||||
|
"folder": "INBOX",
|
||||||
|
"filter_from": "from@example.com",
|
||||||
|
"maximum_age": 30,
|
||||||
|
"action": MailRule.MailAction.MARK_READ,
|
||||||
|
"assign_title_from": MailRule.TitleSource.FROM_SUBJECT,
|
||||||
|
"assign_correspondent_from": MailRule.CorrespondentSource.FROM_NOTHING,
|
||||||
|
"order": 0,
|
||||||
|
"attachment_type": MailRule.AttachmentProcessing.ATTACHMENTS_ONLY,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(response.status_code, status.HTTP_201_CREATED)
|
||||||
|
self.assertEqual(MailRule.objects.get().account, foreign_account)
|
||||||
|
|
||||||
|
def test_update_mail_rule_forbidden_for_unpermitted_account(self):
|
||||||
|
own_account = MailAccount.objects.create(
|
||||||
|
name="Email1",
|
||||||
|
username="username1",
|
||||||
|
password="password1",
|
||||||
|
imap_server="server.example.com",
|
||||||
|
imap_port=443,
|
||||||
|
imap_security=MailAccount.ImapSecurity.SSL,
|
||||||
|
character_set="UTF-8",
|
||||||
|
)
|
||||||
|
other_user = User.objects.create_user(username="mail-owner")
|
||||||
|
foreign_account = MailAccount.objects.create(
|
||||||
|
name="ForeignEmail",
|
||||||
|
username="username2",
|
||||||
|
password="password2",
|
||||||
|
imap_server="server.example.com",
|
||||||
|
imap_port=443,
|
||||||
|
imap_security=MailAccount.ImapSecurity.SSL,
|
||||||
|
character_set="UTF-8",
|
||||||
|
owner=other_user,
|
||||||
|
)
|
||||||
|
rule1 = MailRule.objects.create(
|
||||||
|
name="Rule1",
|
||||||
|
account=own_account,
|
||||||
|
folder="INBOX",
|
||||||
|
filter_from="from@example.com",
|
||||||
|
maximum_age=30,
|
||||||
|
action=MailRule.MailAction.MARK_READ,
|
||||||
|
assign_title_from=MailRule.TitleSource.FROM_SUBJECT,
|
||||||
|
assign_correspondent_from=MailRule.CorrespondentSource.FROM_NOTHING,
|
||||||
|
order=0,
|
||||||
|
attachment_type=MailRule.AttachmentProcessing.ATTACHMENTS_ONLY,
|
||||||
|
)
|
||||||
|
|
||||||
|
response = self.client.patch(
|
||||||
|
f"{self.ENDPOINT}{rule1.pk}/",
|
||||||
|
data={"account": foreign_account.pk},
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
|
||||||
|
rule1.refresh_from_db()
|
||||||
|
self.assertEqual(rule1.account, own_account)
|
||||||
|
|
||||||
def test_get_mail_rules_owner_aware(self):
|
def test_get_mail_rules_owner_aware(self):
|
||||||
"""
|
"""
|
||||||
GIVEN:
|
GIVEN:
|
||||||
|
|||||||
@@ -448,7 +448,7 @@ class TestMail(
|
|||||||
c = handler._get_correspondent(message, rule)
|
c = handler._get_correspondent(message, rule)
|
||||||
self.assertIsNotNone(c)
|
self.assertIsNotNone(c)
|
||||||
self.assertEqual(c.name, "someone@somewhere.com")
|
self.assertEqual(c.name, "someone@somewhere.com")
|
||||||
self.assertEqual(c.matching_algorithm, MatchingModel.MATCH_ANY)
|
self.assertEqual(c.matching_algorithm, MatchingModel.MATCH_LITERAL)
|
||||||
self.assertEqual(c.match, "someone@somewhere.com")
|
self.assertEqual(c.match, "someone@somewhere.com")
|
||||||
c = handler._get_correspondent(message2, rule)
|
c = handler._get_correspondent(message2, rule)
|
||||||
self.assertIsNotNone(c)
|
self.assertIsNotNone(c)
|
||||||
|
|||||||
@@ -1991,7 +1991,7 @@ wheels = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "paperless-ngx"
|
name = "paperless-ngx"
|
||||||
version = "2.20.8"
|
version = "2.20.14"
|
||||||
source = { virtual = "." }
|
source = { virtual = "." }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "babel", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" },
|
{ name = "babel", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" },
|
||||||
|
|||||||
Reference in new issue
Block a user