mirror of
https://github.com/paperless-ngx/paperless-ngx.git
synced 2025-10-10 02:16:12 -05:00
Compare commits
1 Commits
feature-di
...
fix-move-t
Author | SHA1 | Date | |
---|---|---|---|
![]() |
17fba7da40 |
54
.github/workflows/ci.yml
vendored
54
.github/workflows/ci.yml
vendored
@@ -25,7 +25,7 @@ jobs:
|
|||||||
steps:
|
steps:
|
||||||
- name: Check if workflow should run
|
- name: Check if workflow should run
|
||||||
id: check
|
id: check
|
||||||
uses: actions/github-script@v8
|
uses: actions/github-script@v7
|
||||||
with:
|
with:
|
||||||
github-token: ${{ secrets.GITHUB_TOKEN }}
|
github-token: ${{ secrets.GITHUB_TOKEN }}
|
||||||
script: |
|
script: |
|
||||||
@@ -69,7 +69,7 @@ jobs:
|
|||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@v5
|
uses: actions/checkout@v5
|
||||||
- name: Install python
|
- name: Install python
|
||||||
uses: actions/setup-python@v6
|
uses: actions/setup-python@v5
|
||||||
with:
|
with:
|
||||||
python-version: ${{ env.DEFAULT_PYTHON_VERSION }}
|
python-version: ${{ env.DEFAULT_PYTHON_VERSION }}
|
||||||
- name: Check files
|
- name: Check files
|
||||||
@@ -84,7 +84,7 @@ jobs:
|
|||||||
uses: actions/checkout@v5
|
uses: actions/checkout@v5
|
||||||
- name: Set up Python
|
- name: Set up Python
|
||||||
id: setup-python
|
id: setup-python
|
||||||
uses: actions/setup-python@v6
|
uses: actions/setup-python@v5
|
||||||
with:
|
with:
|
||||||
python-version: ${{ env.DEFAULT_PYTHON_VERSION }}
|
python-version: ${{ env.DEFAULT_PYTHON_VERSION }}
|
||||||
- name: Install uv
|
- name: Install uv
|
||||||
@@ -138,7 +138,7 @@ jobs:
|
|||||||
docker compose --file ${{ github.workspace }}/docker/compose/docker-compose.ci-test.yml up --detach
|
docker compose --file ${{ github.workspace }}/docker/compose/docker-compose.ci-test.yml up --detach
|
||||||
- name: Set up Python
|
- name: Set up Python
|
||||||
id: setup-python
|
id: setup-python
|
||||||
uses: actions/setup-python@v6
|
uses: actions/setup-python@v5
|
||||||
with:
|
with:
|
||||||
python-version: "${{ matrix.python-version }}"
|
python-version: "${{ matrix.python-version }}"
|
||||||
- name: Install uv
|
- name: Install uv
|
||||||
@@ -183,13 +183,23 @@ jobs:
|
|||||||
if: always()
|
if: always()
|
||||||
uses: codecov/test-results-action@v1
|
uses: codecov/test-results-action@v1
|
||||||
with:
|
with:
|
||||||
|
token: ${{ secrets.CODECOV_TOKEN }}
|
||||||
flags: backend-python-${{ matrix.python-version }}
|
flags: backend-python-${{ matrix.python-version }}
|
||||||
files: junit.xml
|
files: junit.xml
|
||||||
- name: Upload backend coverage to Codecov
|
- name: Upload backend coverage to Codecov
|
||||||
uses: codecov/codecov-action@v5
|
uses: codecov/codecov-action@v5
|
||||||
with:
|
with:
|
||||||
|
token: ${{ secrets.CODECOV_TOKEN }}
|
||||||
flags: backend-python-${{ matrix.python-version }}
|
flags: backend-python-${{ matrix.python-version }}
|
||||||
files: coverage.xml
|
files: coverage.xml
|
||||||
|
- name: Upload backend coverage to Coveralls
|
||||||
|
uses: coverallsapp/github-action@v2
|
||||||
|
with:
|
||||||
|
github-token: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
file: coverage.xml
|
||||||
|
format: cobertura
|
||||||
|
flag-name: backend-python-${{ matrix.python-version }}
|
||||||
|
parallel: true
|
||||||
- name: Stop containers
|
- name: Stop containers
|
||||||
if: always()
|
if: always()
|
||||||
run: |
|
run: |
|
||||||
@@ -207,7 +217,7 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
version: 10
|
version: 10
|
||||||
- name: Use Node.js 20
|
- name: Use Node.js 20
|
||||||
uses: actions/setup-node@v5
|
uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
node-version: 20.x
|
node-version: 20.x
|
||||||
cache: 'pnpm'
|
cache: 'pnpm'
|
||||||
@@ -240,7 +250,7 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
version: 10
|
version: 10
|
||||||
- name: Use Node.js 20
|
- name: Use Node.js 20
|
||||||
uses: actions/setup-node@v5
|
uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
node-version: 20.x
|
node-version: 20.x
|
||||||
cache: 'pnpm'
|
cache: 'pnpm'
|
||||||
@@ -263,13 +273,35 @@ jobs:
|
|||||||
uses: codecov/test-results-action@v1
|
uses: codecov/test-results-action@v1
|
||||||
if: always()
|
if: always()
|
||||||
with:
|
with:
|
||||||
|
token: ${{ secrets.CODECOV_TOKEN }}
|
||||||
flags: frontend-node-${{ matrix.node-version }}
|
flags: frontend-node-${{ matrix.node-version }}
|
||||||
directory: src-ui/
|
directory: src-ui/
|
||||||
- name: Upload frontend coverage to Codecov
|
- name: Upload frontend coverage to Codecov
|
||||||
uses: codecov/codecov-action@v5
|
uses: codecov/codecov-action@v5
|
||||||
with:
|
with:
|
||||||
|
token: ${{ secrets.CODECOV_TOKEN }}
|
||||||
flags: frontend-node-${{ matrix.node-version }}
|
flags: frontend-node-${{ matrix.node-version }}
|
||||||
directory: src-ui/coverage/
|
directory: src-ui/coverage/
|
||||||
|
- name: Upload frontend coverage to Coveralls
|
||||||
|
uses: coverallsapp/github-action@v2
|
||||||
|
with:
|
||||||
|
github-token: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
file: src-ui/coverage/lcov.info
|
||||||
|
format: lcov
|
||||||
|
flag-name: frontend-node-${{ matrix.node-version }}-shard-${{ matrix.shard-index }}
|
||||||
|
parallel: true
|
||||||
|
coveralls-finish:
|
||||||
|
name: Finalize Coveralls
|
||||||
|
runs-on: ubuntu-24.04
|
||||||
|
needs:
|
||||||
|
- tests-backend
|
||||||
|
- tests-frontend
|
||||||
|
steps:
|
||||||
|
- name: Mark Coveralls jobs complete
|
||||||
|
uses: coverallsapp/github-action@v2
|
||||||
|
with:
|
||||||
|
github-token: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
parallel-finished: true
|
||||||
tests-frontend-e2e:
|
tests-frontend-e2e:
|
||||||
name: "Frontend E2E Tests (Node ${{ matrix.node-version }} - ${{ matrix.shard-index }}/${{ matrix.shard-count }})"
|
name: "Frontend E2E Tests (Node ${{ matrix.node-version }} - ${{ matrix.shard-index }}/${{ matrix.shard-count }})"
|
||||||
runs-on: ubuntu-24.04
|
runs-on: ubuntu-24.04
|
||||||
@@ -288,7 +320,7 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
version: 10
|
version: 10
|
||||||
- name: Use Node.js 20
|
- name: Use Node.js 20
|
||||||
uses: actions/setup-node@v5
|
uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
node-version: 20.x
|
node-version: 20.x
|
||||||
cache: 'pnpm'
|
cache: 'pnpm'
|
||||||
@@ -331,7 +363,7 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
version: 10
|
version: 10
|
||||||
- name: Use Node.js 20
|
- name: Use Node.js 20
|
||||||
uses: actions/setup-node@v5
|
uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
node-version: 20.x
|
node-version: 20.x
|
||||||
cache: 'pnpm'
|
cache: 'pnpm'
|
||||||
@@ -473,7 +505,7 @@ jobs:
|
|||||||
uses: actions/checkout@v5
|
uses: actions/checkout@v5
|
||||||
- name: Set up Python
|
- name: Set up Python
|
||||||
id: setup-python
|
id: setup-python
|
||||||
uses: actions/setup-python@v6
|
uses: actions/setup-python@v5
|
||||||
with:
|
with:
|
||||||
python-version: ${{ env.DEFAULT_PYTHON_VERSION }}
|
python-version: ${{ env.DEFAULT_PYTHON_VERSION }}
|
||||||
- name: Install uv
|
- name: Install uv
|
||||||
@@ -621,7 +653,7 @@ jobs:
|
|||||||
ref: main
|
ref: main
|
||||||
- name: Set up Python
|
- name: Set up Python
|
||||||
id: setup-python
|
id: setup-python
|
||||||
uses: actions/setup-python@v6
|
uses: actions/setup-python@v5
|
||||||
with:
|
with:
|
||||||
python-version: ${{ env.DEFAULT_PYTHON_VERSION }}
|
python-version: ${{ env.DEFAULT_PYTHON_VERSION }}
|
||||||
- name: Install uv
|
- name: Install uv
|
||||||
@@ -653,7 +685,7 @@ jobs:
|
|||||||
git commit -am "Changelog ${{ needs.publish-release.outputs.version }} - GHA"
|
git commit -am "Changelog ${{ needs.publish-release.outputs.version }} - GHA"
|
||||||
git push origin ${{ needs.publish-release.outputs.version }}-changelog
|
git push origin ${{ needs.publish-release.outputs.version }}-changelog
|
||||||
- name: Create Pull Request
|
- name: Create Pull Request
|
||||||
uses: actions/github-script@v8
|
uses: actions/github-script@v7
|
||||||
with:
|
with:
|
||||||
script: |
|
script: |
|
||||||
const { repo, owner } = context.repo;
|
const { repo, owner } = context.repo;
|
||||||
|
7
.github/workflows/cleanup-tags.yml
vendored
7
.github/workflows/cleanup-tags.yml
vendored
@@ -6,9 +6,10 @@
|
|||||||
# This workflow will not trigger runs on forked repos.
|
# This workflow will not trigger runs on forked repos.
|
||||||
name: Cleanup Image Tags
|
name: Cleanup Image Tags
|
||||||
on:
|
on:
|
||||||
workflow_dispatch:
|
delete:
|
||||||
schedule:
|
push:
|
||||||
- cron: '0 0 * * 0'
|
paths:
|
||||||
|
- ".github/workflows/cleanup-tags.yml"
|
||||||
concurrency:
|
concurrency:
|
||||||
group: registry-tags-cleanup
|
group: registry-tags-cleanup
|
||||||
cancel-in-progress: false
|
cancel-in-progress: false
|
||||||
|
8
.github/workflows/pr-bot.yml
vendored
8
.github/workflows/pr-bot.yml
vendored
@@ -12,7 +12,7 @@ jobs:
|
|||||||
steps:
|
steps:
|
||||||
- name: Label PR by file path or branch name
|
- name: Label PR by file path or branch name
|
||||||
# see .github/labeler.yml for the labeler config
|
# see .github/labeler.yml for the labeler config
|
||||||
uses: actions/labeler@v6
|
uses: actions/labeler@v5
|
||||||
with:
|
with:
|
||||||
repo-token: ${{ secrets.GITHUB_TOKEN }}
|
repo-token: ${{ secrets.GITHUB_TOKEN }}
|
||||||
- name: Label by size
|
- name: Label by size
|
||||||
@@ -26,7 +26,7 @@ jobs:
|
|||||||
fail_if_xl: 'false'
|
fail_if_xl: 'false'
|
||||||
excluded_files: /\.lock$/ /\.txt$/ ^src-ui/pnpm-lock\.yaml$ ^src-ui/messages\.xlf$ ^src/locale/en_US/LC_MESSAGES/django\.po$
|
excluded_files: /\.lock$/ /\.txt$/ ^src-ui/pnpm-lock\.yaml$ ^src-ui/messages\.xlf$ ^src/locale/en_US/LC_MESSAGES/django\.po$
|
||||||
- name: Label by PR title
|
- name: Label by PR title
|
||||||
uses: actions/github-script@v8
|
uses: actions/github-script@v7
|
||||||
with:
|
with:
|
||||||
script: |
|
script: |
|
||||||
const pr = context.payload.pull_request;
|
const pr = context.payload.pull_request;
|
||||||
@@ -52,7 +52,7 @@ jobs:
|
|||||||
}
|
}
|
||||||
- name: Label bot-generated PRs
|
- name: Label bot-generated PRs
|
||||||
if: ${{ contains(github.actor, 'dependabot') || contains(github.actor, 'crowdin-bot') }}
|
if: ${{ contains(github.actor, 'dependabot') || contains(github.actor, 'crowdin-bot') }}
|
||||||
uses: actions/github-script@v8
|
uses: actions/github-script@v7
|
||||||
with:
|
with:
|
||||||
script: |
|
script: |
|
||||||
const pr = context.payload.pull_request;
|
const pr = context.payload.pull_request;
|
||||||
@@ -77,7 +77,7 @@ jobs:
|
|||||||
}
|
}
|
||||||
- name: Welcome comment
|
- name: Welcome comment
|
||||||
if: ${{ !contains(github.actor, 'bot') }}
|
if: ${{ !contains(github.actor, 'bot') }}
|
||||||
uses: actions/github-script@v8
|
uses: actions/github-script@v7
|
||||||
with:
|
with:
|
||||||
script: |
|
script: |
|
||||||
const pr = context.payload.pull_request;
|
const pr = context.payload.pull_request;
|
||||||
|
8
.github/workflows/repo-maintenance.yml
vendored
8
.github/workflows/repo-maintenance.yml
vendored
@@ -15,7 +15,7 @@ jobs:
|
|||||||
if: github.repository_owner == 'paperless-ngx'
|
if: github.repository_owner == 'paperless-ngx'
|
||||||
runs-on: ubuntu-24.04
|
runs-on: ubuntu-24.04
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/stale@v10
|
- uses: actions/stale@v9
|
||||||
with:
|
with:
|
||||||
days-before-stale: 7
|
days-before-stale: 7
|
||||||
days-before-close: 14
|
days-before-close: 14
|
||||||
@@ -57,7 +57,7 @@ jobs:
|
|||||||
if: github.repository_owner == 'paperless-ngx'
|
if: github.repository_owner == 'paperless-ngx'
|
||||||
runs-on: ubuntu-24.04
|
runs-on: ubuntu-24.04
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/github-script@v8
|
- uses: actions/github-script@v7
|
||||||
with:
|
with:
|
||||||
script: |
|
script: |
|
||||||
function sleep(ms) {
|
function sleep(ms) {
|
||||||
@@ -114,7 +114,7 @@ jobs:
|
|||||||
if: github.repository_owner == 'paperless-ngx'
|
if: github.repository_owner == 'paperless-ngx'
|
||||||
runs-on: ubuntu-24.04
|
runs-on: ubuntu-24.04
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/github-script@v8
|
- uses: actions/github-script@v7
|
||||||
with:
|
with:
|
||||||
script: |
|
script: |
|
||||||
function sleep(ms) {
|
function sleep(ms) {
|
||||||
@@ -206,7 +206,7 @@ jobs:
|
|||||||
if: github.repository_owner == 'paperless-ngx'
|
if: github.repository_owner == 'paperless-ngx'
|
||||||
runs-on: ubuntu-24.04
|
runs-on: ubuntu-24.04
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/github-script@v8
|
- uses: actions/github-script@v7
|
||||||
with:
|
with:
|
||||||
script: |
|
script: |
|
||||||
function sleep(ms) {
|
function sleep(ms) {
|
||||||
|
4
.github/workflows/translate-strings.yml
vendored
4
.github/workflows/translate-strings.yml
vendored
@@ -17,7 +17,7 @@ jobs:
|
|||||||
ref: ${{ github.head_ref }}
|
ref: ${{ github.head_ref }}
|
||||||
- name: Set up Python
|
- name: Set up Python
|
||||||
id: setup-python
|
id: setup-python
|
||||||
uses: actions/setup-python@v6
|
uses: actions/setup-python@v5
|
||||||
- name: Install system dependencies
|
- name: Install system dependencies
|
||||||
run: |
|
run: |
|
||||||
sudo apt-get update -qq
|
sudo apt-get update -qq
|
||||||
@@ -38,7 +38,7 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
version: 10
|
version: 10
|
||||||
- name: Use Node.js 20
|
- name: Use Node.js 20
|
||||||
uses: actions/setup-node@v5
|
uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
node-version: 20.x
|
node-version: 20.x
|
||||||
cache: 'pnpm'
|
cache: 'pnpm'
|
||||||
|
@@ -49,7 +49,7 @@ repos:
|
|||||||
- 'prettier-plugin-organize-imports@4.1.0'
|
- 'prettier-plugin-organize-imports@4.1.0'
|
||||||
# Python hooks
|
# Python hooks
|
||||||
- repo: https://github.com/astral-sh/ruff-pre-commit
|
- repo: https://github.com/astral-sh/ruff-pre-commit
|
||||||
rev: v0.13.2
|
rev: v0.13.0
|
||||||
hooks:
|
hooks:
|
||||||
- id: ruff-check
|
- id: ruff-check
|
||||||
- id: ruff-format
|
- id: ruff-format
|
||||||
@@ -59,7 +59,7 @@ repos:
|
|||||||
- id: pyproject-fmt
|
- id: pyproject-fmt
|
||||||
# Dockerfile hooks
|
# Dockerfile hooks
|
||||||
- repo: https://github.com/AleksaC/hadolint-py
|
- repo: https://github.com/AleksaC/hadolint-py
|
||||||
rev: v2.14.0
|
rev: v2.12.1b3
|
||||||
hooks:
|
hooks:
|
||||||
- id: hadolint
|
- id: hadolint
|
||||||
# Shell script hooks
|
# Shell script hooks
|
||||||
|
@@ -32,7 +32,7 @@ RUN set -eux \
|
|||||||
# Purpose: Installs s6-overlay and rootfs
|
# Purpose: Installs s6-overlay and rootfs
|
||||||
# Comments:
|
# Comments:
|
||||||
# - Don't leave anything extra in here either
|
# - Don't leave anything extra in here either
|
||||||
FROM ghcr.io/astral-sh/uv:0.8.22-python3.12-bookworm-slim AS s6-overlay-base
|
FROM ghcr.io/astral-sh/uv:0.8.17-python3.12-bookworm-slim AS s6-overlay-base
|
||||||
|
|
||||||
WORKDIR /usr/src/s6
|
WORKDIR /usr/src/s6
|
||||||
|
|
||||||
|
@@ -32,7 +32,7 @@ services:
|
|||||||
volumes:
|
volumes:
|
||||||
- redisdata:/data
|
- redisdata:/data
|
||||||
db:
|
db:
|
||||||
image: docker.io/library/postgres:18
|
image: docker.io/library/postgres:17
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
volumes:
|
volumes:
|
||||||
- pgdata:/var/lib/postgresql/data
|
- pgdata:/var/lib/postgresql/data
|
||||||
|
@@ -35,7 +35,7 @@ services:
|
|||||||
volumes:
|
volumes:
|
||||||
- redisdata:/data
|
- redisdata:/data
|
||||||
db:
|
db:
|
||||||
image: docker.io/library/postgres:18
|
image: docker.io/library/postgres:17
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
volumes:
|
volumes:
|
||||||
- pgdata:/var/lib/postgresql/data
|
- pgdata:/var/lib/postgresql/data
|
||||||
|
@@ -31,7 +31,7 @@ services:
|
|||||||
volumes:
|
volumes:
|
||||||
- redisdata:/data
|
- redisdata:/data
|
||||||
db:
|
db:
|
||||||
image: docker.io/library/postgres:18
|
image: docker.io/library/postgres:17
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
volumes:
|
volumes:
|
||||||
- pgdata:/var/lib/postgresql/data
|
- pgdata:/var/lib/postgresql/data
|
||||||
|
@@ -170,11 +170,11 @@ Available options are `postgresql` and `mariadb`.
|
|||||||
|
|
||||||
!!! note
|
!!! note
|
||||||
|
|
||||||
A small pool is typically sufficient — for example, a size of 4.
|
A small pool is typically sufficient — for example, a size of 4.
|
||||||
Make sure your PostgreSQL server's max_connections setting is large enough to handle:
|
Make sure your PostgreSQL server's max_connections setting is large enough to handle:
|
||||||
```(Paperless workers + Celery workers) × pool size + safety margin```
|
```(Paperless workers + Celery workers) × pool size + safety margin```
|
||||||
For example, with 4 Paperless workers and 2 Celery workers, and a pool size of 4:
|
For example, with 4 Paperless workers and 2 Celery workers, and a pool size of 4:
|
||||||
(4 + 2) × 4 + 10 = 34 connections required.
|
(4 + 2) × 4 + 10 = 34 connections required.
|
||||||
|
|
||||||
#### [`PAPERLESS_DB_READ_CACHE_ENABLED=<bool>`](#PAPERLESS_DB_READ_CACHE_ENABLED) {#PAPERLESS_DB_READ_CACHE_ENABLED}
|
#### [`PAPERLESS_DB_READ_CACHE_ENABLED=<bool>`](#PAPERLESS_DB_READ_CACHE_ENABLED) {#PAPERLESS_DB_READ_CACHE_ENABLED}
|
||||||
|
|
||||||
@@ -184,9 +184,9 @@ Available options are `postgresql` and `mariadb`.
|
|||||||
|
|
||||||
!!! danger
|
!!! danger
|
||||||
|
|
||||||
**Do not modify the database outside the application while it is running.**
|
**Do not modify the database outside the application while it is running.**
|
||||||
This includes actions such as restoring a backup, upgrading the database, or performing manual inserts. All external modifications must be done **only when the application is stopped**.
|
This includes actions such as restoring a backup, upgrading the database, or performing manual inserts. All external modifications must be done **only when the application is stopped**.
|
||||||
After making any such changes, you **must invalidate the DB read cache** using the `invalidate_cachalot` management command.
|
After making any such changes, you **must invalidate the DB read cache** using the `invalidate_cachalot` management command.
|
||||||
|
|
||||||
#### [`PAPERLESS_READ_CACHE_TTL=<int>`](#PAPERLESS_READ_CACHE_TTL) {#PAPERLESS_READ_CACHE_TTL}
|
#### [`PAPERLESS_READ_CACHE_TTL=<int>`](#PAPERLESS_READ_CACHE_TTL) {#PAPERLESS_READ_CACHE_TTL}
|
||||||
|
|
||||||
@@ -196,7 +196,7 @@ Available options are `postgresql` and `mariadb`.
|
|||||||
|
|
||||||
!!! warning
|
!!! warning
|
||||||
|
|
||||||
A high TTL increases memory usage over time. Memory may be used until end of TTL, even if the cache is invalidated with the `invalidate_cachalot` command.
|
A high TTL increases memory usage over time. Memory may be used until end of TTL, even if the cache is invalidated with the `invalidate_cachalot` command.
|
||||||
|
|
||||||
In case of an out-of-memory (OOM) situation, Redis may stop accepting new data — including cache entries, scheduled tasks, and documents to consume.
|
In case of an out-of-memory (OOM) situation, Redis may stop accepting new data — including cache entries, scheduled tasks, and documents to consume.
|
||||||
If your system has limited RAM, consider configuring a dedicated Redis instance for the read cache, with a memory limit and the eviction policy set to `allkeys-lru`.
|
If your system has limited RAM, consider configuring a dedicated Redis instance for the read cache, with a memory limit and the eviction policy set to `allkeys-lru`.
|
||||||
|
@@ -414,7 +414,7 @@ fields and permissions, which will be merged.
|
|||||||
|
|
||||||
#### Types {#workflow-trigger-types}
|
#### Types {#workflow-trigger-types}
|
||||||
|
|
||||||
Currently, there are four events that correspond to workflow trigger 'types':
|
Currently, there are three events that correspond to workflow trigger 'types':
|
||||||
|
|
||||||
1. **Consumption Started**: _before_ a document is consumed, so events can include filters by source (mail, consumption
|
1. **Consumption Started**: _before_ a document is consumed, so events can include filters by source (mail, consumption
|
||||||
folder or API), file path, file name, mail rule
|
folder or API), file path, file name, mail rule
|
||||||
@@ -427,7 +427,7 @@ Currently, there are four events that correspond to workflow trigger 'types':
|
|||||||
added, created, updated date or you can specify a (date) custom field. You can also specify a day offset from the date (positive
|
added, created, updated date or you can specify a (date) custom field. You can also specify a day offset from the date (positive
|
||||||
offsets will trigger after the date, negative offsets will trigger before).
|
offsets will trigger after the date, negative offsets will trigger before).
|
||||||
|
|
||||||
The following flow diagram illustrates the four document trigger types:
|
The following flow diagram illustrates the three document trigger types:
|
||||||
|
|
||||||
```mermaid
|
```mermaid
|
||||||
flowchart TD
|
flowchart TD
|
||||||
@@ -637,7 +637,7 @@ When you first delete a document it is moved to the 'trash' until either it is e
|
|||||||
You can set how long documents remain in the trash before being automatically deleted with [`PAPERLESS_EMPTY_TRASH_DELAY`](configuration.md#PAPERLESS_EMPTY_TRASH_DELAY), which defaults
|
You can set how long documents remain in the trash before being automatically deleted with [`PAPERLESS_EMPTY_TRASH_DELAY`](configuration.md#PAPERLESS_EMPTY_TRASH_DELAY), which defaults
|
||||||
to 30 days. Until the file is actually deleted (e.g. the trash is emptied), all files and database content remains intact and can be restored at any point up until that time.
|
to 30 days. Until the file is actually deleted (e.g. the trash is emptied), all files and database content remains intact and can be restored at any point up until that time.
|
||||||
|
|
||||||
Additionally you may configure a directory where deleted files are moved to when the trash is emptied with [`PAPERLESS_EMPTY_TRASH_DIR`](configuration.md#PAPERLESS_EMPTY_TRASH_DIR).
|
Additionally you may configure a directory where deleted files are moved to when they the trash is emptied with [`PAPERLESS_EMPTY_TRASH_DIR`](configuration.md#PAPERLESS_EMPTY_TRASH_DIR).
|
||||||
Note that the empty trash directory only stores the original file, the archive file and all database information is permanently removed once a document is fully deleted.
|
Note that the empty trash directory only stores the original file, the archive file and all database information is permanently removed once a document is fully deleted.
|
||||||
|
|
||||||
## Best practices {#basic-searching}
|
## Best practices {#basic-searching}
|
||||||
|
@@ -30,10 +30,10 @@ dependencies = [
|
|||||||
"django-cachalot~=2.8.0",
|
"django-cachalot~=2.8.0",
|
||||||
"django-celery-results~=2.6.0",
|
"django-celery-results~=2.6.0",
|
||||||
"django-compression-middleware~=0.5.0",
|
"django-compression-middleware~=0.5.0",
|
||||||
"django-cors-headers~=4.9.0",
|
"django-cors-headers~=4.8.0",
|
||||||
"django-extensions~=4.1",
|
"django-extensions~=4.1",
|
||||||
"django-filter~=25.1",
|
"django-filter~=25.1",
|
||||||
"django-guardian~=3.2.0",
|
"django-guardian~=3.1.2",
|
||||||
"django-multiselectfield~=1.0.1",
|
"django-multiselectfield~=1.0.1",
|
||||||
"django-soft-delete~=1.0.18",
|
"django-soft-delete~=1.0.18",
|
||||||
"django-treenode>=0.23.2",
|
"django-treenode>=0.23.2",
|
||||||
@@ -54,6 +54,7 @@ dependencies = [
|
|||||||
"ocrmypdf~=16.11.0",
|
"ocrmypdf~=16.11.0",
|
||||||
"pathvalidate~=3.3.1",
|
"pathvalidate~=3.3.1",
|
||||||
"pdf2image~=1.17.0",
|
"pdf2image~=1.17.0",
|
||||||
|
"psycopg-pool",
|
||||||
"python-dateutil~=2.9.0",
|
"python-dateutil~=2.9.0",
|
||||||
"python-dotenv~=1.1.0",
|
"python-dotenv~=1.1.0",
|
||||||
"python-gnupg~=0.5.4",
|
"python-gnupg~=0.5.4",
|
||||||
|
@@ -174,7 +174,7 @@ test('bulk edit', async ({ page }) => {
|
|||||||
await expect(page.locator('pngx-document-list')).toHaveText(
|
await expect(page.locator('pngx-document-list')).toHaveText(
|
||||||
/Selected 61 of 61 documents/i
|
/Selected 61 of 61 documents/i
|
||||||
)
|
)
|
||||||
await page.getByRole('button', { name: 'None' }).click()
|
await page.getByRole('button', { name: 'Cancel' }).click()
|
||||||
|
|
||||||
await page.locator('pngx-document-card-small').nth(1).click()
|
await page.locator('pngx-document-card-small').nth(1).click()
|
||||||
await page.locator('pngx-document-card-small').nth(2).click()
|
await page.locator('pngx-document-card-small').nth(2).click()
|
||||||
|
File diff suppressed because it is too large
Load Diff
@@ -11,17 +11,17 @@
|
|||||||
},
|
},
|
||||||
"private": true,
|
"private": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@angular/cdk": "^20.2.6",
|
"@angular/cdk": "^20.2.2",
|
||||||
"@angular/common": "~20.3.2",
|
"@angular/common": "~20.2.4",
|
||||||
"@angular/compiler": "~20.3.2",
|
"@angular/compiler": "~20.2.4",
|
||||||
"@angular/core": "~20.3.2",
|
"@angular/core": "~20.2.4",
|
||||||
"@angular/forms": "~20.3.2",
|
"@angular/forms": "~20.2.4",
|
||||||
"@angular/localize": "~20.3.2",
|
"@angular/localize": "~20.2.4",
|
||||||
"@angular/platform-browser": "~20.3.2",
|
"@angular/platform-browser": "~20.2.4",
|
||||||
"@angular/platform-browser-dynamic": "~20.3.2",
|
"@angular/platform-browser-dynamic": "~20.2.4",
|
||||||
"@angular/router": "~20.3.2",
|
"@angular/router": "~20.2.4",
|
||||||
"@ng-bootstrap/ng-bootstrap": "^19.0.1",
|
"@ng-bootstrap/ng-bootstrap": "^19.0.1",
|
||||||
"@ng-select/ng-select": "^20.2.2",
|
"@ng-select/ng-select": "^20.1.3",
|
||||||
"@ngneat/dirty-check-forms": "^3.0.3",
|
"@ngneat/dirty-check-forms": "^3.0.3",
|
||||||
"@popperjs/core": "^2.11.8",
|
"@popperjs/core": "^2.11.8",
|
||||||
"bootstrap": "^5.3.8",
|
"bootstrap": "^5.3.8",
|
||||||
@@ -29,48 +29,47 @@
|
|||||||
"mime-names": "^1.0.0",
|
"mime-names": "^1.0.0",
|
||||||
"ng2-pdf-viewer": "^10.4.0",
|
"ng2-pdf-viewer": "^10.4.0",
|
||||||
"ngx-bootstrap-icons": "^1.9.3",
|
"ngx-bootstrap-icons": "^1.9.3",
|
||||||
"ngx-color": "^10.1.0",
|
"ngx-color": "^10.0.0",
|
||||||
"ngx-cookie-service": "^20.1.0",
|
"ngx-cookie-service": "^20.1.0",
|
||||||
"ngx-device-detector": "^10.1.0",
|
"ngx-device-detector": "^10.1.0",
|
||||||
"ngx-ui-tour-ng-bootstrap": "^17.0.1",
|
"ngx-ui-tour-ng-bootstrap": "^17.0.1",
|
||||||
"rxjs": "^7.8.2",
|
"rxjs": "^7.8.2",
|
||||||
"tslib": "^2.8.1",
|
"tslib": "^2.8.1",
|
||||||
"utif": "^3.1.0",
|
"utif": "^3.1.0",
|
||||||
"uuid": "^13.0.0",
|
"uuid": "^11.1.0",
|
||||||
"zone.js": "^0.15.1"
|
"zone.js": "^0.15.1"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@angular-builders/custom-webpack": "^20.0.0",
|
"@angular-builders/custom-webpack": "^20.0.0",
|
||||||
"@angular-builders/jest": "^20.0.0",
|
"@angular-builders/jest": "^20.0.0",
|
||||||
"@angular-devkit/core": "^20.3.3",
|
"@angular-devkit/core": "^20.2.2",
|
||||||
"@angular-devkit/schematics": "^20.3.3",
|
"@angular-devkit/schematics": "^20.2.2",
|
||||||
"@angular-eslint/builder": "20.3.0",
|
"@angular-eslint/builder": "20.2.0",
|
||||||
"@angular-eslint/eslint-plugin": "20.3.0",
|
"@angular-eslint/eslint-plugin": "20.2.0",
|
||||||
"@angular-eslint/eslint-plugin-template": "20.3.0",
|
"@angular-eslint/eslint-plugin-template": "20.2.0",
|
||||||
"@angular-eslint/schematics": "20.3.0",
|
"@angular-eslint/schematics": "20.2.0",
|
||||||
"@angular-eslint/template-parser": "20.3.0",
|
"@angular-eslint/template-parser": "20.2.0",
|
||||||
"@angular/build": "^20.3.3",
|
"@angular/build": "^20.2.2",
|
||||||
"@angular/cli": "~20.3.3",
|
"@angular/cli": "~20.2.2",
|
||||||
"@angular/compiler-cli": "~20.3.2",
|
"@angular/compiler-cli": "~20.2.4",
|
||||||
"@codecov/webpack-plugin": "^1.9.1",
|
"@codecov/webpack-plugin": "^1.9.1",
|
||||||
"@playwright/test": "^1.55.1",
|
"@playwright/test": "^1.55.0",
|
||||||
"@types/jest": "^30.0.0",
|
"@types/jest": "^30.0.0",
|
||||||
"@types/node": "^24.6.1",
|
"@types/node": "^24.3.0",
|
||||||
"@typescript-eslint/eslint-plugin": "^8.45.0",
|
"@typescript-eslint/eslint-plugin": "^8.41.0",
|
||||||
"@typescript-eslint/parser": "^8.45.0",
|
"@typescript-eslint/parser": "^8.41.0",
|
||||||
"@typescript-eslint/utils": "^8.45.0",
|
"@typescript-eslint/utils": "^8.41.0",
|
||||||
"eslint": "^9.36.0",
|
"eslint": "^9.34.0",
|
||||||
"jest": "30.2.0",
|
"jest": "30.1.3",
|
||||||
"jest-environment-jsdom": "^30.2.0",
|
"jest-environment-jsdom": "^30.1.2",
|
||||||
"jest-junit": "^16.0.0",
|
"jest-junit": "^16.0.0",
|
||||||
"jest-preset-angular": "^15.0.2",
|
"jest-preset-angular": "^15.0.0",
|
||||||
"jest-websocket-mock": "^2.5.0",
|
"jest-websocket-mock": "^2.5.0",
|
||||||
"prettier-plugin-organize-imports": "^4.3.0",
|
"prettier-plugin-organize-imports": "^4.2.0",
|
||||||
"ts-node": "~10.9.1",
|
"ts-node": "~10.9.1",
|
||||||
"typescript": "^5.8.3",
|
"typescript": "^5.8.3",
|
||||||
"webpack": "^5.102.0"
|
"webpack": "^5.101.3"
|
||||||
},
|
},
|
||||||
"packageManager": "pnpm@10.17.1",
|
|
||||||
"pnpm": {
|
"pnpm": {
|
||||||
"onlyBuiltDependencies": [
|
"onlyBuiltDependencies": [
|
||||||
"@parcel/watcher",
|
"@parcel/watcher",
|
||||||
|
3494
src-ui/pnpm-lock.yaml
generated
3494
src-ui/pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@@ -145,14 +145,4 @@ HTMLCanvasElement.prototype.getContext = <
|
|||||||
typeof HTMLCanvasElement.prototype.getContext
|
typeof HTMLCanvasElement.prototype.getContext
|
||||||
>jest.fn()
|
>jest.fn()
|
||||||
|
|
||||||
jest.mock('uuid', () => ({
|
|
||||||
v4: jest.fn(() =>
|
|
||||||
'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (char: string) => {
|
|
||||||
const random = Math.floor(Math.random() * 16)
|
|
||||||
const value = char === 'x' ? random : (random & 0x3) | 0x8
|
|
||||||
return value.toString(16)
|
|
||||||
})
|
|
||||||
),
|
|
||||||
}))
|
|
||||||
|
|
||||||
jest.mock('pdfjs-dist')
|
jest.mock('pdfjs-dist')
|
||||||
|
@@ -16,7 +16,6 @@ import {
|
|||||||
NgbNavItem,
|
NgbNavItem,
|
||||||
} from '@ng-bootstrap/ng-bootstrap'
|
} from '@ng-bootstrap/ng-bootstrap'
|
||||||
import { allIcons, NgxBootstrapIconsModule } from 'ngx-bootstrap-icons'
|
import { allIcons, NgxBootstrapIconsModule } from 'ngx-bootstrap-icons'
|
||||||
import { throwError } from 'rxjs'
|
|
||||||
import { routes } from 'src/app/app-routing.module'
|
import { routes } from 'src/app/app-routing.module'
|
||||||
import {
|
import {
|
||||||
PaperlessTask,
|
PaperlessTask,
|
||||||
@@ -29,7 +28,6 @@ import { PermissionsGuard } from 'src/app/guards/permissions.guard'
|
|||||||
import { CustomDatePipe } from 'src/app/pipes/custom-date.pipe'
|
import { CustomDatePipe } from 'src/app/pipes/custom-date.pipe'
|
||||||
import { PermissionsService } from 'src/app/services/permissions.service'
|
import { PermissionsService } from 'src/app/services/permissions.service'
|
||||||
import { TasksService } from 'src/app/services/tasks.service'
|
import { TasksService } from 'src/app/services/tasks.service'
|
||||||
import { ToastService } from 'src/app/services/toast.service'
|
|
||||||
import { environment } from 'src/environments/environment'
|
import { environment } from 'src/environments/environment'
|
||||||
import { ConfirmDialogComponent } from '../../common/confirm-dialog/confirm-dialog.component'
|
import { ConfirmDialogComponent } from '../../common/confirm-dialog/confirm-dialog.component'
|
||||||
import { PageHeaderComponent } from '../../common/page-header/page-header.component'
|
import { PageHeaderComponent } from '../../common/page-header/page-header.component'
|
||||||
@@ -125,7 +123,6 @@ describe('TasksComponent', () => {
|
|||||||
let router: Router
|
let router: Router
|
||||||
let httpTestingController: HttpTestingController
|
let httpTestingController: HttpTestingController
|
||||||
let reloadSpy
|
let reloadSpy
|
||||||
let toastService: ToastService
|
|
||||||
|
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
TestBed.configureTestingModule({
|
TestBed.configureTestingModule({
|
||||||
@@ -160,7 +157,6 @@ describe('TasksComponent', () => {
|
|||||||
httpTestingController = TestBed.inject(HttpTestingController)
|
httpTestingController = TestBed.inject(HttpTestingController)
|
||||||
modalService = TestBed.inject(NgbModal)
|
modalService = TestBed.inject(NgbModal)
|
||||||
router = TestBed.inject(Router)
|
router = TestBed.inject(Router)
|
||||||
toastService = TestBed.inject(ToastService)
|
|
||||||
fixture = TestBed.createComponent(TasksComponent)
|
fixture = TestBed.createComponent(TasksComponent)
|
||||||
component = fixture.componentInstance
|
component = fixture.componentInstance
|
||||||
jest.useFakeTimers()
|
jest.useFakeTimers()
|
||||||
@@ -253,42 +249,6 @@ describe('TasksComponent', () => {
|
|||||||
expect(dismissSpy).toHaveBeenCalledWith(selected)
|
expect(dismissSpy).toHaveBeenCalledWith(selected)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should show an error and re-enable modal buttons when dismissing multiple tasks fails', () => {
|
|
||||||
component.selectedTasks = new Set([tasks[0].id, tasks[1].id])
|
|
||||||
const error = new Error('dismiss failed')
|
|
||||||
const toastSpy = jest.spyOn(toastService, 'showError')
|
|
||||||
const dismissSpy = jest
|
|
||||||
.spyOn(tasksService, 'dismissTasks')
|
|
||||||
.mockReturnValue(throwError(() => error))
|
|
||||||
|
|
||||||
let modal: NgbModalRef
|
|
||||||
modalService.activeInstances.subscribe((m) => (modal = m[m.length - 1]))
|
|
||||||
|
|
||||||
component.dismissTasks()
|
|
||||||
expect(modal).not.toBeUndefined()
|
|
||||||
|
|
||||||
modal.componentInstance.confirmClicked.emit()
|
|
||||||
|
|
||||||
expect(dismissSpy).toHaveBeenCalledWith(new Set([tasks[0].id, tasks[1].id]))
|
|
||||||
expect(toastSpy).toHaveBeenCalledWith('Error dismissing tasks', error)
|
|
||||||
expect(modal.componentInstance.buttonsEnabled).toBe(true)
|
|
||||||
expect(component.selectedTasks.size).toBe(0)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('should show an error when dismissing a single task fails', () => {
|
|
||||||
const error = new Error('dismiss failed')
|
|
||||||
const toastSpy = jest.spyOn(toastService, 'showError')
|
|
||||||
const dismissSpy = jest
|
|
||||||
.spyOn(tasksService, 'dismissTasks')
|
|
||||||
.mockReturnValue(throwError(() => error))
|
|
||||||
|
|
||||||
component.dismissTask(tasks[0])
|
|
||||||
|
|
||||||
expect(dismissSpy).toHaveBeenCalledWith(new Set([tasks[0].id]))
|
|
||||||
expect(toastSpy).toHaveBeenCalledWith('Error dismissing task', error)
|
|
||||||
expect(component.selectedTasks.size).toBe(0)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('should support dismiss all tasks', () => {
|
it('should support dismiss all tasks', () => {
|
||||||
let modal: NgbModalRef
|
let modal: NgbModalRef
|
||||||
modalService.activeInstances.subscribe((m) => (modal = m[m.length - 1]))
|
modalService.activeInstances.subscribe((m) => (modal = m[m.length - 1]))
|
||||||
|
@@ -24,7 +24,6 @@ import { PaperlessTask } from 'src/app/data/paperless-task'
|
|||||||
import { IfPermissionsDirective } from 'src/app/directives/if-permissions.directive'
|
import { IfPermissionsDirective } from 'src/app/directives/if-permissions.directive'
|
||||||
import { CustomDatePipe } from 'src/app/pipes/custom-date.pipe'
|
import { CustomDatePipe } from 'src/app/pipes/custom-date.pipe'
|
||||||
import { TasksService } from 'src/app/services/tasks.service'
|
import { TasksService } from 'src/app/services/tasks.service'
|
||||||
import { ToastService } from 'src/app/services/toast.service'
|
|
||||||
import { ConfirmDialogComponent } from '../../common/confirm-dialog/confirm-dialog.component'
|
import { ConfirmDialogComponent } from '../../common/confirm-dialog/confirm-dialog.component'
|
||||||
import { PageHeaderComponent } from '../../common/page-header/page-header.component'
|
import { PageHeaderComponent } from '../../common/page-header/page-header.component'
|
||||||
import { LoadingComponentWithPermissions } from '../../loading-component/loading.component'
|
import { LoadingComponentWithPermissions } from '../../loading-component/loading.component'
|
||||||
@@ -73,7 +72,6 @@ export class TasksComponent
|
|||||||
tasksService = inject(TasksService)
|
tasksService = inject(TasksService)
|
||||||
private modalService = inject(NgbModal)
|
private modalService = inject(NgbModal)
|
||||||
private readonly router = inject(Router)
|
private readonly router = inject(Router)
|
||||||
private readonly toastService = inject(ToastService)
|
|
||||||
|
|
||||||
public activeTab: TaskTab
|
public activeTab: TaskTab
|
||||||
public selectedTasks: Set<number> = new Set()
|
public selectedTasks: Set<number> = new Set()
|
||||||
@@ -156,19 +154,11 @@ export class TasksComponent
|
|||||||
modal.componentInstance.confirmClicked.pipe(first()).subscribe(() => {
|
modal.componentInstance.confirmClicked.pipe(first()).subscribe(() => {
|
||||||
modal.componentInstance.buttonsEnabled = false
|
modal.componentInstance.buttonsEnabled = false
|
||||||
modal.close()
|
modal.close()
|
||||||
this.tasksService.dismissTasks(tasks).subscribe({
|
this.tasksService.dismissTasks(tasks)
|
||||||
error: (e) => {
|
|
||||||
this.toastService.showError($localize`Error dismissing tasks`, e)
|
|
||||||
modal.componentInstance.buttonsEnabled = true
|
|
||||||
},
|
|
||||||
})
|
|
||||||
this.clearSelection()
|
this.clearSelection()
|
||||||
})
|
})
|
||||||
} else {
|
} else {
|
||||||
this.tasksService.dismissTasks(tasks).subscribe({
|
this.tasksService.dismissTasks(tasks)
|
||||||
error: (e) =>
|
|
||||||
this.toastService.showError($localize`Error dismissing task`, e),
|
|
||||||
})
|
|
||||||
this.clearSelection()
|
this.clearSelection()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@@ -41,3 +41,9 @@
|
|||||||
min-width: 140px;
|
min-width: 140px;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.btn-group-xs {
|
||||||
|
> .btn {
|
||||||
|
border-radius: 0.15rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@@ -1,18 +1,19 @@
|
|||||||
<div class="mb-3">
|
<div class="mb-3">
|
||||||
@if (title) {
|
@if (title) {
|
||||||
<label class="form-label" [for]="inputId">{{title}}</label>
|
<label [for]="inputId">{{title}}</label>
|
||||||
}
|
}
|
||||||
|
|
||||||
<div class="input-group" [class.is-invalid]="error">
|
<div class="input-group" [class.is-invalid]="error">
|
||||||
<button type="button" class="input-group-text" [style.background-color]="value" (click)="colorPicker.toggle()"> </button>
|
<span class="input-group-text" [style.background-color]="value"> </span>
|
||||||
|
|
||||||
<ng-template #popContent>
|
<ng-template #popContent>
|
||||||
<div style="min-width: 200px;" class="pb-3">
|
<div style="min-width: 200px;" class="pb-3">
|
||||||
<color-slider [color]="value" (onChangeComplete)="colorChanged($event.color.hex)"></color-slider>
|
<color-slider [color]="value" (onChangeComplete)="colorChanged($event.color.hex)"></color-slider>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</ng-template>
|
</ng-template>
|
||||||
|
|
||||||
<input #inputField class="form-control" [class.is-invalid]="error" [id]="inputId" [(ngModel)]="value" (change)="onChange(value)" [autoClose]="'outside'" [ngbPopover]="popContent" #colorPicker="ngbPopover" placement="bottom" popoverClass="shadow">
|
<input #inputField class="form-control" [class.is-invalid]="error" [id]="inputId" [(ngModel)]="value" (change)="onChange(value)" [autoClose]="'outside'" [ngbPopover]="popContent" placement="bottom" popoverClass="shadow">
|
||||||
|
|
||||||
<button class="btn btn-outline-secondary" type="button" (click)="randomize()">
|
<button class="btn btn-outline-secondary" type="button" (click)="randomize()">
|
||||||
<i-bs name="dice5"></i-bs>
|
<i-bs name="dice5"></i-bs>
|
||||||
|
@@ -42,8 +42,8 @@ describe('ColorComponent', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
it('should set swatch color', () => {
|
it('should set swatch color', () => {
|
||||||
const swatch: HTMLButtonElement = fixture.nativeElement.querySelector(
|
const swatch: HTMLSpanElement = fixture.nativeElement.querySelector(
|
||||||
'button.input-group-text'
|
'span.input-group-text'
|
||||||
)
|
)
|
||||||
expect(swatch.style.backgroundColor).toEqual('')
|
expect(swatch.style.backgroundColor).toEqual('')
|
||||||
component.value = '#ff0000'
|
component.value = '#ff0000'
|
||||||
|
@@ -1,10 +1,7 @@
|
|||||||
<div class="row pt-3 pb-3 pb-md-2 align-items-center">
|
<div class="row pt-3 pb-3 pb-md-2 align-items-center">
|
||||||
<div class="col-md text-truncate">
|
<div class="col-md text-truncate">
|
||||||
<h3 class="text-truncate d-flex align-items-center" style="line-height: 1.4">
|
<h3 class="text-truncate" style="line-height: 1.4">
|
||||||
{{title}}
|
{{title}}
|
||||||
@if (id) {
|
|
||||||
<span class="badge bg-primary text-primary-text-contrast ms-2 small fs-normal">ID: {{id}}</span>
|
|
||||||
}
|
|
||||||
@if (subTitle) {
|
@if (subTitle) {
|
||||||
<span class="h6 mb-0 d-block d-md-inline fw-normal ms-md-3 text-truncate" style="line-height: 1.4">{{subTitle}}</span>
|
<span class="h6 mb-0 d-block d-md-inline fw-normal ms-md-3 text-truncate" style="line-height: 1.4">{{subTitle}}</span>
|
||||||
}
|
}
|
||||||
|
@@ -1,10 +1,5 @@
|
|||||||
h3 {
|
h3 {
|
||||||
min-height: calc(1.325rem + 0.9vw);
|
min-height: calc(1.325rem + 0.9vw);
|
||||||
|
|
||||||
.badge {
|
|
||||||
font-size: 0.65rem;
|
|
||||||
line-height: 1;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (min-width: 1200px) {
|
@media (min-width: 1200px) {
|
||||||
|
@@ -26,9 +26,6 @@ export class PageHeaderComponent {
|
|||||||
return this._title
|
return this._title
|
||||||
}
|
}
|
||||||
|
|
||||||
@Input()
|
|
||||||
id: number
|
|
||||||
|
|
||||||
@Input()
|
@Input()
|
||||||
subTitle: string = ''
|
subTitle: string = ''
|
||||||
|
|
||||||
|
@@ -1,4 +1,4 @@
|
|||||||
<pngx-page-header [(title)]="title" [id]="documentId">
|
<pngx-page-header [(title)]="title">
|
||||||
@if (archiveContentRenderType === ContentRenderType.PDF && !useNativePdfViewer) {
|
@if (archiveContentRenderType === ContentRenderType.PDF && !useNativePdfViewer) {
|
||||||
@if (previewNumPages) {
|
@if (previewNumPages) {
|
||||||
<div class="input-group input-group-sm d-none d-md-flex">
|
<div class="input-group input-group-sm d-none d-md-flex">
|
||||||
|
@@ -1212,7 +1212,7 @@ describe('DocumentDetailComponent', () => {
|
|||||||
it('should support keyboard shortcuts', () => {
|
it('should support keyboard shortcuts', () => {
|
||||||
initNormally()
|
initNormally()
|
||||||
|
|
||||||
const hasNextSpy = jest.spyOn(component, 'hasNext').mockReturnValue(true)
|
jest.spyOn(component, 'hasNext').mockReturnValue(true)
|
||||||
const nextSpy = jest.spyOn(component, 'nextDoc')
|
const nextSpy = jest.spyOn(component, 'nextDoc')
|
||||||
document.dispatchEvent(
|
document.dispatchEvent(
|
||||||
new KeyboardEvent('keydown', { key: 'arrowright', ctrlKey: true })
|
new KeyboardEvent('keydown', { key: 'arrowright', ctrlKey: true })
|
||||||
@@ -1226,32 +1226,21 @@ describe('DocumentDetailComponent', () => {
|
|||||||
)
|
)
|
||||||
expect(prevSpy).toHaveBeenCalled()
|
expect(prevSpy).toHaveBeenCalled()
|
||||||
|
|
||||||
const isDirtySpy = jest
|
jest.spyOn(openDocumentsService, 'isDirty').mockReturnValue(true)
|
||||||
.spyOn(openDocumentsService, 'isDirty')
|
|
||||||
.mockReturnValue(true)
|
|
||||||
const saveSpy = jest.spyOn(component, 'save')
|
const saveSpy = jest.spyOn(component, 'save')
|
||||||
document.dispatchEvent(
|
document.dispatchEvent(
|
||||||
new KeyboardEvent('keydown', { key: 's', ctrlKey: true })
|
new KeyboardEvent('keydown', { key: 's', ctrlKey: true })
|
||||||
)
|
)
|
||||||
expect(saveSpy).toHaveBeenCalled()
|
expect(saveSpy).toHaveBeenCalled()
|
||||||
|
|
||||||
hasNextSpy.mockReturnValue(true)
|
jest.spyOn(openDocumentsService, 'isDirty').mockReturnValue(true)
|
||||||
|
jest.spyOn(component, 'hasNext').mockReturnValue(true)
|
||||||
const saveNextSpy = jest.spyOn(component, 'saveEditNext')
|
const saveNextSpy = jest.spyOn(component, 'saveEditNext')
|
||||||
document.dispatchEvent(
|
document.dispatchEvent(
|
||||||
new KeyboardEvent('keydown', { key: 's', ctrlKey: true, shiftKey: true })
|
new KeyboardEvent('keydown', { key: 's', ctrlKey: true, shiftKey: true })
|
||||||
)
|
)
|
||||||
expect(saveNextSpy).toHaveBeenCalled()
|
expect(saveNextSpy).toHaveBeenCalled()
|
||||||
|
|
||||||
saveSpy.mockClear()
|
|
||||||
saveNextSpy.mockClear()
|
|
||||||
isDirtySpy.mockReturnValue(true)
|
|
||||||
hasNextSpy.mockReturnValue(false)
|
|
||||||
document.dispatchEvent(
|
|
||||||
new KeyboardEvent('keydown', { key: 's', ctrlKey: true, shiftKey: true })
|
|
||||||
)
|
|
||||||
expect(saveNextSpy).not.toHaveBeenCalled()
|
|
||||||
expect(saveSpy).toHaveBeenCalledWith(true)
|
|
||||||
|
|
||||||
const closeSpy = jest.spyOn(component, 'close')
|
const closeSpy = jest.spyOn(component, 'close')
|
||||||
document.dispatchEvent(new KeyboardEvent('keydown', { key: 'escape' }))
|
document.dispatchEvent(new KeyboardEvent('keydown', { key: 'escape' }))
|
||||||
expect(closeSpy).toHaveBeenCalled()
|
expect(closeSpy).toHaveBeenCalled()
|
||||||
|
@@ -615,10 +615,7 @@ export class DocumentDetailComponent
|
|||||||
})
|
})
|
||||||
.pipe(takeUntil(this.unsubscribeNotifier))
|
.pipe(takeUntil(this.unsubscribeNotifier))
|
||||||
.subscribe(() => {
|
.subscribe(() => {
|
||||||
if (this.openDocumentService.isDirty(this.document)) {
|
if (this.openDocumentService.isDirty(this.document)) this.saveEditNext()
|
||||||
if (this.hasNext()) this.saveEditNext()
|
|
||||||
else this.save(true)
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@@ -1,144 +1,161 @@
|
|||||||
<div class="d-flex flex-wrap gap-4">
|
<div class="d-flex flex-wrap gap-4">
|
||||||
<div class="d-flex flex-wrap align-items-center gap-2" *pngxIfPermissions="{ action: PermissionAction.Change, type: PermissionType.Document }">
|
<div class="d-flex align-items-center" role="group" aria-label="Select">
|
||||||
<label class="me-2" i18n>Edit:</label>
|
<button class="btn btn-sm btn-outline-secondary" (click)="list.selectNone()">
|
||||||
@if (permissionService.currentUserCan(PermissionAction.View, PermissionType.Tag)) {
|
<i-bs name="slash-circle"></i-bs> <ng-container i18n>Cancel</ng-container>
|
||||||
<pngx-filterable-dropdown title="Tags" icon="tag-fill" i18n-title
|
|
||||||
filterPlaceholder="Filter tags" i18n-filterPlaceholder
|
|
||||||
[disabled]="!userCanEditAll || disabled"
|
|
||||||
[editing]="true"
|
|
||||||
[applyOnClose]="applyOnClose"
|
|
||||||
[createRef]="createTag.bind(this)"
|
|
||||||
(opened)="openTagsDropdown()"
|
|
||||||
[(selectionModel)]="tagSelectionModel"
|
|
||||||
[documentCounts]="tagDocumentCounts"
|
|
||||||
(apply)="setTags($event)"
|
|
||||||
shortcutKey="t">
|
|
||||||
</pngx-filterable-dropdown>
|
|
||||||
}
|
|
||||||
@if (permissionService.currentUserCan(PermissionAction.View, PermissionType.Correspondent)) {
|
|
||||||
<pngx-filterable-dropdown title="Correspondent" icon="person-fill" i18n-title
|
|
||||||
filterPlaceholder="Filter correspondents" i18n-filterPlaceholder
|
|
||||||
[disabled]="!userCanEditAll || disabled"
|
|
||||||
[editing]="true"
|
|
||||||
[applyOnClose]="applyOnClose"
|
|
||||||
[createRef]="createCorrespondent.bind(this)"
|
|
||||||
(opened)="openCorrespondentDropdown()"
|
|
||||||
[(selectionModel)]="correspondentSelectionModel"
|
|
||||||
[documentCounts]="correspondentDocumentCounts"
|
|
||||||
(apply)="setCorrespondents($event)"
|
|
||||||
shortcutKey="y">
|
|
||||||
</pngx-filterable-dropdown>
|
|
||||||
}
|
|
||||||
@if (permissionService.currentUserCan(PermissionAction.View, PermissionType.DocumentType)) {
|
|
||||||
<pngx-filterable-dropdown title="Document type" icon="file-earmark-fill" i18n-title
|
|
||||||
filterPlaceholder="Filter document types" i18n-filterPlaceholder
|
|
||||||
[disabled]="!userCanEditAll || disabled"
|
|
||||||
[editing]="true"
|
|
||||||
[applyOnClose]="applyOnClose"
|
|
||||||
[createRef]="createDocumentType.bind(this)"
|
|
||||||
(opened)="openDocumentTypeDropdown()"
|
|
||||||
[(selectionModel)]="documentTypeSelectionModel"
|
|
||||||
[documentCounts]="documentTypeDocumentCounts"
|
|
||||||
(apply)="setDocumentTypes($event)"
|
|
||||||
shortcutKey="u">
|
|
||||||
</pngx-filterable-dropdown>
|
|
||||||
}
|
|
||||||
@if (permissionService.currentUserCan(PermissionAction.View, PermissionType.StoragePath)) {
|
|
||||||
<pngx-filterable-dropdown title="Storage path" icon="folder-fill" i18n-title
|
|
||||||
filterPlaceholder="Filter storage paths" i18n-filterPlaceholder
|
|
||||||
[disabled]="!userCanEditAll || disabled"
|
|
||||||
[editing]="true"
|
|
||||||
[applyOnClose]="applyOnClose"
|
|
||||||
[createRef]="createStoragePath.bind(this)"
|
|
||||||
(opened)="openStoragePathDropdown()"
|
|
||||||
[(selectionModel)]="storagePathsSelectionModel"
|
|
||||||
[documentCounts]="storagePathDocumentCounts"
|
|
||||||
(apply)="setStoragePaths($event)"
|
|
||||||
shortcutKey="i">
|
|
||||||
</pngx-filterable-dropdown>
|
|
||||||
}
|
|
||||||
@if (permissionService.currentUserCan(PermissionAction.View, PermissionType.CustomField)) {
|
|
||||||
<pngx-filterable-dropdown title="Custom fields" icon="ui-radios" i18n-title
|
|
||||||
filterPlaceholder="Filter custom fields" i18n-filterPlaceholder
|
|
||||||
[disabled]="!userCanEditAll"
|
|
||||||
[editing]="true"
|
|
||||||
[applyOnClose]="applyOnClose"
|
|
||||||
[createRef]="createCustomField.bind(this)"
|
|
||||||
(opened)="openCustomFieldsDropdown()"
|
|
||||||
[(selectionModel)]="customFieldsSelectionModel"
|
|
||||||
[documentCounts]="customFieldDocumentCounts"
|
|
||||||
extraButtonTitle="Set values"
|
|
||||||
i18n-extraButtonTitle
|
|
||||||
(extraButton)="setCustomFieldValues($event)"
|
|
||||||
(apply)="setCustomFields($event)">
|
|
||||||
</pngx-filterable-dropdown>
|
|
||||||
}
|
|
||||||
<div class="btn-group">
|
|
||||||
<button type="button" class="btn btn-sm btn-outline-primary me-2" (click)="setPermissions()" [disabled]="!userOwnsAll || !userCanEditAll">
|
|
||||||
<i-bs name="person-fill-lock"></i-bs><div class="d-none d-sm-inline"> <ng-container i18n>Permissions</ng-container></div>
|
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
<div class="d-flex align-items-center gap-2" role="group" aria-label="Select">
|
||||||
<div class="d-flex align-items-center gap-2 ms-auto">
|
<label class="me-2" i18n>Select:</label>
|
||||||
<div class="btn-toolbar">
|
<div class="btn-group">
|
||||||
<div ngbDropdown>
|
<button class="btn btn-sm btn-outline-primary" (click)="list.selectPage()">
|
||||||
<button class="btn btn-sm btn-outline-primary" id="dropdownSelect" [disabled]="!userCanEdit && !userCanAdd" ngbDropdownToggle>
|
<i-bs name="file-earmark-check"></i-bs> <ng-container i18n>Page</ng-container>
|
||||||
<i-bs name="three-dots"></i-bs>
|
|
||||||
<div class="d-none d-sm-inline"> <ng-container i18n>Actions</ng-container></div>
|
|
||||||
</button>
|
|
||||||
<div ngbDropdownMenu aria-labelledby="dropdownSelect" class="shadow">
|
|
||||||
<button ngbDropdownItem (click)="reprocessSelected()" [disabled]="!userCanEditAll && !userCanEditAll">
|
|
||||||
<i-bs name="body-text"></i-bs> <ng-container i18n>Reprocess</ng-container>
|
|
||||||
</button>
|
</button>
|
||||||
<button ngbDropdownItem (click)="rotateSelected()" [disabled]="!userOwnsAll && !userCanEditAll">
|
<button class="btn btn-sm btn-outline-primary" (click)="list.selectAll()">
|
||||||
<i-bs name="arrow-clockwise"></i-bs> <ng-container i18n>Rotate</ng-container>
|
<i-bs name="check-all"></i-bs> <ng-container i18n>All</ng-container>
|
||||||
</button>
|
</button>
|
||||||
<button ngbDropdownItem (click)="mergeSelected()" [disabled]="!userCanAdd || list.selected.size < 2">
|
|
||||||
<i-bs name="journals"></i-bs> <ng-container i18n>Merge</ng-container>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="btn-group btn-group-sm">
|
|
||||||
<button class="btn btn-sm btn-outline-primary" [disabled]="awaitingDownload" (click)="downloadSelected()">
|
|
||||||
@if (!awaitingDownload) {
|
|
||||||
<i-bs name="arrow-down"></i-bs>
|
|
||||||
}
|
|
||||||
@if (awaitingDownload) {
|
|
||||||
<div class="spinner-border spinner-border-sm" role="status">
|
|
||||||
<span class="visually-hidden">Preparing download...</span>
|
|
||||||
</div>
|
</div>
|
||||||
}
|
|
||||||
<div class="d-none d-sm-inline"> <ng-container i18n>Download</ng-container></div>
|
|
||||||
</button>
|
|
||||||
<div ngbDropdown class="me-2 d-flex btn-group" role="group">
|
|
||||||
<button type="button" class="btn btn-sm btn-outline-primary dropdown-toggle-split rounded-end" ngbDropdownToggle></button>
|
|
||||||
<div ngbDropdownMenu aria-labelledby="dropdownSelect" class="shadow">
|
|
||||||
<form [formGroup]="downloadForm" class="px-3 py-1">
|
|
||||||
<p class="mb-1" i18n>Include:</p>
|
|
||||||
<div class="form-group ps-3 mb-2">
|
|
||||||
<div class="form-check">
|
|
||||||
<input type="checkbox" class="form-check-input" id="downloadFileType_archive" formControlName="downloadFileTypeArchive" />
|
|
||||||
<label class="form-check-label" for="downloadFileType_archive" i18n>Archived files</label>
|
|
||||||
</div>
|
|
||||||
<div class="form-check">
|
|
||||||
<input type="checkbox" class="form-check-input" id="downloadFileType_originals" formControlName="downloadFileTypeOriginals" />
|
|
||||||
<label class="form-check-label" for="downloadFileType_originals" i18n>Original files</label>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="form-check">
|
|
||||||
<input type="checkbox" class="form-check-input" id="downloadUseFormatting" formControlName="downloadUseFormatting" />
|
|
||||||
<label class="form-check-label" for="downloadUseFormatting" i18n>Use formatted filename</label>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
<div class="d-flex align-items-center gap-2" *pngxIfPermissions="{ action: PermissionAction.Change, type: PermissionType.Document }">
|
||||||
</div>
|
<label class="me-2" i18n>Edit:</label>
|
||||||
|
@if (permissionService.currentUserCan(PermissionAction.View, PermissionType.Tag)) {
|
||||||
|
<pngx-filterable-dropdown title="Tags" icon="tag-fill" i18n-title
|
||||||
|
filterPlaceholder="Filter tags" i18n-filterPlaceholder
|
||||||
|
[disabled]="!userCanEditAll || disabled"
|
||||||
|
[editing]="true"
|
||||||
|
[applyOnClose]="applyOnClose"
|
||||||
|
[createRef]="createTag.bind(this)"
|
||||||
|
(opened)="openTagsDropdown()"
|
||||||
|
[(selectionModel)]="tagSelectionModel"
|
||||||
|
[documentCounts]="tagDocumentCounts"
|
||||||
|
(apply)="setTags($event)"
|
||||||
|
shortcutKey="t">
|
||||||
|
</pngx-filterable-dropdown>
|
||||||
|
}
|
||||||
|
@if (permissionService.currentUserCan(PermissionAction.View, PermissionType.Correspondent)) {
|
||||||
|
<pngx-filterable-dropdown title="Correspondent" icon="person-fill" i18n-title
|
||||||
|
filterPlaceholder="Filter correspondents" i18n-filterPlaceholder
|
||||||
|
[disabled]="!userCanEditAll || disabled"
|
||||||
|
[editing]="true"
|
||||||
|
[applyOnClose]="applyOnClose"
|
||||||
|
[createRef]="createCorrespondent.bind(this)"
|
||||||
|
(opened)="openCorrespondentDropdown()"
|
||||||
|
[(selectionModel)]="correspondentSelectionModel"
|
||||||
|
[documentCounts]="correspondentDocumentCounts"
|
||||||
|
(apply)="setCorrespondents($event)"
|
||||||
|
shortcutKey="y">
|
||||||
|
</pngx-filterable-dropdown>
|
||||||
|
}
|
||||||
|
@if (permissionService.currentUserCan(PermissionAction.View, PermissionType.DocumentType)) {
|
||||||
|
<pngx-filterable-dropdown title="Document type" icon="file-earmark-fill" i18n-title
|
||||||
|
filterPlaceholder="Filter document types" i18n-filterPlaceholder
|
||||||
|
[disabled]="!userCanEditAll || disabled"
|
||||||
|
[editing]="true"
|
||||||
|
[applyOnClose]="applyOnClose"
|
||||||
|
[createRef]="createDocumentType.bind(this)"
|
||||||
|
(opened)="openDocumentTypeDropdown()"
|
||||||
|
[(selectionModel)]="documentTypeSelectionModel"
|
||||||
|
[documentCounts]="documentTypeDocumentCounts"
|
||||||
|
(apply)="setDocumentTypes($event)"
|
||||||
|
shortcutKey="u">
|
||||||
|
</pngx-filterable-dropdown>
|
||||||
|
}
|
||||||
|
@if (permissionService.currentUserCan(PermissionAction.View, PermissionType.StoragePath)) {
|
||||||
|
<pngx-filterable-dropdown title="Storage path" icon="folder-fill" i18n-title
|
||||||
|
filterPlaceholder="Filter storage paths" i18n-filterPlaceholder
|
||||||
|
[disabled]="!userCanEditAll || disabled"
|
||||||
|
[editing]="true"
|
||||||
|
[applyOnClose]="applyOnClose"
|
||||||
|
[createRef]="createStoragePath.bind(this)"
|
||||||
|
(opened)="openStoragePathDropdown()"
|
||||||
|
[(selectionModel)]="storagePathsSelectionModel"
|
||||||
|
[documentCounts]="storagePathDocumentCounts"
|
||||||
|
(apply)="setStoragePaths($event)"
|
||||||
|
shortcutKey="i">
|
||||||
|
</pngx-filterable-dropdown>
|
||||||
|
}
|
||||||
|
@if (permissionService.currentUserCan(PermissionAction.View, PermissionType.CustomField)) {
|
||||||
|
<pngx-filterable-dropdown title="Custom fields" icon="ui-radios" i18n-title
|
||||||
|
filterPlaceholder="Filter custom fields" i18n-filterPlaceholder
|
||||||
|
[disabled]="!userCanEditAll"
|
||||||
|
[editing]="true"
|
||||||
|
[applyOnClose]="applyOnClose"
|
||||||
|
[createRef]="createCustomField.bind(this)"
|
||||||
|
(opened)="openCustomFieldsDropdown()"
|
||||||
|
[(selectionModel)]="customFieldsSelectionModel"
|
||||||
|
[documentCounts]="customFieldDocumentCounts"
|
||||||
|
extraButtonTitle="Set values"
|
||||||
|
i18n-extraButtonTitle
|
||||||
|
(extraButton)="setCustomFieldValues($event)"
|
||||||
|
(apply)="setCustomFields($event)">
|
||||||
|
</pngx-filterable-dropdown>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
<div class="d-flex align-items-center gap-2 ms-auto">
|
||||||
|
<div class="btn-toolbar">
|
||||||
|
|
||||||
<div class="btn-group btn-group-sm">
|
<button type="button" class="btn btn-sm btn-outline-primary me-2" (click)="setPermissions()" [disabled]="!userOwnsAll || !userCanEditAll">
|
||||||
<button type="button" class="btn btn-sm btn-outline-danger" (click)="applyDelete()" *pngxIfPermissions="{ action: PermissionAction.Delete, type: PermissionType.Document }" [disabled]="!userOwnsAll">
|
<i-bs name="person-fill-lock"></i-bs><div class="d-none d-sm-inline"> <ng-container i18n>Permissions</ng-container></div>
|
||||||
<i-bs name="trash"></i-bs> <ng-container i18n>Delete</ng-container>
|
</button>
|
||||||
</button>
|
|
||||||
</div>
|
<div ngbDropdown>
|
||||||
</div>
|
<button class="btn btn-sm btn-outline-primary" id="dropdownSelect" [disabled]="!userCanEdit && !userCanAdd" ngbDropdownToggle>
|
||||||
</div>
|
<i-bs name="three-dots"></i-bs>
|
||||||
|
<div class="d-none d-sm-inline"> <ng-container i18n>Actions</ng-container></div>
|
||||||
|
</button>
|
||||||
|
<div ngbDropdownMenu aria-labelledby="dropdownSelect" class="shadow">
|
||||||
|
<button ngbDropdownItem (click)="reprocessSelected()" [disabled]="!userCanEditAll && !userCanEditAll">
|
||||||
|
<i-bs name="body-text"></i-bs> <ng-container i18n>Reprocess</ng-container>
|
||||||
|
</button>
|
||||||
|
<button ngbDropdownItem (click)="rotateSelected()" [disabled]="!userOwnsAll && !userCanEditAll">
|
||||||
|
<i-bs name="arrow-clockwise"></i-bs> <ng-container i18n>Rotate</ng-container>
|
||||||
|
</button>
|
||||||
|
<button ngbDropdownItem (click)="mergeSelected()" [disabled]="!userCanAdd || list.selected.size < 2">
|
||||||
|
<i-bs name="journals"></i-bs> <ng-container i18n>Merge</ng-container>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="btn-group btn-group-sm">
|
||||||
|
<button class="btn btn-sm btn-outline-primary" [disabled]="awaitingDownload" (click)="downloadSelected()">
|
||||||
|
@if (!awaitingDownload) {
|
||||||
|
<i-bs name="arrow-down"></i-bs>
|
||||||
|
}
|
||||||
|
@if (awaitingDownload) {
|
||||||
|
<div class="spinner-border spinner-border-sm" role="status">
|
||||||
|
<span class="visually-hidden">Preparing download...</span>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
<div class="d-none d-sm-inline"> <ng-container i18n>Download</ng-container></div>
|
||||||
|
</button>
|
||||||
|
<div ngbDropdown class="me-2 d-flex btn-group" role="group">
|
||||||
|
<button type="button" class="btn btn-sm btn-outline-primary dropdown-toggle-split rounded-end" ngbDropdownToggle></button>
|
||||||
|
<div ngbDropdownMenu aria-labelledby="dropdownSelect" class="shadow">
|
||||||
|
<form [formGroup]="downloadForm" class="px-3 py-1">
|
||||||
|
<p class="mb-1" i18n>Include:</p>
|
||||||
|
<div class="form-group ps-3 mb-2">
|
||||||
|
<div class="form-check">
|
||||||
|
<input type="checkbox" class="form-check-input" id="downloadFileType_archive" formControlName="downloadFileTypeArchive" />
|
||||||
|
<label class="form-check-label" for="downloadFileType_archive" i18n>Archived files</label>
|
||||||
|
</div>
|
||||||
|
<div class="form-check">
|
||||||
|
<input type="checkbox" class="form-check-input" id="downloadFileType_originals" formControlName="downloadFileTypeOriginals" />
|
||||||
|
<label class="form-check-label" for="downloadFileType_originals" i18n>Original files</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="form-check">
|
||||||
|
<input type="checkbox" class="form-check-input" id="downloadUseFormatting" formControlName="downloadUseFormatting" />
|
||||||
|
<label class="form-check-label" for="downloadUseFormatting" i18n>Use formatted filename</label>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="btn-group btn-group-sm">
|
||||||
|
<button type="button" class="btn btn-sm btn-outline-danger" (click)="applyDelete()" *pngxIfPermissions="{ action: PermissionAction.Delete, type: PermissionType.Document }" [disabled]="!userOwnsAll">
|
||||||
|
<i-bs name="trash"></i-bs> <ng-container i18n>Delete</ng-container>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
@@ -5,7 +5,3 @@
|
|||||||
.dropdown-menu{
|
.dropdown-menu{
|
||||||
--bs-dropdown-min-width: 12rem;
|
--bs-dropdown-min-width: 12rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn-group .btn {
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
|
@@ -1,36 +1,16 @@
|
|||||||
<pngx-page-header [title]="getTitle()">
|
<pngx-page-header [title]="getTitle()">
|
||||||
<div ngbDropdown class="btn-group flex-fill d-sm-none">
|
|
||||||
<button class="btn btn-sm btn-outline-primary" id="dropdownSelectMobile" ngbDropdownToggle>
|
<div ngbDropdown class="btn-group flex-fill">
|
||||||
|
<button class="btn btn-sm btn-outline-primary" id="dropdownSelect" ngbDropdownToggle>
|
||||||
<i-bs name="text-indent-left"></i-bs>
|
<i-bs name="text-indent-left"></i-bs>
|
||||||
<div class="d-none d-sm-inline"> <ng-container i18n>Select</ng-container></div>
|
<div class="d-none d-sm-inline"> <ng-container i18n>Select</ng-container></div>
|
||||||
@if (list.selected.size > 0) {
|
|
||||||
<pngx-clearable-badge [selected]="list.selected.size > 0" [number]="list.selected.size" (cleared)="list.selectNone()"></pngx-clearable-badge><span class="visually-hidden">selected</span>
|
|
||||||
}
|
|
||||||
</button>
|
</button>
|
||||||
<div ngbDropdownMenu aria-labelledby="dropdownSelectMobile" class="shadow">
|
<div ngbDropdownMenu aria-labelledby="dropdownSelect" class="shadow">
|
||||||
<button ngbDropdownItem (click)="list.selectNone()" i18n>Select none</button>
|
<button ngbDropdownItem (click)="list.selectNone()" i18n>Select none</button>
|
||||||
<button ngbDropdownItem (click)="list.selectPage()" i18n>Select page</button>
|
<button ngbDropdownItem (click)="list.selectPage()" i18n>Select page</button>
|
||||||
<button ngbDropdownItem (click)="list.selectAll()" i18n>Select all</button>
|
<button ngbDropdownItem (click)="list.selectAll()" i18n>Select all</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="d-none d-sm-flex flex-fill me-3">
|
|
||||||
<div class="input-group input-group-sm">
|
|
||||||
<span class="input-group-text border-0">Select:</span>
|
|
||||||
</div>
|
|
||||||
<div class="btn-group btn-group-sm flex-nowrap">
|
|
||||||
@if (list.selected.size > 0) {
|
|
||||||
<button class="btn btn-sm btn-outline-secondary" (click)="list.selectNone()">
|
|
||||||
<i-bs name="slash-circle"></i-bs> <ng-container i18n>None</ng-container>
|
|
||||||
</button>
|
|
||||||
}
|
|
||||||
<button class="btn btn-sm btn-outline-primary" (click)="list.selectPage()">
|
|
||||||
<i-bs name="file-earmark-check"></i-bs> <ng-container i18n>Page</ng-container>
|
|
||||||
</button>
|
|
||||||
<button class="btn btn-sm btn-outline-primary" (click)="list.selectAll()">
|
|
||||||
<i-bs name="check-all"></i-bs> <ng-container i18n>All</ng-container>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div ngbDropdown class="btn-group flex-fill">
|
<div ngbDropdown class="btn-group flex-fill">
|
||||||
<button class="btn btn-sm btn-outline-primary" id="dropdownDisplayFields" ngbDropdownToggle>
|
<button class="btn btn-sm btn-outline-primary" id="dropdownDisplayFields" ngbDropdownToggle>
|
||||||
<i-bs name="card-heading"></i-bs>
|
<i-bs name="card-heading"></i-bs>
|
||||||
@@ -146,13 +126,8 @@
|
|||||||
@if (!list.isReloading && isFiltered) {
|
@if (!list.isReloading && isFiltered) {
|
||||||
<button class="btn btn-link py-0" (click)="resetFilters()">
|
<button class="btn btn-link py-0" (click)="resetFilters()">
|
||||||
<i-bs width="1em" height="1em" name="x"></i-bs><small i18n>Reset filters</small>
|
<i-bs width="1em" height="1em" name="x"></i-bs><small i18n>Reset filters</small>
|
||||||
</button>
|
</button>
|
||||||
}
|
}
|
||||||
@if (!list.isReloading && list.selected.size > 0) {
|
|
||||||
<button class="btn btn-link py-0" (click)="list.selectNone()">
|
|
||||||
<i-bs width="1em" height="1em" name="slash-circle" class="me-1"></i-bs><small i18n>Clear selection</small>
|
|
||||||
</button>
|
|
||||||
}
|
|
||||||
</div>
|
</div>
|
||||||
@if (list.collectionSize) {
|
@if (list.collectionSize) {
|
||||||
<ngb-pagination [pageSize]="list.pageSize" [collectionSize]="list.collectionSize" [(page)]="list.currentPage" [maxSize]="5"
|
<ngb-pagination [pageSize]="list.pageSize" [collectionSize]="list.collectionSize" [(page)]="list.currentPage" [maxSize]="5"
|
||||||
|
@@ -56,7 +56,6 @@ import {
|
|||||||
filterRulesDiffer,
|
filterRulesDiffer,
|
||||||
isFullTextFilterRule,
|
isFullTextFilterRule,
|
||||||
} from 'src/app/utils/filter-rules'
|
} from 'src/app/utils/filter-rules'
|
||||||
import { ClearableBadgeComponent } from '../common/clearable-badge/clearable-badge.component'
|
|
||||||
import { CustomFieldDisplayComponent } from '../common/custom-field-display/custom-field-display.component'
|
import { CustomFieldDisplayComponent } from '../common/custom-field-display/custom-field-display.component'
|
||||||
import { PageHeaderComponent } from '../common/page-header/page-header.component'
|
import { PageHeaderComponent } from '../common/page-header/page-header.component'
|
||||||
import { PreviewPopupComponent } from '../common/preview-popup/preview-popup.component'
|
import { PreviewPopupComponent } from '../common/preview-popup/preview-popup.component'
|
||||||
@@ -73,7 +72,6 @@ import { SaveViewConfigDialogComponent } from './save-view-config-dialog/save-vi
|
|||||||
templateUrl: './document-list.component.html',
|
templateUrl: './document-list.component.html',
|
||||||
styleUrls: ['./document-list.component.scss'],
|
styleUrls: ['./document-list.component.scss'],
|
||||||
imports: [
|
imports: [
|
||||||
ClearableBadgeComponent,
|
|
||||||
CustomFieldDisplayComponent,
|
CustomFieldDisplayComponent,
|
||||||
PageHeaderComponent,
|
PageHeaderComponent,
|
||||||
BulkEditorComponent,
|
BulkEditorComponent,
|
||||||
|
@@ -51,7 +51,7 @@ describe('TasksService', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
it('calls acknowledge_tasks api endpoint on dismiss and reloads', () => {
|
it('calls acknowledge_tasks api endpoint on dismiss and reloads', () => {
|
||||||
tasksService.dismissTasks(new Set([1, 2, 3])).subscribe()
|
tasksService.dismissTasks(new Set([1, 2, 3]))
|
||||||
const req = httpTestingController.expectOne(
|
const req = httpTestingController.expectOne(
|
||||||
`${environment.apiBaseUrl}tasks/acknowledge/`
|
`${environment.apiBaseUrl}tasks/acknowledge/`
|
||||||
)
|
)
|
||||||
|
@@ -1,7 +1,7 @@
|
|||||||
import { HttpClient } from '@angular/common/http'
|
import { HttpClient } from '@angular/common/http'
|
||||||
import { Injectable, inject } from '@angular/core'
|
import { Injectable, inject } from '@angular/core'
|
||||||
import { Observable, Subject } from 'rxjs'
|
import { Observable, Subject } from 'rxjs'
|
||||||
import { first, takeUntil, tap } from 'rxjs/operators'
|
import { first, takeUntil } from 'rxjs/operators'
|
||||||
import {
|
import {
|
||||||
PaperlessTask,
|
PaperlessTask,
|
||||||
PaperlessTaskName,
|
PaperlessTaskName,
|
||||||
@@ -68,17 +68,14 @@ export class TasksService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public dismissTasks(task_ids: Set<number>) {
|
public dismissTasks(task_ids: Set<number>) {
|
||||||
return this.http
|
this.http
|
||||||
.post(`${this.baseUrl}tasks/acknowledge/`, {
|
.post(`${this.baseUrl}tasks/acknowledge/`, {
|
||||||
tasks: [...task_ids],
|
tasks: [...task_ids],
|
||||||
})
|
})
|
||||||
.pipe(
|
.pipe(first())
|
||||||
first(),
|
.subscribe((r) => {
|
||||||
takeUntil(this.unsubscribeNotifer),
|
this.reload()
|
||||||
tap(() => {
|
})
|
||||||
this.reload()
|
|
||||||
})
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public cancelPending(): void {
|
public cancelPending(): void {
|
||||||
|
@@ -161,21 +161,3 @@ class PaperlessNotePermissions(BasePermission):
|
|||||||
perms = self.perms_map[request.method]
|
perms = self.perms_map[request.method]
|
||||||
|
|
||||||
return request.user.has_perms(perms)
|
return request.user.has_perms(perms)
|
||||||
|
|
||||||
|
|
||||||
class AcknowledgeTasksPermissions(BasePermission):
|
|
||||||
"""
|
|
||||||
Permissions class that checks for model permissions for acknowledging tasks.
|
|
||||||
"""
|
|
||||||
|
|
||||||
perms_map = {
|
|
||||||
"POST": ["documents.change_paperlesstask"],
|
|
||||||
}
|
|
||||||
|
|
||||||
def has_permission(self, request, view):
|
|
||||||
if not request.user or not request.user.is_authenticated: # pragma: no cover
|
|
||||||
return False
|
|
||||||
|
|
||||||
perms = self.perms_map.get(request.method, [])
|
|
||||||
|
|
||||||
return request.user.has_perms(perms)
|
|
||||||
|
@@ -76,9 +76,7 @@ def check_sanity(*, progress=False, scheduled=True) -> SanityCheckMessages:
|
|||||||
messages = SanityCheckMessages()
|
messages = SanityCheckMessages()
|
||||||
|
|
||||||
present_files = {
|
present_files = {
|
||||||
x.resolve()
|
x.resolve() for x in Path(settings.MEDIA_ROOT).glob("**/*") if not x.is_dir()
|
||||||
for x in Path(settings.MEDIA_ROOT).glob("**/*")
|
|
||||||
if not x.is_dir() and x.name not in settings.IGNORABLE_FILES
|
|
||||||
}
|
}
|
||||||
|
|
||||||
lockfile = Path(settings.MEDIA_LOCK).resolve()
|
lockfile = Path(settings.MEDIA_LOCK).resolve()
|
||||||
|
@@ -6,7 +6,6 @@ import re
|
|||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from decimal import Decimal
|
from decimal import Decimal
|
||||||
from typing import TYPE_CHECKING
|
from typing import TYPE_CHECKING
|
||||||
from typing import Literal
|
|
||||||
|
|
||||||
import magic
|
import magic
|
||||||
from celery import states
|
from celery import states
|
||||||
@@ -253,35 +252,6 @@ class OwnedObjectSerializer(
|
|||||||
except KeyError:
|
except KeyError:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
def _get_perms(self, obj, codename: str, target: Literal["users", "groups"]):
|
|
||||||
"""
|
|
||||||
Get the given permissions from context or from django-guardian.
|
|
||||||
|
|
||||||
:param codename: The permission codename, e.g. 'view' or 'change'
|
|
||||||
:param target: 'users' or 'groups'
|
|
||||||
"""
|
|
||||||
key = f"{target}_{codename}_perms"
|
|
||||||
cached = self.context.get(key, {}).get(obj.pk)
|
|
||||||
if cached is not None:
|
|
||||||
return list(cached)
|
|
||||||
|
|
||||||
# Permission not found in the context, get it from guardian
|
|
||||||
if target == "users":
|
|
||||||
return list(
|
|
||||||
get_users_with_perms(
|
|
||||||
obj,
|
|
||||||
only_with_perms_in=[f"{codename}_{obj.__class__.__name__.lower()}"],
|
|
||||||
with_group_users=False,
|
|
||||||
).values_list("id", flat=True),
|
|
||||||
)
|
|
||||||
else: # groups
|
|
||||||
return list(
|
|
||||||
get_groups_with_only_permission(
|
|
||||||
obj,
|
|
||||||
codename=f"{codename}_{obj.__class__.__name__.lower()}",
|
|
||||||
).values_list("id", flat=True),
|
|
||||||
)
|
|
||||||
|
|
||||||
@extend_schema_field(
|
@extend_schema_field(
|
||||||
field={
|
field={
|
||||||
"type": "object",
|
"type": "object",
|
||||||
@@ -316,14 +286,31 @@ class OwnedObjectSerializer(
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
def get_permissions(self, obj) -> dict:
|
def get_permissions(self, obj) -> dict:
|
||||||
|
view_codename = f"view_{obj.__class__.__name__.lower()}"
|
||||||
|
change_codename = f"change_{obj.__class__.__name__.lower()}"
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"view": {
|
"view": {
|
||||||
"users": self._get_perms(obj, "view", "users"),
|
"users": get_users_with_perms(
|
||||||
"groups": self._get_perms(obj, "view", "groups"),
|
obj,
|
||||||
|
only_with_perms_in=[view_codename],
|
||||||
|
with_group_users=False,
|
||||||
|
).values_list("id", flat=True),
|
||||||
|
"groups": get_groups_with_only_permission(
|
||||||
|
obj,
|
||||||
|
codename=view_codename,
|
||||||
|
).values_list("id", flat=True),
|
||||||
},
|
},
|
||||||
"change": {
|
"change": {
|
||||||
"users": self._get_perms(obj, "change", "users"),
|
"users": get_users_with_perms(
|
||||||
"groups": self._get_perms(obj, "change", "groups"),
|
obj,
|
||||||
|
only_with_perms_in=[change_codename],
|
||||||
|
with_group_users=False,
|
||||||
|
).values_list("id", flat=True),
|
||||||
|
"groups": get_groups_with_only_permission(
|
||||||
|
obj,
|
||||||
|
codename=change_codename,
|
||||||
|
).values_list("id", flat=True),
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@@ -135,44 +135,6 @@ class TestTasks(DirectoriesMixin, APITestCase):
|
|||||||
response = self.client.get(self.ENDPOINT + "?acknowledged=false")
|
response = self.client.get(self.ENDPOINT + "?acknowledged=false")
|
||||||
self.assertEqual(len(response.data), 0)
|
self.assertEqual(len(response.data), 0)
|
||||||
|
|
||||||
def test_acknowledge_tasks_requires_change_permission(self):
|
|
||||||
"""
|
|
||||||
GIVEN:
|
|
||||||
- A regular user initially without change permissions
|
|
||||||
- A regular user with change permissions
|
|
||||||
WHEN:
|
|
||||||
- API call is made to acknowledge tasks
|
|
||||||
THEN:
|
|
||||||
- The first user is forbidden from acknowledging tasks
|
|
||||||
- The second user is allowed to acknowledge tasks
|
|
||||||
"""
|
|
||||||
regular_user = User.objects.create_user(username="test")
|
|
||||||
self.client.force_authenticate(user=regular_user)
|
|
||||||
|
|
||||||
task = PaperlessTask.objects.create(
|
|
||||||
task_id=str(uuid.uuid4()),
|
|
||||||
task_file_name="task_one.pdf",
|
|
||||||
)
|
|
||||||
|
|
||||||
response = self.client.post(
|
|
||||||
self.ENDPOINT + "acknowledge/",
|
|
||||||
{"tasks": [task.id]},
|
|
||||||
)
|
|
||||||
self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN)
|
|
||||||
|
|
||||||
regular_user2 = User.objects.create_user(username="test2")
|
|
||||||
regular_user2.user_permissions.add(
|
|
||||||
Permission.objects.get(codename="change_paperlesstask"),
|
|
||||||
)
|
|
||||||
regular_user2.save()
|
|
||||||
self.client.force_authenticate(user=regular_user2)
|
|
||||||
|
|
||||||
response = self.client.post(
|
|
||||||
self.ENDPOINT + "acknowledge/",
|
|
||||||
{"tasks": [task.id]},
|
|
||||||
)
|
|
||||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
|
||||||
|
|
||||||
def test_tasks_owner_aware(self):
|
def test_tasks_owner_aware(self):
|
||||||
"""
|
"""
|
||||||
GIVEN:
|
GIVEN:
|
||||||
|
@@ -169,13 +169,6 @@ class TestSanityCheck(DirectoriesMixin, TestCase):
|
|||||||
messages = check_sanity()
|
messages = check_sanity()
|
||||||
self.assertFalse(messages.has_warning)
|
self.assertFalse(messages.has_warning)
|
||||||
|
|
||||||
def test_ignore_ignorable_files(self):
|
|
||||||
self.make_test_data()
|
|
||||||
Path(self.dirs.media_dir, ".DS_Store").touch()
|
|
||||||
Path(self.dirs.media_dir, "desktop.ini").touch()
|
|
||||||
messages = check_sanity()
|
|
||||||
self.assertFalse(messages.has_warning)
|
|
||||||
|
|
||||||
def test_archive_filename_no_checksum(self):
|
def test_archive_filename_no_checksum(self):
|
||||||
doc = self.make_test_data()
|
doc = self.make_test_data()
|
||||||
doc.archive_checksum = None
|
doc.archive_checksum = None
|
||||||
|
@@ -1,23 +1,17 @@
|
|||||||
import json
|
|
||||||
import tempfile
|
import tempfile
|
||||||
from datetime import timedelta
|
from datetime import timedelta
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.contrib.auth.models import Group
|
|
||||||
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.db import connection
|
|
||||||
from django.test import TestCase
|
from django.test import TestCase
|
||||||
from django.test import override_settings
|
from django.test import override_settings
|
||||||
from django.test.utils import CaptureQueriesContext
|
|
||||||
from django.utils import timezone
|
from django.utils import timezone
|
||||||
from guardian.shortcuts import assign_perm
|
|
||||||
from rest_framework import status
|
from rest_framework import status
|
||||||
|
|
||||||
from documents.models import Document
|
from documents.models import Document
|
||||||
from documents.models import ShareLink
|
from documents.models import ShareLink
|
||||||
from documents.models import Tag
|
|
||||||
from documents.tests.utils import DirectoriesMixin
|
from documents.tests.utils import DirectoriesMixin
|
||||||
from paperless.models import ApplicationConfiguration
|
from paperless.models import ApplicationConfiguration
|
||||||
|
|
||||||
@@ -160,113 +154,3 @@ class TestViews(DirectoriesMixin, TestCase):
|
|||||||
response.render()
|
response.render()
|
||||||
self.assertEqual(response.request["PATH_INFO"], "/accounts/login/")
|
self.assertEqual(response.request["PATH_INFO"], "/accounts/login/")
|
||||||
self.assertContains(response, b"Share link has expired")
|
self.assertContains(response, b"Share link has expired")
|
||||||
|
|
||||||
def test_list_with_full_permissions(self):
|
|
||||||
"""
|
|
||||||
GIVEN:
|
|
||||||
- Tags with different permissions
|
|
||||||
WHEN:
|
|
||||||
- Request to get tag list with full permissions is made
|
|
||||||
THEN:
|
|
||||||
- Tag list is returned with the right permission information
|
|
||||||
"""
|
|
||||||
user2 = User.objects.create(username="user2")
|
|
||||||
user3 = User.objects.create(username="user3")
|
|
||||||
group1 = Group.objects.create(name="group1")
|
|
||||||
group2 = Group.objects.create(name="group2")
|
|
||||||
group3 = Group.objects.create(name="group3")
|
|
||||||
t1 = Tag.objects.create(name="invoice", pk=1)
|
|
||||||
assign_perm("view_tag", self.user, t1)
|
|
||||||
assign_perm("view_tag", user2, t1)
|
|
||||||
assign_perm("view_tag", user3, t1)
|
|
||||||
assign_perm("view_tag", group1, t1)
|
|
||||||
assign_perm("view_tag", group2, t1)
|
|
||||||
assign_perm("view_tag", group3, t1)
|
|
||||||
assign_perm("change_tag", self.user, t1)
|
|
||||||
assign_perm("change_tag", user2, t1)
|
|
||||||
assign_perm("change_tag", group1, t1)
|
|
||||||
assign_perm("change_tag", group2, t1)
|
|
||||||
|
|
||||||
Tag.objects.create(name="bank statement", pk=2)
|
|
||||||
d1 = Document.objects.create(
|
|
||||||
title="Invoice 1",
|
|
||||||
content="This is the invoice of a very expensive item",
|
|
||||||
checksum="A",
|
|
||||||
)
|
|
||||||
d1.tags.add(t1)
|
|
||||||
d2 = Document.objects.create(
|
|
||||||
title="Invoice 2",
|
|
||||||
content="Internet invoice, I should pay it to continue contributing",
|
|
||||||
checksum="B",
|
|
||||||
)
|
|
||||||
d2.tags.add(t1)
|
|
||||||
|
|
||||||
view_permissions = Permission.objects.filter(
|
|
||||||
codename__contains="view_tag",
|
|
||||||
)
|
|
||||||
self.user.user_permissions.add(*view_permissions)
|
|
||||||
self.user.save()
|
|
||||||
|
|
||||||
self.client.force_login(self.user)
|
|
||||||
response = self.client.get("/api/tags/?page=1&full_perms=true")
|
|
||||||
results = json.loads(response.content)["results"]
|
|
||||||
for tag in results:
|
|
||||||
if tag["name"] == "invoice":
|
|
||||||
assert tag["permissions"] == {
|
|
||||||
"view": {
|
|
||||||
"users": [self.user.pk, user2.pk, user3.pk],
|
|
||||||
"groups": [group1.pk, group2.pk, group3.pk],
|
|
||||||
},
|
|
||||||
"change": {
|
|
||||||
"users": [self.user.pk, user2.pk],
|
|
||||||
"groups": [group1.pk, group2.pk],
|
|
||||||
},
|
|
||||||
}
|
|
||||||
elif tag["name"] == "bank statement":
|
|
||||||
assert tag["permissions"] == {
|
|
||||||
"view": {"users": [], "groups": []},
|
|
||||||
"change": {"users": [], "groups": []},
|
|
||||||
}
|
|
||||||
else:
|
|
||||||
assert False, f"Unexpected tag found: {tag['name']}"
|
|
||||||
|
|
||||||
def test_list_no_n_plus_1_queries(self):
|
|
||||||
"""
|
|
||||||
GIVEN:
|
|
||||||
- Tags with different permissions
|
|
||||||
WHEN:
|
|
||||||
- Request to get tag list with full permissions is made
|
|
||||||
THEN:
|
|
||||||
- Permissions are not queried in database tag by tag,
|
|
||||||
i.e. there are no N+1 queries
|
|
||||||
"""
|
|
||||||
view_permissions = Permission.objects.filter(
|
|
||||||
codename__contains="view_tag",
|
|
||||||
)
|
|
||||||
self.user.user_permissions.add(*view_permissions)
|
|
||||||
self.user.save()
|
|
||||||
self.client.force_login(self.user)
|
|
||||||
|
|
||||||
# Start by a small list, and count the number of SQL queries
|
|
||||||
for i in range(2):
|
|
||||||
Tag.objects.create(name=f"tag_{i}")
|
|
||||||
|
|
||||||
with CaptureQueriesContext(connection) as ctx_small:
|
|
||||||
response_small = self.client.get("/api/tags/?full_perms=true")
|
|
||||||
assert response_small.status_code == 200
|
|
||||||
num_queries_small = len(ctx_small.captured_queries)
|
|
||||||
|
|
||||||
# Complete the list, and count the number of SQL queries again
|
|
||||||
for i in range(2, 50):
|
|
||||||
Tag.objects.create(name=f"tag_{i}")
|
|
||||||
|
|
||||||
with CaptureQueriesContext(connection) as ctx_large:
|
|
||||||
response_large = self.client.get("/api/tags/?full_perms=true")
|
|
||||||
assert response_large.status_code == 200
|
|
||||||
num_queries_large = len(ctx_large.captured_queries)
|
|
||||||
|
|
||||||
# A few additional queries are allowed, but not a linear explosion
|
|
||||||
assert num_queries_large <= num_queries_small + 5, (
|
|
||||||
f"Possible N+1 queries detected: {num_queries_small} queries for 2 tags, "
|
|
||||||
f"but {num_queries_large} queries for 50 tags"
|
|
||||||
)
|
|
||||||
|
@@ -5,11 +5,9 @@ import platform
|
|||||||
import re
|
import re
|
||||||
import tempfile
|
import tempfile
|
||||||
import zipfile
|
import zipfile
|
||||||
from collections import defaultdict
|
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from time import mktime
|
from time import mktime
|
||||||
from typing import Literal
|
|
||||||
from unicodedata import normalize
|
from unicodedata import normalize
|
||||||
from urllib.parse import quote
|
from urllib.parse import quote
|
||||||
from urllib.parse import urlparse
|
from urllib.parse import urlparse
|
||||||
@@ -21,7 +19,6 @@ from celery import states
|
|||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.contrib.auth.models import Group
|
from django.contrib.auth.models import Group
|
||||||
from django.contrib.auth.models import User
|
from django.contrib.auth.models import User
|
||||||
from django.contrib.contenttypes.models import ContentType
|
|
||||||
from django.db import connections
|
from django.db import connections
|
||||||
from django.db.migrations.loader import MigrationLoader
|
from django.db.migrations.loader import MigrationLoader
|
||||||
from django.db.migrations.recorder import MigrationRecorder
|
from django.db.migrations.recorder import MigrationRecorder
|
||||||
@@ -59,8 +56,6 @@ from drf_spectacular.utils import OpenApiParameter
|
|||||||
from drf_spectacular.utils import extend_schema
|
from drf_spectacular.utils import extend_schema
|
||||||
from drf_spectacular.utils import extend_schema_view
|
from drf_spectacular.utils import extend_schema_view
|
||||||
from drf_spectacular.utils import inline_serializer
|
from drf_spectacular.utils import inline_serializer
|
||||||
from guardian.utils import get_group_obj_perms_model
|
|
||||||
from guardian.utils import get_user_obj_perms_model
|
|
||||||
from langdetect import detect
|
from langdetect import detect
|
||||||
from packaging import version as packaging_version
|
from packaging import version as packaging_version
|
||||||
from redis import Redis
|
from redis import Redis
|
||||||
@@ -136,7 +131,6 @@ from documents.models import WorkflowAction
|
|||||||
from documents.models import WorkflowTrigger
|
from documents.models import WorkflowTrigger
|
||||||
from documents.parsers import get_parser_class_for_mime_type
|
from documents.parsers import get_parser_class_for_mime_type
|
||||||
from documents.parsers import parse_date_generator
|
from documents.parsers import parse_date_generator
|
||||||
from documents.permissions import AcknowledgeTasksPermissions
|
|
||||||
from documents.permissions import PaperlessAdminPermissions
|
from documents.permissions import PaperlessAdminPermissions
|
||||||
from documents.permissions import PaperlessNotePermissions
|
from documents.permissions import PaperlessNotePermissions
|
||||||
from documents.permissions import PaperlessObjectPermissions
|
from documents.permissions import PaperlessObjectPermissions
|
||||||
@@ -260,104 +254,7 @@ class PassUserMixin(GenericAPIView):
|
|||||||
return super().get_serializer(*args, **kwargs)
|
return super().get_serializer(*args, **kwargs)
|
||||||
|
|
||||||
|
|
||||||
class BulkPermissionMixin:
|
class PermissionsAwareDocumentCountMixin(PassUserMixin):
|
||||||
"""
|
|
||||||
Prefetch Django-Guardian permissions for a list before serialization, to avoid N+1 queries.
|
|
||||||
"""
|
|
||||||
|
|
||||||
def _get_object_perms(
|
|
||||||
self,
|
|
||||||
objects: list,
|
|
||||||
perm_codenames: list[str],
|
|
||||||
actor: Literal["users", "groups"],
|
|
||||||
) -> dict[int, dict[str, list[int]]]:
|
|
||||||
"""
|
|
||||||
Collect object-level permissions for either users or groups.
|
|
||||||
"""
|
|
||||||
model = self.queryset.model
|
|
||||||
obj_perm_model = (
|
|
||||||
get_user_obj_perms_model(model)
|
|
||||||
if actor == "users"
|
|
||||||
else get_group_obj_perms_model(model)
|
|
||||||
)
|
|
||||||
id_field = "user_id" if actor == "users" else "group_id"
|
|
||||||
ctype = ContentType.objects.get_for_model(model)
|
|
||||||
object_pks = [obj.pk for obj in objects]
|
|
||||||
|
|
||||||
perms_qs = obj_perm_model.objects.filter(
|
|
||||||
content_type=ctype,
|
|
||||||
object_pk__in=object_pks,
|
|
||||||
permission__codename__in=perm_codenames,
|
|
||||||
).values_list("object_pk", id_field, "permission__codename")
|
|
||||||
|
|
||||||
perms: dict[int, dict[str, list[int]]] = defaultdict(lambda: defaultdict(list))
|
|
||||||
for object_pk, actor_id, codename in perms_qs:
|
|
||||||
perms[int(object_pk)][codename].append(actor_id)
|
|
||||||
|
|
||||||
# Ensure that all objects have all codenames, even if empty
|
|
||||||
for pk in object_pks:
|
|
||||||
for codename in perm_codenames:
|
|
||||||
perms[pk][codename]
|
|
||||||
|
|
||||||
return perms
|
|
||||||
|
|
||||||
def get_serializer_context(self):
|
|
||||||
"""
|
|
||||||
Get all permissions of the current list of objects at once and pass them to the serializer.
|
|
||||||
This avoid fetching permissions object by object in database.
|
|
||||||
"""
|
|
||||||
context = super().get_serializer_context()
|
|
||||||
try:
|
|
||||||
full_perms = get_boolean(
|
|
||||||
str(self.request.query_params.get("full_perms", "false")),
|
|
||||||
)
|
|
||||||
except ValueError:
|
|
||||||
full_perms = False
|
|
||||||
|
|
||||||
if not full_perms:
|
|
||||||
return context
|
|
||||||
|
|
||||||
# Check which objects are being paginated
|
|
||||||
page = getattr(self, "paginator", None)
|
|
||||||
if page and hasattr(page, "page"):
|
|
||||||
queryset = page.page.object_list
|
|
||||||
elif hasattr(self, "page"):
|
|
||||||
queryset = self.page
|
|
||||||
else:
|
|
||||||
queryset = self.filter_queryset(self.get_queryset())
|
|
||||||
|
|
||||||
model_name = self.queryset.model.__name__.lower()
|
|
||||||
permission_name_view = f"view_{model_name}"
|
|
||||||
permission_name_change = f"change_{model_name}"
|
|
||||||
|
|
||||||
user_perms = self._get_object_perms(
|
|
||||||
objects=queryset,
|
|
||||||
perm_codenames=[permission_name_view, permission_name_change],
|
|
||||||
actor="users",
|
|
||||||
)
|
|
||||||
group_perms = self._get_object_perms(
|
|
||||||
objects=queryset,
|
|
||||||
perm_codenames=[permission_name_view, permission_name_change],
|
|
||||||
actor="groups",
|
|
||||||
)
|
|
||||||
|
|
||||||
context["users_view_perms"] = {
|
|
||||||
pk: user_perms[pk][permission_name_view] for pk in user_perms
|
|
||||||
}
|
|
||||||
context["users_change_perms"] = {
|
|
||||||
pk: user_perms[pk][permission_name_change] for pk in user_perms
|
|
||||||
}
|
|
||||||
context["groups_view_perms"] = {
|
|
||||||
pk: group_perms[pk][permission_name_view] for pk in group_perms
|
|
||||||
}
|
|
||||||
context["groups_change_perms"] = {
|
|
||||||
pk: group_perms[pk][permission_name_change] for pk in group_perms
|
|
||||||
}
|
|
||||||
|
|
||||||
return context
|
|
||||||
|
|
||||||
|
|
||||||
class PermissionsAwareDocumentCountMixin(BulkPermissionMixin, PassUserMixin):
|
|
||||||
"""
|
"""
|
||||||
Mixin to add document count to queryset, permissions-aware if needed
|
Mixin to add document count to queryset, permissions-aware if needed
|
||||||
"""
|
"""
|
||||||
@@ -2488,11 +2385,7 @@ class TasksViewSet(ReadOnlyModelViewSet):
|
|||||||
queryset = PaperlessTask.objects.filter(task_id=task_id)
|
queryset = PaperlessTask.objects.filter(task_id=task_id)
|
||||||
return queryset
|
return queryset
|
||||||
|
|
||||||
@action(
|
@action(methods=["post"], detail=False)
|
||||||
methods=["post"],
|
|
||||||
detail=False,
|
|
||||||
permission_classes=[IsAuthenticated, AcknowledgeTasksPermissions],
|
|
||||||
)
|
|
||||||
def acknowledge(self, request):
|
def acknowledge(self, request):
|
||||||
serializer = AcknowledgeTasksViewSerializer(data=request.data)
|
serializer = AcknowledgeTasksViewSerializer(data=request.data)
|
||||||
serializer.is_valid(raise_exception=True)
|
serializer.is_valid(raise_exception=True)
|
||||||
|
@@ -2,7 +2,7 @@ msgid ""
|
|||||||
msgstr ""
|
msgstr ""
|
||||||
"Project-Id-Version: paperless-ngx\n"
|
"Project-Id-Version: paperless-ngx\n"
|
||||||
"Report-Msgid-Bugs-To: \n"
|
"Report-Msgid-Bugs-To: \n"
|
||||||
"POT-Creation-Date: 2025-09-30 16:50+0000\n"
|
"POT-Creation-Date: 2025-09-22 18:20+0000\n"
|
||||||
"PO-Revision-Date: 2022-02-17 04:17\n"
|
"PO-Revision-Date: 2022-02-17 04:17\n"
|
||||||
"Last-Translator: \n"
|
"Last-Translator: \n"
|
||||||
"Language-Team: English\n"
|
"Language-Team: English\n"
|
||||||
@@ -1191,44 +1191,44 @@ msgstr ""
|
|||||||
msgid "workflow runs"
|
msgid "workflow runs"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: documents/serialisers.py:141
|
#: documents/serialisers.py:140
|
||||||
#, python-format
|
#, python-format
|
||||||
msgid "Invalid regular expression: %(error)s"
|
msgid "Invalid regular expression: %(error)s"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: documents/serialisers.py:607
|
#: documents/serialisers.py:594
|
||||||
msgid "Invalid color."
|
msgid "Invalid color."
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: documents/serialisers.py:636
|
#: documents/serialisers.py:623
|
||||||
msgid "Invalid parent tag."
|
msgid "Invalid parent tag."
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: documents/serialisers.py:1793
|
#: documents/serialisers.py:1780
|
||||||
#, python-format
|
#, python-format
|
||||||
msgid "File type %(type)s not supported"
|
msgid "File type %(type)s not supported"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: documents/serialisers.py:1837
|
#: documents/serialisers.py:1824
|
||||||
#, python-format
|
#, python-format
|
||||||
msgid "Custom field id must be an integer: %(id)s"
|
msgid "Custom field id must be an integer: %(id)s"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: documents/serialisers.py:1844
|
#: documents/serialisers.py:1831
|
||||||
#, python-format
|
#, python-format
|
||||||
msgid "Custom field with id %(id)s does not exist"
|
msgid "Custom field with id %(id)s does not exist"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: documents/serialisers.py:1861 documents/serialisers.py:1871
|
#: documents/serialisers.py:1848 documents/serialisers.py:1858
|
||||||
msgid ""
|
msgid ""
|
||||||
"Custom fields must be a list of integers or an object mapping ids to values."
|
"Custom fields must be a list of integers or an object mapping ids to values."
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: documents/serialisers.py:1866
|
#: documents/serialisers.py:1853
|
||||||
msgid "Some custom fields don't exist or were specified twice."
|
msgid "Some custom fields don't exist or were specified twice."
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: documents/serialisers.py:1936
|
#: documents/serialisers.py:1923
|
||||||
msgid "Invalid variable detected."
|
msgid "Invalid variable detected."
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
|
@@ -1003,18 +1003,6 @@ THREADS_PER_WORKER = os.getenv(
|
|||||||
# Paperless Specific Settings #
|
# Paperless Specific Settings #
|
||||||
###############################################################################
|
###############################################################################
|
||||||
|
|
||||||
IGNORABLE_FILES: Final[list[str]] = [
|
|
||||||
".DS_Store",
|
|
||||||
".DS_STORE",
|
|
||||||
"._*",
|
|
||||||
".stfolder/*",
|
|
||||||
".stversions/*",
|
|
||||||
".localized/*",
|
|
||||||
"desktop.ini",
|
|
||||||
"@eaDir/*",
|
|
||||||
"Thumbs.db",
|
|
||||||
]
|
|
||||||
|
|
||||||
CONSUMER_POLLING = int(os.getenv("PAPERLESS_CONSUMER_POLLING", 0))
|
CONSUMER_POLLING = int(os.getenv("PAPERLESS_CONSUMER_POLLING", 0))
|
||||||
|
|
||||||
CONSUMER_POLLING_DELAY = int(os.getenv("PAPERLESS_CONSUMER_POLLING_DELAY", 5))
|
CONSUMER_POLLING_DELAY = int(os.getenv("PAPERLESS_CONSUMER_POLLING_DELAY", 5))
|
||||||
@@ -1037,7 +1025,7 @@ CONSUMER_IGNORE_PATTERNS = list(
|
|||||||
json.loads(
|
json.loads(
|
||||||
os.getenv(
|
os.getenv(
|
||||||
"PAPERLESS_CONSUMER_IGNORE_PATTERNS",
|
"PAPERLESS_CONSUMER_IGNORE_PATTERNS",
|
||||||
json.dumps(IGNORABLE_FILES),
|
'[".DS_Store", ".DS_STORE", "._*", ".stfolder/*", ".stversions/*", ".localized/*", "desktop.ini", "@eaDir/*", "Thumbs.db"]',
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
Reference in New Issue
Block a user