mirror of
https://github.com/paperless-ngx/paperless-ngx.git
synced 2026-03-12 20:21:23 +00:00
Compare commits
1 Commits
dev
...
feature-se
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e55bb7b5f3 |
24
.github/dependabot.yml
vendored
24
.github/dependabot.yml
vendored
@@ -12,8 +12,6 @@ updates:
|
|||||||
open-pull-requests-limit: 10
|
open-pull-requests-limit: 10
|
||||||
schedule:
|
schedule:
|
||||||
interval: "monthly"
|
interval: "monthly"
|
||||||
cooldown:
|
|
||||||
default-days: 7
|
|
||||||
labels:
|
labels:
|
||||||
- "frontend"
|
- "frontend"
|
||||||
- "dependencies"
|
- "dependencies"
|
||||||
@@ -38,9 +36,7 @@ updates:
|
|||||||
directory: "/"
|
directory: "/"
|
||||||
# Check for updates once a week
|
# Check for updates once a week
|
||||||
schedule:
|
schedule:
|
||||||
interval: "monthly"
|
interval: "weekly"
|
||||||
cooldown:
|
|
||||||
default-days: 7
|
|
||||||
labels:
|
labels:
|
||||||
- "backend"
|
- "backend"
|
||||||
- "dependencies"
|
- "dependencies"
|
||||||
@@ -101,8 +97,6 @@ updates:
|
|||||||
schedule:
|
schedule:
|
||||||
# Check for updates to GitHub Actions every month
|
# Check for updates to GitHub Actions every month
|
||||||
interval: "monthly"
|
interval: "monthly"
|
||||||
cooldown:
|
|
||||||
default-days: 7
|
|
||||||
labels:
|
labels:
|
||||||
- "ci-cd"
|
- "ci-cd"
|
||||||
- "dependencies"
|
- "dependencies"
|
||||||
@@ -118,9 +112,7 @@ updates:
|
|||||||
- "/"
|
- "/"
|
||||||
- "/.devcontainer/"
|
- "/.devcontainer/"
|
||||||
schedule:
|
schedule:
|
||||||
interval: "monthly"
|
interval: "weekly"
|
||||||
cooldown:
|
|
||||||
default-days: 7
|
|
||||||
open-pull-requests-limit: 5
|
open-pull-requests-limit: 5
|
||||||
labels:
|
labels:
|
||||||
- "dependencies"
|
- "dependencies"
|
||||||
@@ -131,9 +123,7 @@ updates:
|
|||||||
- package-ecosystem: "docker-compose"
|
- package-ecosystem: "docker-compose"
|
||||||
directory: "/docker/compose/"
|
directory: "/docker/compose/"
|
||||||
schedule:
|
schedule:
|
||||||
interval: "monthly"
|
interval: "weekly"
|
||||||
cooldown:
|
|
||||||
default-days: 7
|
|
||||||
open-pull-requests-limit: 5
|
open-pull-requests-limit: 5
|
||||||
labels:
|
labels:
|
||||||
- "dependencies"
|
- "dependencies"
|
||||||
@@ -157,11 +147,3 @@ updates:
|
|||||||
postgres:
|
postgres:
|
||||||
patterns:
|
patterns:
|
||||||
- "docker.io/library/postgres*"
|
- "docker.io/library/postgres*"
|
||||||
- package-ecosystem: "pre-commit" # See documentation for possible values
|
|
||||||
directory: "/" # Location of package manifests
|
|
||||||
schedule:
|
|
||||||
interval: "monthly"
|
|
||||||
groups:
|
|
||||||
pre-commit-dependencies:
|
|
||||||
patterns:
|
|
||||||
- "*"
|
|
||||||
|
|||||||
86
.github/workflows/ci-backend.yml
vendored
86
.github/workflows/ci-backend.yml
vendored
@@ -3,9 +3,21 @@ on:
|
|||||||
push:
|
push:
|
||||||
branches-ignore:
|
branches-ignore:
|
||||||
- 'translations**'
|
- 'translations**'
|
||||||
|
paths:
|
||||||
|
- 'src/**'
|
||||||
|
- 'pyproject.toml'
|
||||||
|
- 'uv.lock'
|
||||||
|
- 'docker/compose/docker-compose.ci-test.yml'
|
||||||
|
- '.github/workflows/ci-backend.yml'
|
||||||
pull_request:
|
pull_request:
|
||||||
branches-ignore:
|
branches-ignore:
|
||||||
- 'translations**'
|
- 'translations**'
|
||||||
|
paths:
|
||||||
|
- 'src/**'
|
||||||
|
- 'pyproject.toml'
|
||||||
|
- 'uv.lock'
|
||||||
|
- 'docker/compose/docker-compose.ci-test.yml'
|
||||||
|
- '.github/workflows/ci-backend.yml'
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
concurrency:
|
concurrency:
|
||||||
group: backend-${{ github.event.pull_request.number || github.ref }}
|
group: backend-${{ github.event.pull_request.number || github.ref }}
|
||||||
@@ -14,55 +26,7 @@ env:
|
|||||||
DEFAULT_UV_VERSION: "0.10.x"
|
DEFAULT_UV_VERSION: "0.10.x"
|
||||||
NLTK_DATA: "/usr/share/nltk_data"
|
NLTK_DATA: "/usr/share/nltk_data"
|
||||||
jobs:
|
jobs:
|
||||||
changes:
|
|
||||||
name: Detect Backend Changes
|
|
||||||
runs-on: ubuntu-slim
|
|
||||||
outputs:
|
|
||||||
backend_changed: ${{ steps.force.outputs.run_all == 'true' || steps.filter.outputs.backend == 'true' }}
|
|
||||||
steps:
|
|
||||||
- name: Checkout
|
|
||||||
uses: actions/checkout@v6.0.2
|
|
||||||
with:
|
|
||||||
fetch-depth: 0
|
|
||||||
- name: Decide run mode
|
|
||||||
id: force
|
|
||||||
run: |
|
|
||||||
if [[ "${{ github.event_name }}" == "workflow_dispatch" ]]; then
|
|
||||||
echo "run_all=true" >> "$GITHUB_OUTPUT"
|
|
||||||
elif [[ "${{ github.event_name }}" == "push" && ( "${{ github.ref_name }}" == "main" || "${{ github.ref_name }}" == "dev" ) ]]; then
|
|
||||||
echo "run_all=true" >> "$GITHUB_OUTPUT"
|
|
||||||
else
|
|
||||||
echo "run_all=false" >> "$GITHUB_OUTPUT"
|
|
||||||
fi
|
|
||||||
- name: Set diff range
|
|
||||||
id: range
|
|
||||||
if: steps.force.outputs.run_all != 'true'
|
|
||||||
run: |
|
|
||||||
if [[ "${{ github.event_name }}" == "pull_request" ]]; then
|
|
||||||
echo "base=${{ github.event.pull_request.base.sha }}" >> "$GITHUB_OUTPUT"
|
|
||||||
elif [[ "${{ github.event.created }}" == "true" ]]; then
|
|
||||||
echo "base=${{ github.event.repository.default_branch }}" >> "$GITHUB_OUTPUT"
|
|
||||||
else
|
|
||||||
echo "base=${{ github.event.before }}" >> "$GITHUB_OUTPUT"
|
|
||||||
fi
|
|
||||||
echo "ref=${{ github.sha }}" >> "$GITHUB_OUTPUT"
|
|
||||||
- name: Detect changes
|
|
||||||
id: filter
|
|
||||||
if: steps.force.outputs.run_all != 'true'
|
|
||||||
uses: dorny/paths-filter@v3.0.2
|
|
||||||
with:
|
|
||||||
base: ${{ steps.range.outputs.base }}
|
|
||||||
ref: ${{ steps.range.outputs.ref }}
|
|
||||||
filters: |
|
|
||||||
backend:
|
|
||||||
- 'src/**'
|
|
||||||
- 'pyproject.toml'
|
|
||||||
- 'uv.lock'
|
|
||||||
- 'docker/compose/docker-compose.ci-test.yml'
|
|
||||||
- '.github/workflows/ci-backend.yml'
|
|
||||||
test:
|
test:
|
||||||
needs: changes
|
|
||||||
if: needs.changes.outputs.backend_changed == 'true'
|
|
||||||
name: "Python ${{ matrix.python-version }}"
|
name: "Python ${{ matrix.python-version }}"
|
||||||
runs-on: ubuntu-24.04
|
runs-on: ubuntu-24.04
|
||||||
strategy:
|
strategy:
|
||||||
@@ -136,8 +100,6 @@ jobs:
|
|||||||
docker compose --file docker/compose/docker-compose.ci-test.yml logs
|
docker compose --file docker/compose/docker-compose.ci-test.yml logs
|
||||||
docker compose --file docker/compose/docker-compose.ci-test.yml down
|
docker compose --file docker/compose/docker-compose.ci-test.yml down
|
||||||
typing:
|
typing:
|
||||||
needs: changes
|
|
||||||
if: needs.changes.outputs.backend_changed == 'true'
|
|
||||||
name: Check project typing
|
name: Check project typing
|
||||||
runs-on: ubuntu-24.04
|
runs-on: ubuntu-24.04
|
||||||
env:
|
env:
|
||||||
@@ -188,27 +150,3 @@ jobs:
|
|||||||
--show-error-codes \
|
--show-error-codes \
|
||||||
--warn-unused-configs \
|
--warn-unused-configs \
|
||||||
src/ | uv run mypy-baseline filter
|
src/ | uv run mypy-baseline filter
|
||||||
gate:
|
|
||||||
name: Backend CI Gate
|
|
||||||
needs: [changes, test, typing]
|
|
||||||
if: always()
|
|
||||||
runs-on: ubuntu-slim
|
|
||||||
steps:
|
|
||||||
- name: Check gate
|
|
||||||
run: |
|
|
||||||
if [[ "${{ needs.changes.outputs.backend_changed }}" != "true" ]]; then
|
|
||||||
echo "No backend-relevant changes detected."
|
|
||||||
exit 0
|
|
||||||
fi
|
|
||||||
|
|
||||||
if [[ "${{ needs.test.result }}" != "success" ]]; then
|
|
||||||
echo "::error::Backend test job result: ${{ needs.test.result }}"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
if [[ "${{ needs.typing.result }}" != "success" ]]; then
|
|
||||||
echo "::error::Backend typing job result: ${{ needs.typing.result }}"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
echo "Backend checks passed."
|
|
||||||
|
|||||||
24
.github/workflows/ci-docker.yml
vendored
24
.github/workflows/ci-docker.yml
vendored
@@ -104,9 +104,9 @@ jobs:
|
|||||||
echo "repository=${repo_name}"
|
echo "repository=${repo_name}"
|
||||||
echo "name=${repo_name}" >> $GITHUB_OUTPUT
|
echo "name=${repo_name}" >> $GITHUB_OUTPUT
|
||||||
- name: Set up Docker Buildx
|
- name: Set up Docker Buildx
|
||||||
uses: docker/setup-buildx-action@v4.0.0
|
uses: docker/setup-buildx-action@v3.12.0
|
||||||
- name: Login to GitHub Container Registry
|
- name: Login to GitHub Container Registry
|
||||||
uses: docker/login-action@v4.0.0
|
uses: docker/login-action@v3.7.0
|
||||||
with:
|
with:
|
||||||
registry: ${{ env.REGISTRY }}
|
registry: ${{ env.REGISTRY }}
|
||||||
username: ${{ github.actor }}
|
username: ${{ github.actor }}
|
||||||
@@ -149,16 +149,15 @@ jobs:
|
|||||||
mkdir -p /tmp/digests
|
mkdir -p /tmp/digests
|
||||||
digest="${{ steps.build.outputs.digest }}"
|
digest="${{ steps.build.outputs.digest }}"
|
||||||
echo "digest=${digest}"
|
echo "digest=${digest}"
|
||||||
echo "${digest}" > "/tmp/digests/digest-${{ matrix.arch }}.txt"
|
touch "/tmp/digests/${digest#sha256:}"
|
||||||
- name: Upload digest
|
- name: Upload digest
|
||||||
if: steps.check-push.outputs.should-push == 'true'
|
if: steps.check-push.outputs.should-push == 'true'
|
||||||
uses: actions/upload-artifact@v7.0.0
|
uses: actions/upload-artifact@v7.0.0
|
||||||
with:
|
with:
|
||||||
name: digests-${{ matrix.arch }}
|
name: digests-${{ matrix.arch }}
|
||||||
path: /tmp/digests/digest-${{ matrix.arch }}.txt
|
path: /tmp/digests/*
|
||||||
if-no-files-found: error
|
if-no-files-found: error
|
||||||
retention-days: 1
|
retention-days: 1
|
||||||
archive: false
|
|
||||||
merge-and-push:
|
merge-and-push:
|
||||||
name: Merge and Push Manifest
|
name: Merge and Push Manifest
|
||||||
runs-on: ubuntu-24.04
|
runs-on: ubuntu-24.04
|
||||||
@@ -172,29 +171,29 @@ jobs:
|
|||||||
uses: actions/download-artifact@v8.0.0
|
uses: actions/download-artifact@v8.0.0
|
||||||
with:
|
with:
|
||||||
path: /tmp/digests
|
path: /tmp/digests
|
||||||
pattern: digest-*.txt
|
pattern: digests-*
|
||||||
merge-multiple: true
|
merge-multiple: true
|
||||||
- name: List digests
|
- name: List digests
|
||||||
run: |
|
run: |
|
||||||
echo "Downloaded digests:"
|
echo "Downloaded digests:"
|
||||||
ls -la /tmp/digests/
|
ls -la /tmp/digests/
|
||||||
- name: Set up Docker Buildx
|
- name: Set up Docker Buildx
|
||||||
uses: docker/setup-buildx-action@v4.0.0
|
uses: docker/setup-buildx-action@v3.12.0
|
||||||
- name: Login to GitHub Container Registry
|
- name: Login to GitHub Container Registry
|
||||||
uses: docker/login-action@v4.0.0
|
uses: docker/login-action@v3.7.0
|
||||||
with:
|
with:
|
||||||
registry: ${{ env.REGISTRY }}
|
registry: ${{ env.REGISTRY }}
|
||||||
username: ${{ github.actor }}
|
username: ${{ github.actor }}
|
||||||
password: ${{ secrets.GITHUB_TOKEN }}
|
password: ${{ secrets.GITHUB_TOKEN }}
|
||||||
- name: Login to Docker Hub
|
- name: Login to Docker Hub
|
||||||
if: needs.build-arch.outputs.push-external == 'true'
|
if: needs.build-arch.outputs.push-external == 'true'
|
||||||
uses: docker/login-action@v4.0.0
|
uses: docker/login-action@v3.7.0
|
||||||
with:
|
with:
|
||||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||||
- name: Login to Quay.io
|
- name: Login to Quay.io
|
||||||
if: needs.build-arch.outputs.push-external == 'true'
|
if: needs.build-arch.outputs.push-external == 'true'
|
||||||
uses: docker/login-action@v4.0.0
|
uses: docker/login-action@v3.7.0
|
||||||
with:
|
with:
|
||||||
registry: quay.io
|
registry: quay.io
|
||||||
username: ${{ secrets.QUAY_USERNAME }}
|
username: ${{ secrets.QUAY_USERNAME }}
|
||||||
@@ -218,9 +217,8 @@ jobs:
|
|||||||
tags=$(jq -cr '.tags | map("-t " + .) | join(" ")' <<< "${DOCKER_METADATA_OUTPUT_JSON}")
|
tags=$(jq -cr '.tags | map("-t " + .) | join(" ")' <<< "${DOCKER_METADATA_OUTPUT_JSON}")
|
||||||
|
|
||||||
digests=""
|
digests=""
|
||||||
for digest_file in digest-*.txt; do
|
for digest in *; do
|
||||||
digest=$(cat "${digest_file}")
|
digests+="${{ env.REGISTRY }}/${REPOSITORY}@sha256:${digest} "
|
||||||
digests+="${{ env.REGISTRY }}/${REPOSITORY}@${digest} "
|
|
||||||
done
|
done
|
||||||
|
|
||||||
echo "Creating manifest with tags: ${tags}"
|
echo "Creating manifest with tags: ${tags}"
|
||||||
|
|||||||
88
.github/workflows/ci-docs.yml
vendored
88
.github/workflows/ci-docs.yml
vendored
@@ -1,9 +1,22 @@
|
|||||||
name: Documentation
|
name: Documentation
|
||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
branches-ignore:
|
branches:
|
||||||
- 'translations**'
|
- main
|
||||||
|
- dev
|
||||||
|
paths:
|
||||||
|
- 'docs/**'
|
||||||
|
- 'zensical.toml'
|
||||||
|
- 'pyproject.toml'
|
||||||
|
- 'uv.lock'
|
||||||
|
- '.github/workflows/ci-docs.yml'
|
||||||
pull_request:
|
pull_request:
|
||||||
|
paths:
|
||||||
|
- 'docs/**'
|
||||||
|
- 'zensical.toml'
|
||||||
|
- 'pyproject.toml'
|
||||||
|
- 'uv.lock'
|
||||||
|
- '.github/workflows/ci-docs.yml'
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
concurrency:
|
concurrency:
|
||||||
group: docs-${{ github.event.pull_request.number || github.ref }}
|
group: docs-${{ github.event.pull_request.number || github.ref }}
|
||||||
@@ -16,55 +29,7 @@ env:
|
|||||||
DEFAULT_UV_VERSION: "0.10.x"
|
DEFAULT_UV_VERSION: "0.10.x"
|
||||||
DEFAULT_PYTHON_VERSION: "3.12"
|
DEFAULT_PYTHON_VERSION: "3.12"
|
||||||
jobs:
|
jobs:
|
||||||
changes:
|
|
||||||
name: Detect Docs Changes
|
|
||||||
runs-on: ubuntu-slim
|
|
||||||
outputs:
|
|
||||||
docs_changed: ${{ steps.force.outputs.run_all == 'true' || steps.filter.outputs.docs == 'true' }}
|
|
||||||
steps:
|
|
||||||
- name: Checkout
|
|
||||||
uses: actions/checkout@v6.0.2
|
|
||||||
with:
|
|
||||||
fetch-depth: 0
|
|
||||||
- name: Decide run mode
|
|
||||||
id: force
|
|
||||||
run: |
|
|
||||||
if [[ "${{ github.event_name }}" == "workflow_dispatch" ]]; then
|
|
||||||
echo "run_all=true" >> "$GITHUB_OUTPUT"
|
|
||||||
elif [[ "${{ github.event_name }}" == "push" && ( "${{ github.ref_name }}" == "main" || "${{ github.ref_name }}" == "dev" ) ]]; then
|
|
||||||
echo "run_all=true" >> "$GITHUB_OUTPUT"
|
|
||||||
else
|
|
||||||
echo "run_all=false" >> "$GITHUB_OUTPUT"
|
|
||||||
fi
|
|
||||||
- name: Set diff range
|
|
||||||
id: range
|
|
||||||
if: steps.force.outputs.run_all != 'true'
|
|
||||||
run: |
|
|
||||||
if [[ "${{ github.event_name }}" == "pull_request" ]]; then
|
|
||||||
echo "base=${{ github.event.pull_request.base.sha }}" >> "$GITHUB_OUTPUT"
|
|
||||||
elif [[ "${{ github.event.created }}" == "true" ]]; then
|
|
||||||
echo "base=${{ github.event.repository.default_branch }}" >> "$GITHUB_OUTPUT"
|
|
||||||
else
|
|
||||||
echo "base=${{ github.event.before }}" >> "$GITHUB_OUTPUT"
|
|
||||||
fi
|
|
||||||
echo "ref=${{ github.sha }}" >> "$GITHUB_OUTPUT"
|
|
||||||
- name: Detect changes
|
|
||||||
id: filter
|
|
||||||
if: steps.force.outputs.run_all != 'true'
|
|
||||||
uses: dorny/paths-filter@v3.0.2
|
|
||||||
with:
|
|
||||||
base: ${{ steps.range.outputs.base }}
|
|
||||||
ref: ${{ steps.range.outputs.ref }}
|
|
||||||
filters: |
|
|
||||||
docs:
|
|
||||||
- 'docs/**'
|
|
||||||
- 'zensical.toml'
|
|
||||||
- 'pyproject.toml'
|
|
||||||
- 'uv.lock'
|
|
||||||
- '.github/workflows/ci-docs.yml'
|
|
||||||
build:
|
build:
|
||||||
needs: changes
|
|
||||||
if: needs.changes.outputs.docs_changed == 'true'
|
|
||||||
name: Build Documentation
|
name: Build Documentation
|
||||||
runs-on: ubuntu-24.04
|
runs-on: ubuntu-24.04
|
||||||
steps:
|
steps:
|
||||||
@@ -99,8 +64,8 @@ jobs:
|
|||||||
name: github-pages-${{ github.run_id }}-${{ github.run_attempt }}
|
name: github-pages-${{ github.run_id }}-${{ github.run_attempt }}
|
||||||
deploy:
|
deploy:
|
||||||
name: Deploy Documentation
|
name: Deploy Documentation
|
||||||
needs: [changes, build]
|
needs: build
|
||||||
if: github.event_name == 'push' && github.ref == 'refs/heads/main' && needs.changes.outputs.docs_changed == 'true'
|
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
|
||||||
runs-on: ubuntu-24.04
|
runs-on: ubuntu-24.04
|
||||||
environment:
|
environment:
|
||||||
name: github-pages
|
name: github-pages
|
||||||
@@ -111,22 +76,3 @@ jobs:
|
|||||||
id: deployment
|
id: deployment
|
||||||
with:
|
with:
|
||||||
artifact_name: github-pages-${{ github.run_id }}-${{ github.run_attempt }}
|
artifact_name: github-pages-${{ github.run_id }}-${{ github.run_attempt }}
|
||||||
gate:
|
|
||||||
name: Docs CI Gate
|
|
||||||
needs: [changes, build]
|
|
||||||
if: always()
|
|
||||||
runs-on: ubuntu-slim
|
|
||||||
steps:
|
|
||||||
- name: Check gate
|
|
||||||
run: |
|
|
||||||
if [[ "${{ needs.changes.outputs.docs_changed }}" != "true" ]]; then
|
|
||||||
echo "No docs-relevant changes detected."
|
|
||||||
exit 0
|
|
||||||
fi
|
|
||||||
|
|
||||||
if [[ "${{ needs.build.result }}" != "success" ]]; then
|
|
||||||
echo "::error::Docs build job result: ${{ needs.build.result }}"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
echo "Docs checks passed."
|
|
||||||
|
|||||||
112
.github/workflows/ci-frontend.yml
vendored
112
.github/workflows/ci-frontend.yml
vendored
@@ -3,60 +3,21 @@ on:
|
|||||||
push:
|
push:
|
||||||
branches-ignore:
|
branches-ignore:
|
||||||
- 'translations**'
|
- 'translations**'
|
||||||
|
paths:
|
||||||
|
- 'src-ui/**'
|
||||||
|
- '.github/workflows/ci-frontend.yml'
|
||||||
pull_request:
|
pull_request:
|
||||||
branches-ignore:
|
branches-ignore:
|
||||||
- 'translations**'
|
- 'translations**'
|
||||||
|
paths:
|
||||||
|
- 'src-ui/**'
|
||||||
|
- '.github/workflows/ci-frontend.yml'
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
concurrency:
|
concurrency:
|
||||||
group: frontend-${{ github.event.pull_request.number || github.ref }}
|
group: frontend-${{ github.event.pull_request.number || github.ref }}
|
||||||
cancel-in-progress: true
|
cancel-in-progress: true
|
||||||
jobs:
|
jobs:
|
||||||
changes:
|
|
||||||
name: Detect Frontend Changes
|
|
||||||
runs-on: ubuntu-slim
|
|
||||||
outputs:
|
|
||||||
frontend_changed: ${{ steps.force.outputs.run_all == 'true' || steps.filter.outputs.frontend == 'true' }}
|
|
||||||
steps:
|
|
||||||
- name: Checkout
|
|
||||||
uses: actions/checkout@v6.0.2
|
|
||||||
with:
|
|
||||||
fetch-depth: 0
|
|
||||||
- name: Decide run mode
|
|
||||||
id: force
|
|
||||||
run: |
|
|
||||||
if [[ "${{ github.event_name }}" == "workflow_dispatch" ]]; then
|
|
||||||
echo "run_all=true" >> "$GITHUB_OUTPUT"
|
|
||||||
elif [[ "${{ github.event_name }}" == "push" && ( "${{ github.ref_name }}" == "main" || "${{ github.ref_name }}" == "dev" ) ]]; then
|
|
||||||
echo "run_all=true" >> "$GITHUB_OUTPUT"
|
|
||||||
else
|
|
||||||
echo "run_all=false" >> "$GITHUB_OUTPUT"
|
|
||||||
fi
|
|
||||||
- name: Set diff range
|
|
||||||
id: range
|
|
||||||
if: steps.force.outputs.run_all != 'true'
|
|
||||||
run: |
|
|
||||||
if [[ "${{ github.event_name }}" == "pull_request" ]]; then
|
|
||||||
echo "base=${{ github.event.pull_request.base.sha }}" >> "$GITHUB_OUTPUT"
|
|
||||||
elif [[ "${{ github.event.created }}" == "true" ]]; then
|
|
||||||
echo "base=${{ github.event.repository.default_branch }}" >> "$GITHUB_OUTPUT"
|
|
||||||
else
|
|
||||||
echo "base=${{ github.event.before }}" >> "$GITHUB_OUTPUT"
|
|
||||||
fi
|
|
||||||
echo "ref=${{ github.sha }}" >> "$GITHUB_OUTPUT"
|
|
||||||
- name: Detect changes
|
|
||||||
id: filter
|
|
||||||
if: steps.force.outputs.run_all != 'true'
|
|
||||||
uses: dorny/paths-filter@v3.0.2
|
|
||||||
with:
|
|
||||||
base: ${{ steps.range.outputs.base }}
|
|
||||||
ref: ${{ steps.range.outputs.ref }}
|
|
||||||
filters: |
|
|
||||||
frontend:
|
|
||||||
- 'src-ui/**'
|
|
||||||
- '.github/workflows/ci-frontend.yml'
|
|
||||||
install-dependencies:
|
install-dependencies:
|
||||||
needs: changes
|
|
||||||
if: needs.changes.outputs.frontend_changed == 'true'
|
|
||||||
name: Install Dependencies
|
name: Install Dependencies
|
||||||
runs-on: ubuntu-24.04
|
runs-on: ubuntu-24.04
|
||||||
steps:
|
steps:
|
||||||
@@ -67,7 +28,7 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
version: 10
|
version: 10
|
||||||
- name: Use Node.js 24
|
- name: Use Node.js 24
|
||||||
uses: actions/setup-node@v6.3.0
|
uses: actions/setup-node@v6.2.0
|
||||||
with:
|
with:
|
||||||
node-version: 24.x
|
node-version: 24.x
|
||||||
cache: 'pnpm'
|
cache: 'pnpm'
|
||||||
@@ -84,8 +45,7 @@ jobs:
|
|||||||
run: cd src-ui && pnpm install
|
run: cd src-ui && pnpm install
|
||||||
lint:
|
lint:
|
||||||
name: Lint
|
name: Lint
|
||||||
needs: [changes, install-dependencies]
|
needs: install-dependencies
|
||||||
if: needs.changes.outputs.frontend_changed == 'true'
|
|
||||||
runs-on: ubuntu-24.04
|
runs-on: ubuntu-24.04
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
@@ -95,7 +55,7 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
version: 10
|
version: 10
|
||||||
- name: Use Node.js 24
|
- name: Use Node.js 24
|
||||||
uses: actions/setup-node@v6.3.0
|
uses: actions/setup-node@v6.2.0
|
||||||
with:
|
with:
|
||||||
node-version: 24.x
|
node-version: 24.x
|
||||||
cache: 'pnpm'
|
cache: 'pnpm'
|
||||||
@@ -113,8 +73,7 @@ jobs:
|
|||||||
run: cd src-ui && pnpm run lint
|
run: cd src-ui && pnpm run lint
|
||||||
unit-tests:
|
unit-tests:
|
||||||
name: "Unit Tests (${{ matrix.shard-index }}/${{ matrix.shard-count }})"
|
name: "Unit Tests (${{ matrix.shard-index }}/${{ matrix.shard-count }})"
|
||||||
needs: [changes, install-dependencies]
|
needs: install-dependencies
|
||||||
if: needs.changes.outputs.frontend_changed == 'true'
|
|
||||||
runs-on: ubuntu-24.04
|
runs-on: ubuntu-24.04
|
||||||
strategy:
|
strategy:
|
||||||
fail-fast: false
|
fail-fast: false
|
||||||
@@ -130,7 +89,7 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
version: 10
|
version: 10
|
||||||
- name: Use Node.js 24
|
- name: Use Node.js 24
|
||||||
uses: actions/setup-node@v6.3.0
|
uses: actions/setup-node@v6.2.0
|
||||||
with:
|
with:
|
||||||
node-version: 24.x
|
node-version: 24.x
|
||||||
cache: 'pnpm'
|
cache: 'pnpm'
|
||||||
@@ -160,8 +119,7 @@ jobs:
|
|||||||
directory: src-ui/coverage/
|
directory: src-ui/coverage/
|
||||||
e2e-tests:
|
e2e-tests:
|
||||||
name: "E2E Tests (${{ matrix.shard-index }}/${{ matrix.shard-count }})"
|
name: "E2E Tests (${{ matrix.shard-index }}/${{ matrix.shard-count }})"
|
||||||
needs: [changes, install-dependencies]
|
needs: install-dependencies
|
||||||
if: needs.changes.outputs.frontend_changed == 'true'
|
|
||||||
runs-on: ubuntu-24.04
|
runs-on: ubuntu-24.04
|
||||||
container: mcr.microsoft.com/playwright:v1.58.2-noble
|
container: mcr.microsoft.com/playwright:v1.58.2-noble
|
||||||
env:
|
env:
|
||||||
@@ -181,7 +139,7 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
version: 10
|
version: 10
|
||||||
- name: Use Node.js 24
|
- name: Use Node.js 24
|
||||||
uses: actions/setup-node@v6.3.0
|
uses: actions/setup-node@v6.2.0
|
||||||
with:
|
with:
|
||||||
node-version: 24.x
|
node-version: 24.x
|
||||||
cache: 'pnpm'
|
cache: 'pnpm'
|
||||||
@@ -201,8 +159,7 @@ jobs:
|
|||||||
run: cd src-ui && pnpm exec playwright test --shard ${{ matrix.shard-index }}/${{ matrix.shard-count }}
|
run: cd src-ui && pnpm exec playwright test --shard ${{ matrix.shard-index }}/${{ matrix.shard-count }}
|
||||||
bundle-analysis:
|
bundle-analysis:
|
||||||
name: Bundle Analysis
|
name: Bundle Analysis
|
||||||
needs: [changes, unit-tests, e2e-tests]
|
needs: [unit-tests, e2e-tests]
|
||||||
if: needs.changes.outputs.frontend_changed == 'true'
|
|
||||||
runs-on: ubuntu-24.04
|
runs-on: ubuntu-24.04
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
@@ -214,7 +171,7 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
version: 10
|
version: 10
|
||||||
- name: Use Node.js 24
|
- name: Use Node.js 24
|
||||||
uses: actions/setup-node@v6.3.0
|
uses: actions/setup-node@v6.2.0
|
||||||
with:
|
with:
|
||||||
node-version: 24.x
|
node-version: 24.x
|
||||||
cache: 'pnpm'
|
cache: 'pnpm'
|
||||||
@@ -232,42 +189,3 @@ jobs:
|
|||||||
env:
|
env:
|
||||||
CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }}
|
CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }}
|
||||||
run: cd src-ui && pnpm run build --configuration=production
|
run: cd src-ui && pnpm run build --configuration=production
|
||||||
gate:
|
|
||||||
name: Frontend CI Gate
|
|
||||||
needs: [changes, install-dependencies, lint, unit-tests, e2e-tests, bundle-analysis]
|
|
||||||
if: always()
|
|
||||||
runs-on: ubuntu-slim
|
|
||||||
steps:
|
|
||||||
- name: Check gate
|
|
||||||
run: |
|
|
||||||
if [[ "${{ needs.changes.outputs.frontend_changed }}" != "true" ]]; then
|
|
||||||
echo "No frontend-relevant changes detected."
|
|
||||||
exit 0
|
|
||||||
fi
|
|
||||||
|
|
||||||
if [[ "${{ needs['install-dependencies'].result }}" != "success" ]]; then
|
|
||||||
echo "::error::Frontend install job result: ${{ needs['install-dependencies'].result }}"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
if [[ "${{ needs.lint.result }}" != "success" ]]; then
|
|
||||||
echo "::error::Frontend lint job result: ${{ needs.lint.result }}"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
if [[ "${{ needs['unit-tests'].result }}" != "success" ]]; then
|
|
||||||
echo "::error::Frontend unit-tests job result: ${{ needs['unit-tests'].result }}"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
if [[ "${{ needs['e2e-tests'].result }}" != "success" ]]; then
|
|
||||||
echo "::error::Frontend e2e-tests job result: ${{ needs['e2e-tests'].result }}"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
if [[ "${{ needs['bundle-analysis'].result }}" != "success" ]]; then
|
|
||||||
echo "::error::Frontend bundle-analysis job result: ${{ needs['bundle-analysis'].result }}"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
echo "Frontend checks passed."
|
|
||||||
|
|||||||
2
.github/workflows/ci-release.yml
vendored
2
.github/workflows/ci-release.yml
vendored
@@ -35,7 +35,7 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
version: 10
|
version: 10
|
||||||
- name: Use Node.js 24
|
- name: Use Node.js 24
|
||||||
uses: actions/setup-node@v6.3.0
|
uses: actions/setup-node@v6.2.0
|
||||||
with:
|
with:
|
||||||
node-version: 24.x
|
node-version: 24.x
|
||||||
cache: 'pnpm'
|
cache: 'pnpm'
|
||||||
|
|||||||
17
.github/workflows/pr-bot.yml
vendored
17
.github/workflows/pr-bot.yml
vendored
@@ -2,24 +2,13 @@ name: PR Bot
|
|||||||
on:
|
on:
|
||||||
pull_request_target:
|
pull_request_target:
|
||||||
types: [opened]
|
types: [opened]
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
pull-requests: write
|
||||||
jobs:
|
jobs:
|
||||||
anti-slop:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
permissions:
|
|
||||||
contents: read
|
|
||||||
issues: read
|
|
||||||
pull-requests: write
|
|
||||||
steps:
|
|
||||||
- uses: peakoss/anti-slop@v0.2.1
|
|
||||||
with:
|
|
||||||
max-failures: 4
|
|
||||||
failure-add-pr-labels: 'ai'
|
|
||||||
pr-bot:
|
pr-bot:
|
||||||
name: Automated PR Bot
|
name: Automated PR Bot
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
permissions:
|
|
||||||
contents: read
|
|
||||||
pull-requests: write
|
|
||||||
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
|
||||||
|
|||||||
2
.github/workflows/translate-strings.yml
vendored
2
.github/workflows/translate-strings.yml
vendored
@@ -40,7 +40,7 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
version: 10
|
version: 10
|
||||||
- name: Use Node.js 24
|
- name: Use Node.js 24
|
||||||
uses: actions/setup-node@v6.3.0
|
uses: actions/setup-node@v6.2.0
|
||||||
with:
|
with:
|
||||||
node-version: 24.x
|
node-version: 24.x
|
||||||
cache: 'pnpm'
|
cache: 'pnpm'
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ repos:
|
|||||||
- id: check-case-conflict
|
- id: check-case-conflict
|
||||||
- id: detect-private-key
|
- id: detect-private-key
|
||||||
- repo: https://github.com/codespell-project/codespell
|
- repo: https://github.com/codespell-project/codespell
|
||||||
rev: v2.4.2
|
rev: v2.4.1
|
||||||
hooks:
|
hooks:
|
||||||
- id: codespell
|
- id: codespell
|
||||||
additional_dependencies: [tomli]
|
additional_dependencies: [tomli]
|
||||||
@@ -46,11 +46,11 @@ repos:
|
|||||||
- ts
|
- ts
|
||||||
- markdown
|
- markdown
|
||||||
additional_dependencies:
|
additional_dependencies:
|
||||||
- prettier@3.8.1
|
- prettier@3.3.3
|
||||||
- 'prettier-plugin-organize-imports@4.3.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.15.5
|
rev: v0.15.0
|
||||||
hooks:
|
hooks:
|
||||||
- id: ruff-check
|
- id: ruff-check
|
||||||
- id: ruff-format
|
- id: ruff-format
|
||||||
@@ -65,7 +65,7 @@ repos:
|
|||||||
- id: hadolint
|
- id: hadolint
|
||||||
# Shell script hooks
|
# Shell script hooks
|
||||||
- repo: https://github.com/lovesegfault/beautysh
|
- repo: https://github.com/lovesegfault/beautysh
|
||||||
rev: v6.4.3
|
rev: v6.4.2
|
||||||
hooks:
|
hooks:
|
||||||
- id: beautysh
|
- id: beautysh
|
||||||
types: [file]
|
types: [file]
|
||||||
|
|||||||
@@ -5,6 +5,14 @@ const config = {
|
|||||||
singleQuote: true,
|
singleQuote: true,
|
||||||
// https://prettier.io/docs/en/options.html#trailing-commas
|
// https://prettier.io/docs/en/options.html#trailing-commas
|
||||||
trailingComma: 'es5',
|
trailingComma: 'es5',
|
||||||
|
overrides: [
|
||||||
|
{
|
||||||
|
files: ['docs/*.md'],
|
||||||
|
options: {
|
||||||
|
tabWidth: 4,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
plugins: [require('prettier-plugin-organize-imports')],
|
plugins: [require('prettier-plugin-organize-imports')],
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -30,7 +30,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.10.9-python3.12-trixie-slim AS s6-overlay-base
|
FROM ghcr.io/astral-sh/uv:0.10.7-python3.12-trixie-slim AS s6-overlay-base
|
||||||
|
|
||||||
WORKDIR /usr/src/s6
|
WORKDIR /usr/src/s6
|
||||||
|
|
||||||
|
|||||||
@@ -56,7 +56,6 @@ services:
|
|||||||
environment:
|
environment:
|
||||||
PAPERLESS_REDIS: redis://broker:6379
|
PAPERLESS_REDIS: redis://broker:6379
|
||||||
PAPERLESS_DBHOST: db
|
PAPERLESS_DBHOST: db
|
||||||
PAPERLESS_DBENGINE: postgres
|
|
||||||
env_file:
|
env_file:
|
||||||
- stack.env
|
- stack.env
|
||||||
volumes:
|
volumes:
|
||||||
|
|||||||
@@ -62,7 +62,6 @@ services:
|
|||||||
environment:
|
environment:
|
||||||
PAPERLESS_REDIS: redis://broker:6379
|
PAPERLESS_REDIS: redis://broker:6379
|
||||||
PAPERLESS_DBHOST: db
|
PAPERLESS_DBHOST: db
|
||||||
PAPERLESS_DBENGINE: postgresql
|
|
||||||
PAPERLESS_TIKA_ENABLED: 1
|
PAPERLESS_TIKA_ENABLED: 1
|
||||||
PAPERLESS_TIKA_GOTENBERG_ENDPOINT: http://gotenberg:3000
|
PAPERLESS_TIKA_GOTENBERG_ENDPOINT: http://gotenberg:3000
|
||||||
PAPERLESS_TIKA_ENDPOINT: http://tika:9998
|
PAPERLESS_TIKA_ENDPOINT: http://tika:9998
|
||||||
|
|||||||
@@ -56,7 +56,6 @@ services:
|
|||||||
environment:
|
environment:
|
||||||
PAPERLESS_REDIS: redis://broker:6379
|
PAPERLESS_REDIS: redis://broker:6379
|
||||||
PAPERLESS_DBHOST: db
|
PAPERLESS_DBHOST: db
|
||||||
PAPERLESS_DBENGINE: postgresql
|
|
||||||
volumes:
|
volumes:
|
||||||
data:
|
data:
|
||||||
media:
|
media:
|
||||||
|
|||||||
@@ -51,7 +51,6 @@ services:
|
|||||||
env_file: docker-compose.env
|
env_file: docker-compose.env
|
||||||
environment:
|
environment:
|
||||||
PAPERLESS_REDIS: redis://broker:6379
|
PAPERLESS_REDIS: redis://broker:6379
|
||||||
PAPERLESS_DBENGINE: sqlite
|
|
||||||
PAPERLESS_TIKA_ENABLED: 1
|
PAPERLESS_TIKA_ENABLED: 1
|
||||||
PAPERLESS_TIKA_GOTENBERG_ENDPOINT: http://gotenberg:3000
|
PAPERLESS_TIKA_GOTENBERG_ENDPOINT: http://gotenberg:3000
|
||||||
PAPERLESS_TIKA_ENDPOINT: http://tika:9998
|
PAPERLESS_TIKA_ENDPOINT: http://tika:9998
|
||||||
|
|||||||
@@ -42,7 +42,6 @@ services:
|
|||||||
env_file: docker-compose.env
|
env_file: docker-compose.env
|
||||||
environment:
|
environment:
|
||||||
PAPERLESS_REDIS: redis://broker:6379
|
PAPERLESS_REDIS: redis://broker:6379
|
||||||
PAPERLESS_DBENGINE: sqlite
|
|
||||||
volumes:
|
volumes:
|
||||||
data:
|
data:
|
||||||
media:
|
media:
|
||||||
|
|||||||
@@ -10,10 +10,8 @@ cd "${PAPERLESS_SRC_DIR}"
|
|||||||
|
|
||||||
# The whole migrate, with flock, needs to run as the right user
|
# The whole migrate, with flock, needs to run as the right user
|
||||||
if [[ -n "${USER_IS_NON_ROOT}" ]]; then
|
if [[ -n "${USER_IS_NON_ROOT}" ]]; then
|
||||||
python3 manage.py check --tag compatibility paperless || exit 1
|
|
||||||
exec s6-setlock -n "${data_dir}/migration_lock" python3 manage.py migrate --skip-checks --no-input
|
exec s6-setlock -n "${data_dir}/migration_lock" python3 manage.py migrate --skip-checks --no-input
|
||||||
else
|
else
|
||||||
s6-setuidgid paperless python3 manage.py check --tag compatibility paperless || exit 1
|
|
||||||
exec s6-setuidgid paperless \
|
exec s6-setuidgid paperless \
|
||||||
s6-setlock -n "${data_dir}/migration_lock" \
|
s6-setlock -n "${data_dir}/migration_lock" \
|
||||||
python3 manage.py migrate --skip-checks --no-input
|
python3 manage.py migrate --skip-checks --no-input
|
||||||
|
|||||||
@@ -10,16 +10,16 @@ consuming documents at that time.
|
|||||||
|
|
||||||
Options available to any installation of paperless:
|
Options available to any installation of paperless:
|
||||||
|
|
||||||
- Use the [document exporter](#exporter). The document exporter exports all your documents,
|
- Use the [document exporter](#exporter). The document exporter exports all your documents,
|
||||||
thumbnails, metadata, and database contents to a specific folder. You may import your
|
thumbnails, metadata, and database contents to a specific folder. You may import your
|
||||||
documents and settings into a fresh instance of paperless again or store your
|
documents and settings into a fresh instance of paperless again or store your
|
||||||
documents in another DMS with this export.
|
documents in another DMS with this export.
|
||||||
|
|
||||||
The document exporter is also able to update an already existing
|
The document exporter is also able to update an already existing
|
||||||
export. Therefore, incremental backups with `rsync` are entirely
|
export. Therefore, incremental backups with `rsync` are entirely
|
||||||
possible.
|
possible.
|
||||||
|
|
||||||
The exporter does not include API tokens and they will need to be re-generated after importing.
|
The exporter does not include API tokens and they will need to be re-generated after importing.
|
||||||
|
|
||||||
!!! caution
|
!!! caution
|
||||||
|
|
||||||
@@ -29,27 +29,28 @@ Options available to any installation of paperless:
|
|||||||
|
|
||||||
Options available to docker installations:
|
Options available to docker installations:
|
||||||
|
|
||||||
- Backup the docker volumes. These usually reside within
|
- Backup the docker volumes. These usually reside within
|
||||||
`/var/lib/docker/volumes` on the host and you need to be root in
|
`/var/lib/docker/volumes` on the host and you need to be root in
|
||||||
order to access them.
|
order to access them.
|
||||||
|
|
||||||
Paperless uses 4 volumes:
|
Paperless uses 4 volumes:
|
||||||
- `paperless_media`: This is where your documents are stored.
|
|
||||||
- `paperless_data`: This is where auxiliary data is stored. This
|
- `paperless_media`: This is where your documents are stored.
|
||||||
folder also contains the SQLite database, if you use it.
|
- `paperless_data`: This is where auxiliary data is stored. This
|
||||||
- `paperless_pgdata`: Exists only if you use PostgreSQL and
|
folder also contains the SQLite database, if you use it.
|
||||||
contains the database.
|
- `paperless_pgdata`: Exists only if you use PostgreSQL and
|
||||||
- `paperless_dbdata`: Exists only if you use MariaDB and contains
|
contains the database.
|
||||||
the database.
|
- `paperless_dbdata`: Exists only if you use MariaDB and contains
|
||||||
|
the database.
|
||||||
|
|
||||||
Options available to bare-metal and non-docker installations:
|
Options available to bare-metal and non-docker installations:
|
||||||
|
|
||||||
- Backup the entire paperless folder. This ensures that if your
|
- Backup the entire paperless folder. This ensures that if your
|
||||||
paperless instance crashes at some point or your disk fails, you can
|
paperless instance crashes at some point or your disk fails, you can
|
||||||
simply copy the folder back into place and it works.
|
simply copy the folder back into place and it works.
|
||||||
|
|
||||||
When using PostgreSQL or MariaDB, you'll also have to backup the
|
When using PostgreSQL or MariaDB, you'll also have to backup the
|
||||||
database.
|
database.
|
||||||
|
|
||||||
### Restoring {#migrating-restoring}
|
### Restoring {#migrating-restoring}
|
||||||
|
|
||||||
@@ -508,19 +509,19 @@ collection for issues.
|
|||||||
|
|
||||||
The issues detected by the sanity checker are as follows:
|
The issues detected by the sanity checker are as follows:
|
||||||
|
|
||||||
- Missing original files.
|
- Missing original files.
|
||||||
- Missing archive files.
|
- Missing archive files.
|
||||||
- Inaccessible original files due to improper permissions.
|
- Inaccessible original files due to improper permissions.
|
||||||
- Inaccessible archive files due to improper permissions.
|
- Inaccessible archive files due to improper permissions.
|
||||||
- Corrupted original documents by comparing their checksum against
|
- Corrupted original documents by comparing their checksum against
|
||||||
what is stored in the database.
|
what is stored in the database.
|
||||||
- Corrupted archive documents by comparing their checksum against what
|
- Corrupted archive documents by comparing their checksum against what
|
||||||
is stored in the database.
|
is stored in the database.
|
||||||
- Missing thumbnails.
|
- Missing thumbnails.
|
||||||
- Inaccessible thumbnails due to improper permissions.
|
- Inaccessible thumbnails due to improper permissions.
|
||||||
- Documents without any content (warning).
|
- Documents without any content (warning).
|
||||||
- Orphaned files in the media directory (warning). These are files
|
- Orphaned files in the media directory (warning). These are files
|
||||||
that are not referenced by any document in paperless.
|
that are not referenced by any document in paperless.
|
||||||
|
|
||||||
```
|
```
|
||||||
document_sanity_checker
|
document_sanity_checker
|
||||||
|
|||||||
@@ -25,20 +25,20 @@ documents.
|
|||||||
|
|
||||||
The following algorithms are available:
|
The following algorithms are available:
|
||||||
|
|
||||||
- **None:** No matching will be performed.
|
- **None:** No matching will be performed.
|
||||||
- **Any:** Looks for any occurrence of any word provided in match in
|
- **Any:** Looks for any occurrence of any word provided in match in
|
||||||
the PDF. If you define the match as `Bank1 Bank2`, it will match
|
the PDF. If you define the match as `Bank1 Bank2`, it will match
|
||||||
documents containing either of these terms.
|
documents containing either of these terms.
|
||||||
- **All:** Requires that every word provided appears in the PDF,
|
- **All:** Requires that every word provided appears in the PDF,
|
||||||
albeit not in the order provided.
|
albeit not in the order provided.
|
||||||
- **Exact:** Matches only if the match appears exactly as provided
|
- **Exact:** Matches only if the match appears exactly as provided
|
||||||
(i.e. preserve ordering) in the PDF.
|
(i.e. preserve ordering) in the PDF.
|
||||||
- **Regular expression:** Parses the match as a regular expression and
|
- **Regular expression:** Parses the match as a regular expression and
|
||||||
tries to find a match within the document.
|
tries to find a match within the document.
|
||||||
- **Fuzzy match:** Uses a partial matching based on locating the tag text
|
- **Fuzzy match:** Uses a partial matching based on locating the tag text
|
||||||
inside the document, using a [partial ratio](https://rapidfuzz.github.io/RapidFuzz/Usage/fuzz.html#partial-ratio)
|
inside the document, using a [partial ratio](https://rapidfuzz.github.io/RapidFuzz/Usage/fuzz.html#partial-ratio)
|
||||||
- **Auto:** Tries to automatically match new documents. This does not
|
- **Auto:** Tries to automatically match new documents. This does not
|
||||||
require you to set a match. See the [notes below](#automatic-matching).
|
require you to set a match. See the [notes below](#automatic-matching).
|
||||||
|
|
||||||
When using the _any_ or _all_ matching algorithms, you can search for
|
When using the _any_ or _all_ matching algorithms, you can search for
|
||||||
terms that consist of multiple words by enclosing them in double quotes.
|
terms that consist of multiple words by enclosing them in double quotes.
|
||||||
@@ -69,33 +69,33 @@ Paperless tries to hide much of the involved complexity with this
|
|||||||
approach. However, there are a couple caveats you need to keep in mind
|
approach. However, there are a couple caveats you need to keep in mind
|
||||||
when using this feature:
|
when using this feature:
|
||||||
|
|
||||||
- Changes to your documents are not immediately reflected by the
|
- Changes to your documents are not immediately reflected by the
|
||||||
matching algorithm. The neural network needs to be _trained_ on your
|
matching algorithm. The neural network needs to be _trained_ on your
|
||||||
documents after changes. Paperless periodically (default: once each
|
documents after changes. Paperless periodically (default: once each
|
||||||
hour) checks for changes and does this automatically for you.
|
hour) checks for changes and does this automatically for you.
|
||||||
- The Auto matching algorithm only takes documents into account which
|
- The Auto matching algorithm only takes documents into account which
|
||||||
are NOT placed in your inbox (i.e. have any inbox tags assigned to
|
are NOT placed in your inbox (i.e. have any inbox tags assigned to
|
||||||
them). This ensures that the neural network only learns from
|
them). This ensures that the neural network only learns from
|
||||||
documents which you have correctly tagged before.
|
documents which you have correctly tagged before.
|
||||||
- The matching algorithm can only work if there is a correlation
|
- The matching algorithm can only work if there is a correlation
|
||||||
between the tag, correspondent, document type, or storage path and
|
between the tag, correspondent, document type, or storage path and
|
||||||
the document itself. Your bank statements usually contain your bank
|
the document itself. Your bank statements usually contain your bank
|
||||||
account number and the name of the bank, so this works reasonably
|
account number and the name of the bank, so this works reasonably
|
||||||
well, However, tags such as "TODO" cannot be automatically
|
well, However, tags such as "TODO" cannot be automatically
|
||||||
assigned.
|
assigned.
|
||||||
- The matching algorithm needs a reasonable number of documents to
|
- The matching algorithm needs a reasonable number of documents to
|
||||||
identify when to assign tags, correspondents, storage paths, and
|
identify when to assign tags, correspondents, storage paths, and
|
||||||
types. If one out of a thousand documents has the correspondent
|
types. If one out of a thousand documents has the correspondent
|
||||||
"Very obscure web shop I bought something five years ago", it will
|
"Very obscure web shop I bought something five years ago", it will
|
||||||
probably not assign this correspondent automatically if you buy
|
probably not assign this correspondent automatically if you buy
|
||||||
something from them again. The more documents, the better.
|
something from them again. The more documents, the better.
|
||||||
- Paperless also needs a reasonable amount of negative examples to
|
- Paperless also needs a reasonable amount of negative examples to
|
||||||
decide when not to assign a certain tag, correspondent, document
|
decide when not to assign a certain tag, correspondent, document
|
||||||
type, or storage path. This will usually be the case as you start
|
type, or storage path. This will usually be the case as you start
|
||||||
filling up paperless with documents. Example: If all your documents
|
filling up paperless with documents. Example: If all your documents
|
||||||
are either from "Webshop" or "Bank", paperless will assign one
|
are either from "Webshop" or "Bank", paperless will assign one
|
||||||
of these correspondents to ANY new document, if both are set to
|
of these correspondents to ANY new document, if both are set to
|
||||||
automatic matching.
|
automatic matching.
|
||||||
|
|
||||||
## Hooking into the consumption process {#consume-hooks}
|
## Hooking into the consumption process {#consume-hooks}
|
||||||
|
|
||||||
@@ -243,12 +243,12 @@ webserver:
|
|||||||
|
|
||||||
Troubleshooting:
|
Troubleshooting:
|
||||||
|
|
||||||
- Monitor the Docker Compose log
|
- Monitor the Docker Compose log
|
||||||
`cd ~/paperless-ngx; docker compose logs -f`
|
`cd ~/paperless-ngx; docker compose logs -f`
|
||||||
- Check your script's permission e.g. in case of permission error
|
- Check your script's permission e.g. in case of permission error
|
||||||
`sudo chmod 755 post-consumption-example.sh`
|
`sudo chmod 755 post-consumption-example.sh`
|
||||||
- Pipe your scripts's output to a log file e.g.
|
- Pipe your scripts's output to a log file e.g.
|
||||||
`echo "${DOCUMENT_ID}" | tee --append /usr/src/paperless/scripts/post-consumption-example.log`
|
`echo "${DOCUMENT_ID}" | tee --append /usr/src/paperless/scripts/post-consumption-example.log`
|
||||||
|
|
||||||
## File name handling {#file-name-handling}
|
## File name handling {#file-name-handling}
|
||||||
|
|
||||||
@@ -307,35 +307,35 @@ will create a directory structure as follows:
|
|||||||
|
|
||||||
Paperless provides the following variables for use within filenames:
|
Paperless provides the following variables for use within filenames:
|
||||||
|
|
||||||
- `{{ asn }}`: The archive serial number of the document, or "none".
|
- `{{ asn }}`: The archive serial number of the document, or "none".
|
||||||
- `{{ correspondent }}`: The name of the correspondent, or "none".
|
- `{{ correspondent }}`: The name of the correspondent, or "none".
|
||||||
- `{{ document_type }}`: The name of the document type, or "none".
|
- `{{ document_type }}`: The name of the document type, or "none".
|
||||||
- `{{ tag_list }}`: A comma separated list of all tags assigned to the
|
- `{{ tag_list }}`: A comma separated list of all tags assigned to the
|
||||||
document.
|
document.
|
||||||
- `{{ title }}`: The title of the document.
|
- `{{ title }}`: The title of the document.
|
||||||
- `{{ created }}`: The full date (ISO 8601 format, e.g. `2024-03-14`) the document was created.
|
- `{{ created }}`: The full date (ISO 8601 format, e.g. `2024-03-14`) the document was created.
|
||||||
- `{{ created_year }}`: Year created only, formatted as the year with
|
- `{{ created_year }}`: Year created only, formatted as the year with
|
||||||
century.
|
century.
|
||||||
- `{{ created_year_short }}`: Year created only, formatted as the year
|
- `{{ created_year_short }}`: Year created only, formatted as the year
|
||||||
without century, zero padded.
|
without century, zero padded.
|
||||||
- `{{ created_month }}`: Month created only (number 01-12).
|
- `{{ created_month }}`: Month created only (number 01-12).
|
||||||
- `{{ created_month_name }}`: Month created name, as per locale
|
- `{{ created_month_name }}`: Month created name, as per locale
|
||||||
- `{{ created_month_name_short }}`: Month created abbreviated name, as per
|
- `{{ created_month_name_short }}`: Month created abbreviated name, as per
|
||||||
locale
|
locale
|
||||||
- `{{ created_day }}`: Day created only (number 01-31).
|
- `{{ created_day }}`: Day created only (number 01-31).
|
||||||
- `{{ added }}`: The full date (ISO format) the document was added to
|
- `{{ added }}`: The full date (ISO format) the document was added to
|
||||||
paperless.
|
paperless.
|
||||||
- `{{ added_year }}`: Year added only.
|
- `{{ added_year }}`: Year added only.
|
||||||
- `{{ added_year_short }}`: Year added only, formatted as the year without
|
- `{{ added_year_short }}`: Year added only, formatted as the year without
|
||||||
century, zero padded.
|
century, zero padded.
|
||||||
- `{{ added_month }}`: Month added only (number 01-12).
|
- `{{ added_month }}`: Month added only (number 01-12).
|
||||||
- `{{ added_month_name }}`: Month added name, as per locale
|
- `{{ added_month_name }}`: Month added name, as per locale
|
||||||
- `{{ added_month_name_short }}`: Month added abbreviated name, as per
|
- `{{ added_month_name_short }}`: Month added abbreviated name, as per
|
||||||
locale
|
locale
|
||||||
- `{{ added_day }}`: Day added only (number 01-31).
|
- `{{ added_day }}`: Day added only (number 01-31).
|
||||||
- `{{ owner_username }}`: Username of document owner, if any, or "none"
|
- `{{ owner_username }}`: Username of document owner, if any, or "none"
|
||||||
- `{{ original_name }}`: Document original filename, minus the extension, if any, or "none"
|
- `{{ original_name }}`: Document original filename, minus the extension, if any, or "none"
|
||||||
- `{{ doc_pk }}`: The paperless identifier (primary key) for the document.
|
- `{{ doc_pk }}`: The paperless identifier (primary key) for the document.
|
||||||
|
|
||||||
!!! warning
|
!!! warning
|
||||||
|
|
||||||
@@ -388,10 +388,10 @@ before empty placeholders are removed as well, empty directories are omitted.
|
|||||||
When a single storage layout is not sufficient for your use case, storage paths allow for more complex
|
When a single storage layout is not sufficient for your use case, storage paths allow for more complex
|
||||||
structure to set precisely where each document is stored in the file system.
|
structure to set precisely where each document is stored in the file system.
|
||||||
|
|
||||||
- Each storage path is a [`PAPERLESS_FILENAME_FORMAT`](configuration.md#PAPERLESS_FILENAME_FORMAT) and
|
- Each storage path is a [`PAPERLESS_FILENAME_FORMAT`](configuration.md#PAPERLESS_FILENAME_FORMAT) and
|
||||||
follows the rules described above
|
follows the rules described above
|
||||||
- Each document is assigned a storage path using the matching algorithms described above, but can be
|
- Each document is assigned a storage path using the matching algorithms described above, but can be
|
||||||
overwritten at any time
|
overwritten at any time
|
||||||
|
|
||||||
For example, you could define the following two storage paths:
|
For example, you could define the following two storage paths:
|
||||||
|
|
||||||
@@ -457,13 +457,13 @@ The `get_cf_value` filter retrieves a value from custom field data with optional
|
|||||||
|
|
||||||
###### Parameters
|
###### Parameters
|
||||||
|
|
||||||
- `custom_fields`: This _must_ be the provided custom field data
|
- `custom_fields`: This _must_ be the provided custom field data
|
||||||
- `name` (str): Name of the custom field to retrieve
|
- `name` (str): Name of the custom field to retrieve
|
||||||
- `default` (str, optional): Default value to return if field is not found or has no value
|
- `default` (str, optional): Default value to return if field is not found or has no value
|
||||||
|
|
||||||
###### Returns
|
###### Returns
|
||||||
|
|
||||||
- `str | None`: The field value, default value, or `None` if neither exists
|
- `str | None`: The field value, default value, or `None` if neither exists
|
||||||
|
|
||||||
###### Examples
|
###### Examples
|
||||||
|
|
||||||
@@ -487,12 +487,12 @@ The `datetime` filter formats a datetime string or datetime object using Python'
|
|||||||
|
|
||||||
###### Parameters
|
###### Parameters
|
||||||
|
|
||||||
- `value` (str | datetime): Date/time value to format (strings will be parsed automatically)
|
- `value` (str | datetime): Date/time value to format (strings will be parsed automatically)
|
||||||
- `format` (str): Python strftime format string
|
- `format` (str): Python strftime format string
|
||||||
|
|
||||||
###### Returns
|
###### Returns
|
||||||
|
|
||||||
- `str`: Formatted datetime string
|
- `str`: Formatted datetime string
|
||||||
|
|
||||||
###### Examples
|
###### Examples
|
||||||
|
|
||||||
@@ -525,13 +525,13 @@ An ISO string can also be provided to control the output format.
|
|||||||
|
|
||||||
###### Parameters
|
###### Parameters
|
||||||
|
|
||||||
- `value` (date | datetime | str): Date, datetime object or ISO string to format (datetime should be timezone-aware)
|
- `value` (date | datetime | str): Date, datetime object or ISO string to format (datetime should be timezone-aware)
|
||||||
- `format` (str): Format type - either a Babel preset ('short', 'medium', 'long', 'full') or custom pattern
|
- `format` (str): Format type - either a Babel preset ('short', 'medium', 'long', 'full') or custom pattern
|
||||||
- `locale` (str): Locale code for localization (e.g., 'en_US', 'fr_FR', 'de_DE')
|
- `locale` (str): Locale code for localization (e.g., 'en_US', 'fr_FR', 'de_DE')
|
||||||
|
|
||||||
###### Returns
|
###### Returns
|
||||||
|
|
||||||
- `str`: Localized, formatted date string
|
- `str`: Localized, formatted date string
|
||||||
|
|
||||||
###### Examples
|
###### Examples
|
||||||
|
|
||||||
@@ -565,15 +565,15 @@ See the [supported format codes](https://unicode.org/reports/tr35/tr35-dates.htm
|
|||||||
|
|
||||||
### Format Presets
|
### Format Presets
|
||||||
|
|
||||||
- **short**: Abbreviated format (e.g., "1/15/24")
|
- **short**: Abbreviated format (e.g., "1/15/24")
|
||||||
- **medium**: Medium-length format (e.g., "Jan 15, 2024")
|
- **medium**: Medium-length format (e.g., "Jan 15, 2024")
|
||||||
- **long**: Long format with full month name (e.g., "January 15, 2024")
|
- **long**: Long format with full month name (e.g., "January 15, 2024")
|
||||||
- **full**: Full format including day of week (e.g., "Monday, January 15, 2024")
|
- **full**: Full format including day of week (e.g., "Monday, January 15, 2024")
|
||||||
|
|
||||||
#### Additional Variables
|
#### Additional Variables
|
||||||
|
|
||||||
- `{{ tag_name_list }}`: A list of tag names applied to the document, ordered by the tag name. Note this is a list, not a single string
|
- `{{ tag_name_list }}`: A list of tag names applied to the document, ordered by the tag name. Note this is a list, not a single string
|
||||||
- `{{ custom_fields }}`: A mapping of custom field names to their type and value. A user can access the mapping by field name or check if a field is applied by checking its existence in the variable.
|
- `{{ custom_fields }}`: A mapping of custom field names to their type and value. A user can access the mapping by field name or check if a field is applied by checking its existence in the variable.
|
||||||
|
|
||||||
!!! tip
|
!!! tip
|
||||||
|
|
||||||
@@ -675,15 +675,15 @@ installation, you can use volumes to accomplish this:
|
|||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
services:
|
services:
|
||||||
# ...
|
|
||||||
webserver:
|
|
||||||
environment:
|
|
||||||
- PAPERLESS_ENABLE_FLOWER
|
|
||||||
ports:
|
|
||||||
- 5555:5555 # (2)!
|
|
||||||
# ...
|
# ...
|
||||||
volumes:
|
webserver:
|
||||||
- /path/to/my/flowerconfig.py:/usr/src/paperless/src/paperless/flowerconfig.py:ro # (1)!
|
environment:
|
||||||
|
- PAPERLESS_ENABLE_FLOWER
|
||||||
|
ports:
|
||||||
|
- 5555:5555 # (2)!
|
||||||
|
# ...
|
||||||
|
volumes:
|
||||||
|
- /path/to/my/flowerconfig.py:/usr/src/paperless/src/paperless/flowerconfig.py:ro # (1)!
|
||||||
```
|
```
|
||||||
|
|
||||||
1. Note the `:ro` tag means the file will be mounted as read only.
|
1. Note the `:ro` tag means the file will be mounted as read only.
|
||||||
@@ -714,11 +714,11 @@ For example, using Docker Compose:
|
|||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
services:
|
services:
|
||||||
# ...
|
|
||||||
webserver:
|
|
||||||
# ...
|
# ...
|
||||||
volumes:
|
webserver:
|
||||||
- /path/to/my/scripts:/custom-cont-init.d:ro # (1)!
|
# ...
|
||||||
|
volumes:
|
||||||
|
- /path/to/my/scripts:/custom-cont-init.d:ro # (1)!
|
||||||
```
|
```
|
||||||
|
|
||||||
1. Note the `:ro` tag means the folder will be mounted as read only. This is for extra security against changes
|
1. Note the `:ro` tag means the folder will be mounted as read only. This is for extra security against changes
|
||||||
@@ -771,16 +771,16 @@ Paperless is able to utilize barcodes for automatically performing some tasks.
|
|||||||
|
|
||||||
At this time, the library utilized for detection of barcodes supports the following types:
|
At this time, the library utilized for detection of barcodes supports the following types:
|
||||||
|
|
||||||
- AN-13/UPC-A
|
- AN-13/UPC-A
|
||||||
- UPC-E
|
- UPC-E
|
||||||
- EAN-8
|
- EAN-8
|
||||||
- Code 128
|
- Code 128
|
||||||
- Code 93
|
- Code 93
|
||||||
- Code 39
|
- Code 39
|
||||||
- Codabar
|
- Codabar
|
||||||
- Interleaved 2 of 5
|
- Interleaved 2 of 5
|
||||||
- QR Code
|
- QR Code
|
||||||
- SQ Code
|
- SQ Code
|
||||||
|
|
||||||
For usage in Paperless, the type of barcode does not matter, only the contents of it.
|
For usage in Paperless, the type of barcode does not matter, only the contents of it.
|
||||||
|
|
||||||
@@ -793,8 +793,8 @@ below.
|
|||||||
If document splitting is enabled, Paperless splits _after_ a separator barcode by default.
|
If document splitting is enabled, Paperless splits _after_ a separator barcode by default.
|
||||||
This means:
|
This means:
|
||||||
|
|
||||||
- any page containing the configured separator barcode starts a new document, starting with the **next** page
|
- any page containing the configured separator barcode starts a new document, starting with the **next** page
|
||||||
- pages containing the separator barcode are discarded
|
- pages containing the separator barcode are discarded
|
||||||
|
|
||||||
This is intended for dedicated separator sheets such as PATCH-T pages.
|
This is intended for dedicated separator sheets such as PATCH-T pages.
|
||||||
|
|
||||||
@@ -831,10 +831,10 @@ to `true`.
|
|||||||
When enabled, documents will be split at pages containing tag barcodes, similar to how
|
When enabled, documents will be split at pages containing tag barcodes, similar to how
|
||||||
ASN barcodes work. Key features:
|
ASN barcodes work. Key features:
|
||||||
|
|
||||||
- The page with the tag barcode is **retained** in the resulting document
|
- The page with the tag barcode is **retained** in the resulting document
|
||||||
- **Each split document extracts its own tags** - only tags on pages within that document are assigned
|
- **Each split document extracts its own tags** - only tags on pages within that document are assigned
|
||||||
- Multiple tag barcodes can trigger multiple splits in the same document
|
- Multiple tag barcodes can trigger multiple splits in the same document
|
||||||
- Works seamlessly with ASN barcodes - each split document gets its own ASN and tags
|
- Works seamlessly with ASN barcodes - each split document gets its own ASN and tags
|
||||||
|
|
||||||
This is useful for batch scanning where you place tag barcode pages between different
|
This is useful for batch scanning where you place tag barcode pages between different
|
||||||
documents to both separate and categorize them in a single operation.
|
documents to both separate and categorize them in a single operation.
|
||||||
@@ -996,9 +996,9 @@ If using docker, you'll need to add the following volume mounts to your `docker-
|
|||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
webserver:
|
webserver:
|
||||||
volumes:
|
volumes:
|
||||||
- /home/user/.gnupg/pubring.gpg:/usr/src/paperless/.gnupg/pubring.gpg
|
- /home/user/.gnupg/pubring.gpg:/usr/src/paperless/.gnupg/pubring.gpg
|
||||||
- <path to gpg-agent socket>:/usr/src/paperless/.gnupg/S.gpg-agent
|
- <path to gpg-agent socket>:/usr/src/paperless/.gnupg/S.gpg-agent
|
||||||
```
|
```
|
||||||
|
|
||||||
For a 'bare-metal' installation no further configuration is necessary. If you
|
For a 'bare-metal' installation no further configuration is necessary. If you
|
||||||
@@ -1006,9 +1006,9 @@ want to use a separate `GNUPG_HOME`, you can do so by configuring the [PAPERLESS
|
|||||||
|
|
||||||
### Troubleshooting
|
### Troubleshooting
|
||||||
|
|
||||||
- Make sure, that `gpg-agent` is running on your host machine
|
- Make sure, that `gpg-agent` is running on your host machine
|
||||||
- Make sure, that encryption and decryption works from inside the container using the `gpg` commands from above.
|
- Make sure, that encryption and decryption works from inside the container using the `gpg` commands from above.
|
||||||
- Check that all files in `/usr/src/paperless/.gnupg` have correct permissions
|
- Check that all files in `/usr/src/paperless/.gnupg` have correct permissions
|
||||||
|
|
||||||
```shell
|
```shell
|
||||||
paperless@9da1865df327:~/.gnupg$ ls -al
|
paperless@9da1865df327:~/.gnupg$ ls -al
|
||||||
|
|||||||
270
docs/api.md
270
docs/api.md
@@ -66,10 +66,10 @@ Full text searching is available on the `/api/documents/` endpoint. Two
|
|||||||
specific query parameters cause the API to return full text search
|
specific query parameters cause the API to return full text search
|
||||||
results:
|
results:
|
||||||
|
|
||||||
- `/api/documents/?query=your%20search%20query`: Search for a document
|
- `/api/documents/?query=your%20search%20query`: Search for a document
|
||||||
using a full text query. For details on the syntax, see [Basic Usage - Searching](usage.md#basic-usage_searching).
|
using a full text query. For details on the syntax, see [Basic Usage - Searching](usage.md#basic-usage_searching).
|
||||||
- `/api/documents/?more_like_id=1234`: Search for documents similar to
|
- `/api/documents/?more_like_id=1234`: Search for documents similar to
|
||||||
the document with id 1234.
|
the document with id 1234.
|
||||||
|
|
||||||
Pagination works exactly the same as it does for normal requests on this
|
Pagination works exactly the same as it does for normal requests on this
|
||||||
endpoint.
|
endpoint.
|
||||||
@@ -106,12 +106,12 @@ attribute with various information about the search results:
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
- `score` is an indication how well this document matches the query
|
- `score` is an indication how well this document matches the query
|
||||||
relative to the other search results.
|
relative to the other search results.
|
||||||
- `highlights` is an excerpt from the document content and highlights
|
- `highlights` is an excerpt from the document content and highlights
|
||||||
the search terms with `<span>` tags as shown above.
|
the search terms with `<span>` tags as shown above.
|
||||||
- `rank` is the index of the search results. The first result will
|
- `rank` is the index of the search results. The first result will
|
||||||
have rank 0.
|
have rank 0.
|
||||||
|
|
||||||
### Filtering by custom fields
|
### Filtering by custom fields
|
||||||
|
|
||||||
@@ -122,33 +122,33 @@ use cases:
|
|||||||
1. Documents with a custom field "due" (date) between Aug 1, 2024 and
|
1. Documents with a custom field "due" (date) between Aug 1, 2024 and
|
||||||
Sept 1, 2024 (inclusive):
|
Sept 1, 2024 (inclusive):
|
||||||
|
|
||||||
`?custom_field_query=["due", "range", ["2024-08-01", "2024-09-01"]]`
|
`?custom_field_query=["due", "range", ["2024-08-01", "2024-09-01"]]`
|
||||||
|
|
||||||
2. Documents with a custom field "customer" (text) that equals "bob"
|
2. Documents with a custom field "customer" (text) that equals "bob"
|
||||||
(case sensitive):
|
(case sensitive):
|
||||||
|
|
||||||
`?custom_field_query=["customer", "exact", "bob"]`
|
`?custom_field_query=["customer", "exact", "bob"]`
|
||||||
|
|
||||||
3. Documents with a custom field "answered" (boolean) set to `true`:
|
3. Documents with a custom field "answered" (boolean) set to `true`:
|
||||||
|
|
||||||
`?custom_field_query=["answered", "exact", true]`
|
`?custom_field_query=["answered", "exact", true]`
|
||||||
|
|
||||||
4. Documents with a custom field "favorite animal" (select) set to either
|
4. Documents with a custom field "favorite animal" (select) set to either
|
||||||
"cat" or "dog":
|
"cat" or "dog":
|
||||||
|
|
||||||
`?custom_field_query=["favorite animal", "in", ["cat", "dog"]]`
|
`?custom_field_query=["favorite animal", "in", ["cat", "dog"]]`
|
||||||
|
|
||||||
5. Documents with a custom field "address" (text) that is empty:
|
5. Documents with a custom field "address" (text) that is empty:
|
||||||
|
|
||||||
`?custom_field_query=["OR", [["address", "isnull", true], ["address", "exact", ""]]]`
|
`?custom_field_query=["OR", [["address", "isnull", true], ["address", "exact", ""]]]`
|
||||||
|
|
||||||
6. Documents that don't have a field called "foo":
|
6. Documents that don't have a field called "foo":
|
||||||
|
|
||||||
`?custom_field_query=["foo", "exists", false]`
|
`?custom_field_query=["foo", "exists", false]`
|
||||||
|
|
||||||
7. Documents that have document links "references" to both document 3 and 7:
|
7. Documents that have document links "references" to both document 3 and 7:
|
||||||
|
|
||||||
`?custom_field_query=["references", "contains", [3, 7]]`
|
`?custom_field_query=["references", "contains", [3, 7]]`
|
||||||
|
|
||||||
All field types support basic operations including `exact`, `in`, `isnull`,
|
All field types support basic operations including `exact`, `in`, `isnull`,
|
||||||
and `exists`. String, URL, and monetary fields support case-insensitive
|
and `exists`. String, URL, and monetary fields support case-insensitive
|
||||||
@@ -164,8 +164,8 @@ Get auto completions for a partial search term.
|
|||||||
|
|
||||||
Query parameters:
|
Query parameters:
|
||||||
|
|
||||||
- `term`: The incomplete term.
|
- `term`: The incomplete term.
|
||||||
- `limit`: Amount of results. Defaults to 10.
|
- `limit`: Amount of results. Defaults to 10.
|
||||||
|
|
||||||
Results returned by the endpoint are ordered by importance of the term
|
Results returned by the endpoint are ordered by importance of the term
|
||||||
in the document index. The first result is the term that has the highest
|
in the document index. The first result is the term that has the highest
|
||||||
@@ -189,19 +189,19 @@ from there.
|
|||||||
|
|
||||||
The endpoint supports the following optional form fields:
|
The endpoint supports the following optional form fields:
|
||||||
|
|
||||||
- `title`: Specify a title that the consumer should use for the
|
- `title`: Specify a title that the consumer should use for the
|
||||||
document.
|
document.
|
||||||
- `created`: Specify a DateTime where the document was created (e.g.
|
- `created`: Specify a DateTime where the document was created (e.g.
|
||||||
"2016-04-19" or "2016-04-19 06:15:00+02:00").
|
"2016-04-19" or "2016-04-19 06:15:00+02:00").
|
||||||
- `correspondent`: Specify the ID of a correspondent that the consumer
|
- `correspondent`: Specify the ID of a correspondent that the consumer
|
||||||
should use for the document.
|
should use for the document.
|
||||||
- `document_type`: Similar to correspondent.
|
- `document_type`: Similar to correspondent.
|
||||||
- `storage_path`: Similar to correspondent.
|
- `storage_path`: Similar to correspondent.
|
||||||
- `tags`: Similar to correspondent. Specify this multiple times to
|
- `tags`: Similar to correspondent. Specify this multiple times to
|
||||||
have multiple tags added to the document.
|
have multiple tags added to the document.
|
||||||
- `archive_serial_number`: An optional archive serial number to set.
|
- `archive_serial_number`: An optional archive serial number to set.
|
||||||
- `custom_fields`: Either an array of custom field ids to assign (with an empty
|
- `custom_fields`: Either an array of custom field ids to assign (with an empty
|
||||||
value) to the document or an object mapping field id -> value.
|
value) to the document or an object mapping field id -> value.
|
||||||
|
|
||||||
The endpoint will immediately return HTTP 200 if the document consumption
|
The endpoint will immediately return HTTP 200 if the document consumption
|
||||||
process was started successfully, with the UUID of the consumption task
|
process was started successfully, with the UUID of the consumption task
|
||||||
@@ -215,16 +215,16 @@ consumption including the ID of a created document if consumption succeeded.
|
|||||||
|
|
||||||
Document versions are file-level versions linked to one root document.
|
Document versions are file-level versions linked to one root document.
|
||||||
|
|
||||||
- Root document metadata (title, tags, correspondent, document type, storage path, custom fields, permissions) remains shared.
|
- Root document metadata (title, tags, correspondent, document type, storage path, custom fields, permissions) remains shared.
|
||||||
- Version-specific file data (file, mime type, checksums, archive info, extracted text content) belongs to the selected/latest version.
|
- Version-specific file data (file, mime type, checksums, archive info, extracted text content) belongs to the selected/latest version.
|
||||||
|
|
||||||
Version-aware endpoints:
|
Version-aware endpoints:
|
||||||
|
|
||||||
- `GET /api/documents/{id}/`: returns root document data; `content` resolves to latest version content by default. Use `?version={version_id}` to resolve content for a specific version.
|
- `GET /api/documents/{id}/`: returns root document data; `content` resolves to latest version content by default. Use `?version={version_id}` to resolve content for a specific version.
|
||||||
- `PATCH /api/documents/{id}/`: content updates target the selected version (`?version={version_id}`) or latest version by default; non-content metadata updates target the root document.
|
- `PATCH /api/documents/{id}/`: content updates target the selected version (`?version={version_id}`) or latest version by default; non-content metadata updates target the root document.
|
||||||
- `GET /api/documents/{id}/download/`, `GET /api/documents/{id}/preview/`, `GET /api/documents/{id}/thumb/`, `GET /api/documents/{id}/metadata/`: accept `?version={version_id}`.
|
- `GET /api/documents/{id}/download/`, `GET /api/documents/{id}/preview/`, `GET /api/documents/{id}/thumb/`, `GET /api/documents/{id}/metadata/`: accept `?version={version_id}`.
|
||||||
- `POST /api/documents/{id}/update_version/`: uploads a new version using multipart form field `document` and optional `version_label`.
|
- `POST /api/documents/{id}/update_version/`: uploads a new version using multipart form field `document` and optional `version_label`.
|
||||||
- `DELETE /api/documents/{root_id}/versions/{version_id}/`: deletes a non-root version.
|
- `DELETE /api/documents/{root_id}/versions/{version_id}/`: deletes a non-root version.
|
||||||
|
|
||||||
## Permissions
|
## Permissions
|
||||||
|
|
||||||
@@ -282,38 +282,74 @@ a json payload of the format:
|
|||||||
|
|
||||||
The following methods are supported:
|
The following methods are supported:
|
||||||
|
|
||||||
- `set_correspondent`
|
- `set_correspondent`
|
||||||
- Requires `parameters`: `{ "correspondent": CORRESPONDENT_ID }`
|
- Requires `parameters`: `{ "correspondent": CORRESPONDENT_ID }`
|
||||||
- `set_document_type`
|
- `set_document_type`
|
||||||
- Requires `parameters`: `{ "document_type": DOCUMENT_TYPE_ID }`
|
- Requires `parameters`: `{ "document_type": DOCUMENT_TYPE_ID }`
|
||||||
- `set_storage_path`
|
- `set_storage_path`
|
||||||
- Requires `parameters`: `{ "storage_path": STORAGE_PATH_ID }`
|
- Requires `parameters`: `{ "storage_path": STORAGE_PATH_ID }`
|
||||||
- `add_tag`
|
- `add_tag`
|
||||||
- Requires `parameters`: `{ "tag": TAG_ID }`
|
- Requires `parameters`: `{ "tag": TAG_ID }`
|
||||||
- `remove_tag`
|
- `remove_tag`
|
||||||
- Requires `parameters`: `{ "tag": TAG_ID }`
|
- Requires `parameters`: `{ "tag": TAG_ID }`
|
||||||
- `modify_tags`
|
- `modify_tags`
|
||||||
- Requires `parameters`: `{ "add_tags": [LIST_OF_TAG_IDS] }` and `{ "remove_tags": [LIST_OF_TAG_IDS] }`
|
- Requires `parameters`: `{ "add_tags": [LIST_OF_TAG_IDS] }` and `{ "remove_tags": [LIST_OF_TAG_IDS] }`
|
||||||
- `delete`
|
- `delete`
|
||||||
- No `parameters` required
|
- No `parameters` required
|
||||||
- `reprocess`
|
- `reprocess`
|
||||||
- No `parameters` required
|
- No `parameters` required
|
||||||
- `set_permissions`
|
- `set_permissions`
|
||||||
- Requires `parameters`:
|
- Requires `parameters`:
|
||||||
- `"set_permissions": PERMISSIONS_OBJ` (see format [above](#permissions)) and / or
|
- `"set_permissions": PERMISSIONS_OBJ` (see format [above](#permissions)) and / or
|
||||||
- `"owner": OWNER_ID or null`
|
- `"owner": OWNER_ID or null`
|
||||||
- `"merge": true or false` (defaults to false)
|
- `"merge": true or false` (defaults to false)
|
||||||
- The `merge` flag determines if the supplied permissions will overwrite all existing permissions (including
|
- The `merge` flag determines if the supplied permissions will overwrite all existing permissions (including
|
||||||
removing them) or be merged with existing permissions.
|
removing them) or be merged with existing permissions.
|
||||||
- `modify_custom_fields`
|
- `edit_pdf`
|
||||||
- Requires `parameters`:
|
- Requires `parameters`:
|
||||||
- `"add_custom_fields": { CUSTOM_FIELD_ID: VALUE }`: JSON object consisting of custom field id:value pairs to add to the document, can also be a list of custom field IDs
|
- `"doc_ids": [DOCUMENT_ID]` A list of a single document ID to edit.
|
||||||
to add with empty values.
|
- `"operations": [OPERATION, ...]` A list of operations to perform on the documents. Each operation is a dictionary
|
||||||
- `"remove_custom_fields": [CUSTOM_FIELD_ID]`: custom field ids to remove from the document.
|
with the following keys:
|
||||||
|
- `"page": PAGE_NUMBER` The page number to edit (1-based).
|
||||||
#### Document-editing operations
|
- `"rotate": DEGREES` Optional rotation in degrees (90, 180, 270).
|
||||||
|
- `"doc": OUTPUT_DOCUMENT_INDEX` Optional index of the output document for split operations.
|
||||||
Beginning with version 10+, the API supports individual endpoints for document-editing operations (`merge`, `rotate`, `edit_pdf`, etc), thus their documentation can be found in the API spec / viewer. Legacy document-editing methods via `/api/documents/bulk_edit/` are still supported for compatibility, are deprecated and clients should migrate to the individual endpoints before they are removed in a future version.
|
- Optional `parameters`:
|
||||||
|
- `"delete_original": true` to delete the original documents after editing.
|
||||||
|
- `"update_document": true` to add the edited PDF as a new version of the root document.
|
||||||
|
- `"include_metadata": true` to copy metadata from the original document to the edited document.
|
||||||
|
- `remove_password`
|
||||||
|
- Requires `parameters`:
|
||||||
|
- `"password": "PASSWORD_STRING"` The password to remove from the PDF documents.
|
||||||
|
- Optional `parameters`:
|
||||||
|
- `"update_document": true` to add the password-less PDF as a new version of the root document.
|
||||||
|
- `"delete_original": true` to delete the original document after editing.
|
||||||
|
- `"include_metadata": true` to copy metadata from the original document to the new password-less document.
|
||||||
|
- `merge`
|
||||||
|
- No additional `parameters` required.
|
||||||
|
- The ordering of the merged document is determined by the list of IDs.
|
||||||
|
- Optional `parameters`:
|
||||||
|
- `"metadata_document_id": DOC_ID` apply metadata (tags, correspondent, etc.) from this document to the merged document.
|
||||||
|
- `"delete_originals": true` to delete the original documents. This requires the calling user being the owner of
|
||||||
|
all documents that are merged.
|
||||||
|
- `split`
|
||||||
|
- Requires `parameters`:
|
||||||
|
- `"pages": [..]` The list should be a list of pages and/or a ranges, separated by commas e.g. `"[1,2-3,4,5-7]"`
|
||||||
|
- Optional `parameters`:
|
||||||
|
- `"delete_originals": true` to delete the original document after consumption. This requires the calling user being the owner of
|
||||||
|
the document.
|
||||||
|
- The split operation only accepts a single document.
|
||||||
|
- `rotate`
|
||||||
|
- Requires `parameters`:
|
||||||
|
- `"degrees": DEGREES`. Must be an integer i.e. 90, 180, 270
|
||||||
|
- `delete_pages`
|
||||||
|
- Requires `parameters`:
|
||||||
|
- `"pages": [..]` The list should be a list of integers e.g. `"[2,3,4]"`
|
||||||
|
- The delete_pages operation only accepts a single document.
|
||||||
|
- `modify_custom_fields`
|
||||||
|
- Requires `parameters`:
|
||||||
|
- `"add_custom_fields": { CUSTOM_FIELD_ID: VALUE }`: JSON object consisting of custom field id:value pairs to add to the document, can also be a list of custom field IDs
|
||||||
|
to add with empty values.
|
||||||
|
- `"remove_custom_fields": [CUSTOM_FIELD_ID]`: custom field ids to remove from the document.
|
||||||
|
|
||||||
### Objects
|
### Objects
|
||||||
|
|
||||||
@@ -333,38 +369,41 @@ operations, using the endpoint: `/api/bulk_edit_objects/`, which requires a json
|
|||||||
|
|
||||||
## API Versioning
|
## API Versioning
|
||||||
|
|
||||||
The REST API is versioned.
|
The REST API is versioned since Paperless-ngx 1.3.0.
|
||||||
|
|
||||||
- Versioning ensures that changes to the API don't break older
|
- Versioning ensures that changes to the API don't break older
|
||||||
clients.
|
clients.
|
||||||
- Clients specify the specific version of the API they wish to use
|
- Clients specify the specific version of the API they wish to use
|
||||||
with every request and Paperless will handle the request using the
|
with every request and Paperless will handle the request using the
|
||||||
specified API version.
|
specified API version.
|
||||||
- Even if the underlying data model changes, supported older API
|
- Even if the underlying data model changes, older API versions will
|
||||||
versions continue to serve compatible data.
|
always serve compatible data.
|
||||||
- If no version is specified, Paperless serves the configured default
|
- If no version is specified, Paperless will serve version 1 to ensure
|
||||||
API version (currently `10`).
|
compatibility with older clients that do not request a specific API
|
||||||
- Supported API versions are currently `9` and `10`.
|
version.
|
||||||
|
|
||||||
API versions are specified by submitting an additional HTTP `Accept`
|
API versions are specified by submitting an additional HTTP `Accept`
|
||||||
header with every request:
|
header with every request:
|
||||||
|
|
||||||
```
|
```
|
||||||
Accept: application/json; version=10
|
Accept: application/json; version=6
|
||||||
```
|
```
|
||||||
|
|
||||||
If an invalid version is specified, Paperless responds with
|
If an invalid version is specified, Paperless 1.3.0 will respond with
|
||||||
`406 Not Acceptable` and an error message in the body.
|
"406 Not Acceptable" and an error message in the body. Earlier
|
||||||
|
versions of Paperless will serve API version 1 regardless of whether a
|
||||||
|
version is specified via the `Accept` header.
|
||||||
|
|
||||||
If a client wishes to verify whether it is compatible with any given
|
If a client wishes to verify whether it is compatible with any given
|
||||||
server, the following procedure should be performed:
|
server, the following procedure should be performed:
|
||||||
|
|
||||||
1. Perform an _authenticated_ request against any API endpoint. The
|
1. Perform an _authenticated_ request against any API endpoint. If the
|
||||||
server will add two custom headers to the response:
|
server is on version 1.3.0 or newer, the server will add two custom
|
||||||
|
headers to the response:
|
||||||
|
|
||||||
```
|
```
|
||||||
X-Api-Version: 10
|
X-Api-Version: 2
|
||||||
X-Version: <server-version>
|
X-Version: 1.3.0
|
||||||
```
|
```
|
||||||
|
|
||||||
2. Determine whether the client is compatible with this server based on
|
2. Determine whether the client is compatible with this server based on
|
||||||
@@ -384,56 +423,51 @@ Initial API version.
|
|||||||
|
|
||||||
#### Version 2
|
#### Version 2
|
||||||
|
|
||||||
- Added field `Tag.color`. This read/write string field contains a hex
|
- Added field `Tag.color`. This read/write string field contains a hex
|
||||||
color such as `#a6cee3`.
|
color such as `#a6cee3`.
|
||||||
- Added read-only field `Tag.text_color`. This field contains the text
|
- Added read-only field `Tag.text_color`. This field contains the text
|
||||||
color to use for a specific tag, which is either black or white
|
color to use for a specific tag, which is either black or white
|
||||||
depending on the brightness of `Tag.color`.
|
depending on the brightness of `Tag.color`.
|
||||||
- Removed field `Tag.colour`.
|
- Removed field `Tag.colour`.
|
||||||
|
|
||||||
#### Version 3
|
#### Version 3
|
||||||
|
|
||||||
- Permissions endpoints have been added.
|
- Permissions endpoints have been added.
|
||||||
- The format of the `/api/ui_settings/` has changed.
|
- The format of the `/api/ui_settings/` has changed.
|
||||||
|
|
||||||
#### Version 4
|
#### Version 4
|
||||||
|
|
||||||
- Consumption templates were refactored to workflows and API endpoints
|
- Consumption templates were refactored to workflows and API endpoints
|
||||||
changed as such.
|
changed as such.
|
||||||
|
|
||||||
#### Version 5
|
#### Version 5
|
||||||
|
|
||||||
- Added bulk deletion methods for documents and objects.
|
- Added bulk deletion methods for documents and objects.
|
||||||
|
|
||||||
#### Version 6
|
#### Version 6
|
||||||
|
|
||||||
- Moved acknowledge tasks endpoint to be under `/api/tasks/acknowledge/`.
|
- Moved acknowledge tasks endpoint to be under `/api/tasks/acknowledge/`.
|
||||||
|
|
||||||
#### Version 7
|
#### Version 7
|
||||||
|
|
||||||
- The format of select type custom fields has changed to return the options
|
- The format of select type custom fields has changed to return the options
|
||||||
as an array of objects with `id` and `label` fields as opposed to a simple
|
as an array of objects with `id` and `label` fields as opposed to a simple
|
||||||
list of strings. When creating or updating a custom field value of a
|
list of strings. When creating or updating a custom field value of a
|
||||||
document for a select type custom field, the value should be the `id` of
|
document for a select type custom field, the value should be the `id` of
|
||||||
the option whereas previously was the index of the option.
|
the option whereas previously was the index of the option.
|
||||||
|
|
||||||
#### Version 8
|
#### Version 8
|
||||||
|
|
||||||
- The user field of document notes now returns a simplified user object
|
- The user field of document notes now returns a simplified user object
|
||||||
rather than just the user ID.
|
rather than just the user ID.
|
||||||
|
|
||||||
#### Version 9
|
#### Version 9
|
||||||
|
|
||||||
- The document `created` field is now a date, not a datetime. The
|
- The document `created` field is now a date, not a datetime. The
|
||||||
`created_date` field is considered deprecated and will be removed in a
|
`created_date` field is considered deprecated and will be removed in a
|
||||||
future version.
|
future version.
|
||||||
|
|
||||||
#### Version 10
|
#### Version 10
|
||||||
|
|
||||||
- The `show_on_dashboard` and `show_in_sidebar` fields of saved views have been
|
- The `show_on_dashboard` and `show_in_sidebar` fields of saved views have been
|
||||||
removed. Relevant settings are now stored in the UISettings model. Compatibility is maintained
|
removed. Relevant settings are now stored in the UISettings model.
|
||||||
for versions < 10 until support for API v9 is dropped.
|
|
||||||
- Document-editing operations such as `merge`, `rotate`, and `edit_pdf` have been
|
|
||||||
moved from the bulk edit endpoint to their own individual endpoints. Using these methods via
|
|
||||||
the bulk edit endpoint is still supported for compatibility with versions < 10 until support
|
|
||||||
for API v9 is dropped.
|
|
||||||
|
|||||||
9976
docs/changelog.md
9976
docs/changelog.md
File diff suppressed because it is too large
Load Diff
@@ -8,17 +8,17 @@ common [OCR](#ocr) related settings and some frontend settings. If set, these wi
|
|||||||
preference over the settings via environment variables. If not set, the environment setting
|
preference over the settings via environment variables. If not set, the environment setting
|
||||||
or applicable default will be utilized instead.
|
or applicable default will be utilized instead.
|
||||||
|
|
||||||
- If you run paperless on docker, `paperless.conf` is not used.
|
- If you run paperless on docker, `paperless.conf` is not used.
|
||||||
Rather, configure paperless by copying necessary options to
|
Rather, configure paperless by copying necessary options to
|
||||||
`docker-compose.env`.
|
`docker-compose.env`.
|
||||||
|
|
||||||
- If you are running paperless on anything else, paperless will search
|
- If you are running paperless on anything else, paperless will search
|
||||||
for the configuration file in these locations and use the first one
|
for the configuration file in these locations and use the first one
|
||||||
it finds:
|
it finds:
|
||||||
- The environment variable `PAPERLESS_CONFIGURATION_PATH`
|
- The environment variable `PAPERLESS_CONFIGURATION_PATH`
|
||||||
- `/path/to/paperless/paperless.conf`
|
- `/path/to/paperless/paperless.conf`
|
||||||
- `/etc/paperless.conf`
|
- `/etc/paperless.conf`
|
||||||
- `/usr/local/etc/paperless.conf`
|
- `/usr/local/etc/paperless.conf`
|
||||||
|
|
||||||
## Required services
|
## Required services
|
||||||
|
|
||||||
|
|||||||
@@ -6,23 +6,23 @@ on Paperless-ngx.
|
|||||||
Check out the source from GitHub. The repository is organized in the
|
Check out the source from GitHub. The repository is organized in the
|
||||||
following way:
|
following way:
|
||||||
|
|
||||||
- `main` always represents the latest release and will only see
|
- `main` always represents the latest release and will only see
|
||||||
changes when a new release is made.
|
changes when a new release is made.
|
||||||
- `dev` contains the code that will be in the next release.
|
- `dev` contains the code that will be in the next release.
|
||||||
- `feature-X` contains bigger changes that will be in some release, but
|
- `feature-X` contains bigger changes that will be in some release, but
|
||||||
not necessarily the next one.
|
not necessarily the next one.
|
||||||
|
|
||||||
When making functional changes to Paperless-ngx, _always_ make your changes
|
When making functional changes to Paperless-ngx, _always_ make your changes
|
||||||
on the `dev` branch.
|
on the `dev` branch.
|
||||||
|
|
||||||
Apart from that, the folder structure is as follows:
|
Apart from that, the folder structure is as follows:
|
||||||
|
|
||||||
- `docs/` - Documentation.
|
- `docs/` - Documentation.
|
||||||
- `src-ui/` - Code of the front end.
|
- `src-ui/` - Code of the front end.
|
||||||
- `src/` - Code of the back end.
|
- `src/` - Code of the back end.
|
||||||
- `scripts/` - Various scripts that help with different parts of
|
- `scripts/` - Various scripts that help with different parts of
|
||||||
development.
|
development.
|
||||||
- `docker/` - Files required to build the docker image.
|
- `docker/` - Files required to build the docker image.
|
||||||
|
|
||||||
## Contributing to Paperless-ngx
|
## Contributing to Paperless-ngx
|
||||||
|
|
||||||
@@ -75,13 +75,13 @@ first-time setup.
|
|||||||
4. Install the Python dependencies:
|
4. Install the Python dependencies:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
uv sync --group dev
|
$ uv sync --group dev
|
||||||
```
|
```
|
||||||
|
|
||||||
5. Install pre-commit hooks:
|
5. Install pre-commit hooks:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
uv run prek install
|
$ uv run prek install
|
||||||
```
|
```
|
||||||
|
|
||||||
6. Apply migrations and create a superuser (also can be done via the web UI) for your development instance:
|
6. Apply migrations and create a superuser (also can be done via the web UI) for your development instance:
|
||||||
@@ -89,22 +89,23 @@ first-time setup.
|
|||||||
```bash
|
```bash
|
||||||
# src/
|
# src/
|
||||||
|
|
||||||
uv run manage.py migrate
|
$ uv run manage.py migrate
|
||||||
uv run manage.py createsuperuser
|
$ uv run manage.py createsuperuser
|
||||||
```
|
```
|
||||||
|
|
||||||
7. You can now either ...
|
7. You can now either ...
|
||||||
- install Redis or
|
|
||||||
|
|
||||||
- use the included `scripts/start_services.sh` to use Docker to fire
|
- install Redis or
|
||||||
up a Redis instance (and some other services such as Tika,
|
|
||||||
Gotenberg and a database server) or
|
|
||||||
|
|
||||||
- spin up a bare Redis container
|
- use the included `scripts/start_services.sh` to use Docker to fire
|
||||||
|
up a Redis instance (and some other services such as Tika,
|
||||||
|
Gotenberg and a database server) or
|
||||||
|
|
||||||
```bash
|
- spin up a bare Redis container
|
||||||
docker run -d -p 6379:6379 --restart unless-stopped redis:latest
|
|
||||||
```
|
```
|
||||||
|
docker run -d -p 6379:6379 --restart unless-stopped redis:latest
|
||||||
|
```
|
||||||
|
|
||||||
8. Continue with either back-end or front-end development – or both :-).
|
8. Continue with either back-end or front-end development – or both :-).
|
||||||
|
|
||||||
@@ -117,18 +118,18 @@ work well for development, but you can use whatever you want.
|
|||||||
Configure the IDE to use the `src/`-folder as the base source folder.
|
Configure the IDE to use the `src/`-folder as the base source folder.
|
||||||
Configure the following launch configurations in your IDE:
|
Configure the following launch configurations in your IDE:
|
||||||
|
|
||||||
- `uv run manage.py runserver`
|
- `python3 manage.py runserver`
|
||||||
- `uv run manage.py document_consumer`
|
- `python3 manage.py document_consumer`
|
||||||
- `uv run celery --app paperless worker -l DEBUG` (or any other log level)
|
- `celery --app paperless worker -l DEBUG` (or any other log level)
|
||||||
|
|
||||||
To start them all:
|
To start them all:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# src/
|
# src/
|
||||||
|
|
||||||
uv run manage.py runserver & \
|
$ python3 manage.py runserver & \
|
||||||
uv run manage.py document_consumer & \
|
python3 manage.py document_consumer & \
|
||||||
uv run celery --app paperless worker -l DEBUG
|
celery --app paperless worker -l DEBUG
|
||||||
```
|
```
|
||||||
|
|
||||||
You might need the front end to test your back end code.
|
You might need the front end to test your back end code.
|
||||||
@@ -139,17 +140,17 @@ To build the front end once use this command:
|
|||||||
```bash
|
```bash
|
||||||
# src-ui/
|
# src-ui/
|
||||||
|
|
||||||
pnpm install
|
$ pnpm install
|
||||||
pnpm ng build --configuration production
|
$ ng build --configuration production
|
||||||
```
|
```
|
||||||
|
|
||||||
### Testing
|
### Testing
|
||||||
|
|
||||||
- Run `pytest` in the `src/` directory to execute all tests. This also
|
- Run `pytest` in the `src/` directory to execute all tests. This also
|
||||||
generates a HTML coverage report. When running tests, `paperless.conf`
|
generates a HTML coverage report. When running tests, `paperless.conf`
|
||||||
is loaded as well. However, the tests rely on the default
|
is loaded as well. However, the tests rely on the default
|
||||||
configuration. This is not ideal. But for now, make sure no settings
|
configuration. This is not ideal. But for now, make sure no settings
|
||||||
except for DEBUG are overridden when testing.
|
except for DEBUG are overridden when testing.
|
||||||
|
|
||||||
!!! note
|
!!! note
|
||||||
|
|
||||||
@@ -198,7 +199,7 @@ The front end is built using AngularJS. In order to get started, you need Node.j
|
|||||||
4. You can launch a development server by running:
|
4. You can launch a development server by running:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
pnpm ng serve
|
ng serve
|
||||||
```
|
```
|
||||||
|
|
||||||
This will automatically update whenever you save. However, in-place
|
This will automatically update whenever you save. However, in-place
|
||||||
@@ -216,21 +217,21 @@ commit. See [above](#code-formatting-with-pre-commit-hooks) for installation ins
|
|||||||
command such as
|
command such as
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
git ls-files -- '*.ts' | xargs uv run prek run prettier --files
|
$ git ls-files -- '*.ts' | xargs prek run prettier --files
|
||||||
```
|
```
|
||||||
|
|
||||||
Front end testing uses Jest and Playwright. Unit tests and e2e tests,
|
Front end testing uses Jest and Playwright. Unit tests and e2e tests,
|
||||||
respectively, can be run non-interactively with:
|
respectively, can be run non-interactively with:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
pnpm ng test
|
$ ng test
|
||||||
pnpm playwright test
|
$ npx playwright test
|
||||||
```
|
```
|
||||||
|
|
||||||
Playwright also includes a UI which can be run with:
|
Playwright also includes a UI which can be run with:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
pnpm playwright test --ui
|
$ npx playwright test --ui
|
||||||
```
|
```
|
||||||
|
|
||||||
### Building the frontend
|
### Building the frontend
|
||||||
@@ -238,7 +239,7 @@ pnpm playwright test --ui
|
|||||||
In order to build the front end and serve it as part of Django, execute:
|
In order to build the front end and serve it as part of Django, execute:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
pnpm ng build --configuration production
|
$ ng build --configuration production
|
||||||
```
|
```
|
||||||
|
|
||||||
This will build the front end and put it in a location from which the
|
This will build the front end and put it in a location from which the
|
||||||
@@ -253,14 +254,14 @@ these parts have to be translated separately.
|
|||||||
|
|
||||||
### Front end localization
|
### Front end localization
|
||||||
|
|
||||||
- The AngularJS front end does localization according to the [Angular
|
- The AngularJS front end does localization according to the [Angular
|
||||||
documentation](https://angular.io/guide/i18n).
|
documentation](https://angular.io/guide/i18n).
|
||||||
- The source language of the project is "en_US".
|
- The source language of the project is "en_US".
|
||||||
- The source strings end up in the file `src-ui/messages.xlf`.
|
- The source strings end up in the file `src-ui/messages.xlf`.
|
||||||
- The translated strings need to be placed in the
|
- The translated strings need to be placed in the
|
||||||
`src-ui/src/locale/` folder.
|
`src-ui/src/locale/` folder.
|
||||||
- In order to extract added or changed strings from the source files,
|
- In order to extract added or changed strings from the source files,
|
||||||
call `ng extract-i18n`.
|
call `ng extract-i18n`.
|
||||||
|
|
||||||
Adding new languages requires adding the translated files in the
|
Adding new languages requires adding the translated files in the
|
||||||
`src-ui/src/locale/` folder and adjusting a couple files.
|
`src-ui/src/locale/` folder and adjusting a couple files.
|
||||||
@@ -306,18 +307,18 @@ A majority of the strings that appear in the back end appear only when
|
|||||||
the admin is used. However, some of these are still shown on the front
|
the admin is used. However, some of these are still shown on the front
|
||||||
end (such as error messages).
|
end (such as error messages).
|
||||||
|
|
||||||
- The django application does localization according to the [Django
|
- The django application does localization according to the [Django
|
||||||
documentation](https://docs.djangoproject.com/en/3.1/topics/i18n/translation/).
|
documentation](https://docs.djangoproject.com/en/3.1/topics/i18n/translation/).
|
||||||
- The source language of the project is "en_US".
|
- The source language of the project is "en_US".
|
||||||
- Localization files end up in the folder `src/locale/`.
|
- Localization files end up in the folder `src/locale/`.
|
||||||
- In order to extract strings from the application, call
|
- In order to extract strings from the application, call
|
||||||
`uv run manage.py makemessages -l en_US`. This is important after
|
`python3 manage.py makemessages -l en_US`. This is important after
|
||||||
making changes to translatable strings.
|
making changes to translatable strings.
|
||||||
- The message files need to be compiled for them to show up in the
|
- The message files need to be compiled for them to show up in the
|
||||||
application. Call `uv run manage.py compilemessages` to do this.
|
application. Call `python3 manage.py compilemessages` to do this.
|
||||||
The generated files don't get committed into git, since these are
|
The generated files don't get committed into git, since these are
|
||||||
derived artifacts. The build pipeline takes care of executing this
|
derived artifacts. The build pipeline takes care of executing this
|
||||||
command.
|
command.
|
||||||
|
|
||||||
Adding new languages requires adding the translated files in the
|
Adding new languages requires adding the translated files in the
|
||||||
`src/locale/`-folder and adjusting the file
|
`src/locale/`-folder and adjusting the file
|
||||||
@@ -380,10 +381,10 @@ base code.
|
|||||||
Paperless-ngx uses parsers to add documents. A parser is
|
Paperless-ngx uses parsers to add documents. A parser is
|
||||||
responsible for:
|
responsible for:
|
||||||
|
|
||||||
- Retrieving the content from the original
|
- Retrieving the content from the original
|
||||||
- Creating a thumbnail
|
- Creating a thumbnail
|
||||||
- _optional:_ Retrieving a created date from the original
|
- _optional:_ Retrieving a created date from the original
|
||||||
- _optional:_ Creating an archived document from the original
|
- _optional:_ Creating an archived document from the original
|
||||||
|
|
||||||
Custom parsers can be added to Paperless-ngx to support more file types. In
|
Custom parsers can be added to Paperless-ngx to support more file types. In
|
||||||
order to do that, you need to write the parser itself and announce its
|
order to do that, you need to write the parser itself and announce its
|
||||||
@@ -441,17 +442,17 @@ def myparser_consumer_declaration(sender, **kwargs):
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
- `parser` is a reference to a class that extends `DocumentParser`.
|
- `parser` is a reference to a class that extends `DocumentParser`.
|
||||||
- `weight` is used whenever two or more parsers are able to parse a
|
- `weight` is used whenever two or more parsers are able to parse a
|
||||||
file: The parser with the higher weight wins. This can be used to
|
file: The parser with the higher weight wins. This can be used to
|
||||||
override the parsers provided by Paperless-ngx.
|
override the parsers provided by Paperless-ngx.
|
||||||
- `mime_types` is a dictionary. The keys are the mime types your
|
- `mime_types` is a dictionary. The keys are the mime types your
|
||||||
parser supports and the value is the default file extension that
|
parser supports and the value is the default file extension that
|
||||||
Paperless-ngx should use when storing files and serving them for
|
Paperless-ngx should use when storing files and serving them for
|
||||||
download. We could guess that from the file extensions, but some
|
download. We could guess that from the file extensions, but some
|
||||||
mime types have many extensions associated with them and the Python
|
mime types have many extensions associated with them and the Python
|
||||||
methods responsible for guessing the extension do not always return
|
methods responsible for guessing the extension do not always return
|
||||||
the same value.
|
the same value.
|
||||||
|
|
||||||
## Using Visual Studio Code devcontainer
|
## Using Visual Studio Code devcontainer
|
||||||
|
|
||||||
@@ -470,8 +471,9 @@ To get started:
|
|||||||
2. VS Code will prompt you with "Reopen in container". Do so and wait for the environment to start.
|
2. VS Code will prompt you with "Reopen in container". Do so and wait for the environment to start.
|
||||||
|
|
||||||
3. In case your host operating system is Windows:
|
3. In case your host operating system is Windows:
|
||||||
- The Source Control view in Visual Studio Code might show: "The detected Git repository is potentially unsafe as the folder is owned by someone other than the current user." Use "Manage Unsafe Repositories" to fix this.
|
|
||||||
- Git might have detecteded modifications for all files, because Windows is using CRLF line endings. Run `git checkout .` in the containers terminal to fix this issue.
|
- The Source Control view in Visual Studio Code might show: "The detected Git repository is potentially unsafe as the folder is owned by someone other than the current user." Use "Manage Unsafe Repositories" to fix this.
|
||||||
|
- Git might have detecteded modifications for all files, because Windows is using CRLF line endings. Run `git checkout .` in the containers terminal to fix this issue.
|
||||||
|
|
||||||
4. Initialize the project by running the task **Project Setup: Run all Init Tasks**. This
|
4. Initialize the project by running the task **Project Setup: Run all Init Tasks**. This
|
||||||
will initialize the database tables and create a superuser. Then you can compile the front end
|
will initialize the database tables and create a superuser. Then you can compile the front end
|
||||||
@@ -536,12 +538,12 @@ class MyDateParserPlugin(DateParserPluginBase):
|
|||||||
|
|
||||||
Your parser instance is initialized with a `DateParserConfig` object accessible via `self.config`. This provides:
|
Your parser instance is initialized with a `DateParserConfig` object accessible via `self.config`. This provides:
|
||||||
|
|
||||||
- `languages: list[str]` - List of language codes for date parsing
|
- `languages: list[str]` - List of language codes for date parsing
|
||||||
- `timezone_str: str` - Timezone string for date localization
|
- `timezone_str: str` - Timezone string for date localization
|
||||||
- `ignore_dates: set[datetime.date]` - Dates that should be filtered out
|
- `ignore_dates: set[datetime.date]` - Dates that should be filtered out
|
||||||
- `reference_time: datetime.datetime` - Current time for filtering future dates
|
- `reference_time: datetime.datetime` - Current time for filtering future dates
|
||||||
- `filename_date_order: str | None` - Date order preference for filenames (e.g., "DMY", "MDY")
|
- `filename_date_order: str | None` - Date order preference for filenames (e.g., "DMY", "MDY")
|
||||||
- `content_date_order: str` - Date order preference for content
|
- `content_date_order: str` - Date order preference for content
|
||||||
|
|
||||||
The base class provides two helper methods you can use:
|
The base class provides two helper methods you can use:
|
||||||
|
|
||||||
|
|||||||
34
docs/faq.md
34
docs/faq.md
@@ -44,28 +44,28 @@ system. On Linux, chances are high that this location is
|
|||||||
You can always drag those files out of that folder to use them
|
You can always drag those files out of that folder to use them
|
||||||
elsewhere. Here are a couple notes about that.
|
elsewhere. Here are a couple notes about that.
|
||||||
|
|
||||||
- Paperless-ngx never modifies your original documents. It keeps
|
- Paperless-ngx never modifies your original documents. It keeps
|
||||||
checksums of all documents and uses a scheduled sanity checker to
|
checksums of all documents and uses a scheduled sanity checker to
|
||||||
check that they remain the same.
|
check that they remain the same.
|
||||||
- By default, paperless uses the internal ID of each document as its
|
- By default, paperless uses the internal ID of each document as its
|
||||||
filename. This might not be very convenient for export. However, you
|
filename. This might not be very convenient for export. However, you
|
||||||
can adjust the way files are stored in paperless by
|
can adjust the way files are stored in paperless by
|
||||||
[configuring the filename format](advanced_usage.md#file-name-handling).
|
[configuring the filename format](advanced_usage.md#file-name-handling).
|
||||||
- [The exporter](administration.md#exporter) is
|
- [The exporter](administration.md#exporter) is
|
||||||
another easy way to get your files out of paperless with reasonable
|
another easy way to get your files out of paperless with reasonable
|
||||||
file names.
|
file names.
|
||||||
|
|
||||||
## _What file types does paperless-ngx support?_
|
## _What file types does paperless-ngx support?_
|
||||||
|
|
||||||
**A:** Currently, the following files are supported:
|
**A:** Currently, the following files are supported:
|
||||||
|
|
||||||
- PDF documents, PNG images, JPEG images, TIFF images, GIF images and
|
- PDF documents, PNG images, JPEG images, TIFF images, GIF images and
|
||||||
WebP images are processed with OCR and converted into PDF documents.
|
WebP images are processed with OCR and converted into PDF documents.
|
||||||
- Plain text documents are supported as well and are added verbatim to
|
- Plain text documents are supported as well and are added verbatim to
|
||||||
paperless.
|
paperless.
|
||||||
- With the optional Tika integration enabled (see [Tika configuration](https://docs.paperless-ngx.com/configuration#tika)),
|
- With the optional Tika integration enabled (see [Tika configuration](https://docs.paperless-ngx.com/configuration#tika)),
|
||||||
Paperless also supports various Office documents (.docx, .doc, odt,
|
Paperless also supports various Office documents (.docx, .doc, odt,
|
||||||
.ppt, .pptx, .odp, .xls, .xlsx, .ods).
|
.ppt, .pptx, .odp, .xls, .xlsx, .ods).
|
||||||
|
|
||||||
Paperless-ngx determines the type of a file by inspecting its content
|
Paperless-ngx determines the type of a file by inspecting its content
|
||||||
rather than its file extensions. However, files processed via the
|
rather than its file extensions. However, files processed via the
|
||||||
|
|||||||
@@ -28,36 +28,36 @@ physical documents into a searchable online archive so you can keep, well, _less
|
|||||||
|
|
||||||
## Features
|
## Features
|
||||||
|
|
||||||
- **Organize and index** your scanned documents with tags, correspondents, types, and more.
|
- **Organize and index** your scanned documents with tags, correspondents, types, and more.
|
||||||
- _Your_ data is stored locally on _your_ server and is never transmitted or shared in any way, unless you explicitly choose to do so.
|
- _Your_ data is stored locally on _your_ server and is never transmitted or shared in any way, unless you explicitly choose to do so.
|
||||||
- Performs **OCR** on your documents, adding searchable and selectable text, even to documents scanned with only images.
|
- Performs **OCR** on your documents, adding searchable and selectable text, even to documents scanned with only images.
|
||||||
- Utilizes the open-source Tesseract engine to recognize more than 100 languages.
|
- Utilizes the open-source Tesseract engine to recognize more than 100 languages.
|
||||||
- _New!_ Supports remote OCR with Azure AI (opt-in).
|
- _New!_ Supports remote OCR with Azure AI (opt-in).
|
||||||
- Documents are saved as PDF/A format which is designed for long term storage, alongside the unaltered originals.
|
- Documents are saved as PDF/A format which is designed for long term storage, alongside the unaltered originals.
|
||||||
- Uses machine-learning to automatically add tags, correspondents and document types to your documents.
|
- Uses machine-learning to automatically add tags, correspondents and document types to your documents.
|
||||||
- **New**: Paperless-ngx can now leverage AI (Large Language Models or LLMs) for document suggestions. This is an optional feature that can be enabled (and is disabled by default).
|
- **New**: Paperless-ngx can now leverage AI (Large Language Models or LLMs) for document suggestions. This is an optional feature that can be enabled (and is disabled by default).
|
||||||
- Supports PDF documents, images, plain text files, Office documents (Word, Excel, PowerPoint, and LibreOffice equivalents)[^1] and more.
|
- Supports PDF documents, images, plain text files, Office documents (Word, Excel, PowerPoint, and LibreOffice equivalents)[^1] and more.
|
||||||
- Paperless stores your documents plain on disk. Filenames and folders are managed by paperless and their format can be configured freely with different configurations assigned to different documents.
|
- Paperless stores your documents plain on disk. Filenames and folders are managed by paperless and their format can be configured freely with different configurations assigned to different documents.
|
||||||
- **Beautiful, modern web application** that features:
|
- **Beautiful, modern web application** that features:
|
||||||
- Customizable dashboard with statistics.
|
- Customizable dashboard with statistics.
|
||||||
- Filtering by tags, correspondents, types, and more.
|
- Filtering by tags, correspondents, types, and more.
|
||||||
- Bulk editing of tags, correspondents, types and more.
|
- Bulk editing of tags, correspondents, types and more.
|
||||||
- Drag-and-drop uploading of documents throughout the app.
|
- Drag-and-drop uploading of documents throughout the app.
|
||||||
- Customizable views can be saved and displayed on the dashboard and / or sidebar.
|
- Customizable views can be saved and displayed on the dashboard and / or sidebar.
|
||||||
- Support for custom fields of various data types.
|
- Support for custom fields of various data types.
|
||||||
- Shareable public links with optional expiration.
|
- Shareable public links with optional expiration.
|
||||||
- **Full text search** helps you find what you need:
|
- **Full text search** helps you find what you need:
|
||||||
- Auto completion suggests relevant words from your documents.
|
- Auto completion suggests relevant words from your documents.
|
||||||
- Results are sorted by relevance to your search query.
|
- Results are sorted by relevance to your search query.
|
||||||
- Highlighting shows you which parts of the document matched the query.
|
- Highlighting shows you which parts of the document matched the query.
|
||||||
- Searching for similar documents ("More like this")
|
- Searching for similar documents ("More like this")
|
||||||
- **Email processing**[^1]: import documents from your email accounts:
|
- **Email processing**[^1]: import documents from your email accounts:
|
||||||
- Configure multiple accounts and rules for each account.
|
- Configure multiple accounts and rules for each account.
|
||||||
- After processing, paperless can perform actions on the messages such as marking as read, deleting and more.
|
- After processing, paperless can perform actions on the messages such as marking as read, deleting and more.
|
||||||
- A built-in robust **multi-user permissions** system that supports 'global' permissions as well as per document or object.
|
- A built-in robust **multi-user permissions** system that supports 'global' permissions as well as per document or object.
|
||||||
- A powerful workflow system that gives you even more control.
|
- A powerful workflow system that gives you even more control.
|
||||||
- **Optimized** for multi core systems: Paperless-ngx consumes multiple documents in parallel.
|
- **Optimized** for multi core systems: Paperless-ngx consumes multiple documents in parallel.
|
||||||
- The integrated sanity checker makes sure that your document archive is in good health.
|
- The integrated sanity checker makes sure that your document archive is in good health.
|
||||||
|
|
||||||
[^1]: Office document and email consumption support is optional and provided by Apache Tika (see [configuration](https://docs.paperless-ngx.com/configuration/#tika))
|
[^1]: Office document and email consumption support is optional and provided by Apache Tika (see [configuration](https://docs.paperless-ngx.com/configuration/#tika))
|
||||||
|
|
||||||
|
|||||||
@@ -42,12 +42,12 @@ The `CONSUMER_BARCODE_SCANNER` setting has been removed. zxing-cpp is now the on
|
|||||||
|
|
||||||
### Action Required
|
### Action Required
|
||||||
|
|
||||||
- If you were already using `CONSUMER_BARCODE_SCANNER=ZXING`, simply remove the setting.
|
- If you were already using `CONSUMER_BARCODE_SCANNER=ZXING`, simply remove the setting.
|
||||||
- If you had `CONSUMER_BARCODE_SCANNER=PYZBAR` or were using the default, no functional changes are needed beyond
|
- If you had `CONSUMER_BARCODE_SCANNER=PYZBAR` or were using the default, no functional changes are needed beyond
|
||||||
removing the setting. zxing-cpp supports all the same barcode formats and you should see improved detection
|
removing the setting. zxing-cpp supports all the same barcode formats and you should see improved detection
|
||||||
reliability.
|
reliability.
|
||||||
- The `libzbar0` / `libzbar-dev` system packages are no longer required and can be removed from any custom Docker
|
- The `libzbar0` / `libzbar-dev` system packages are no longer required and can be removed from any custom Docker
|
||||||
images or host installations.
|
images or host installations.
|
||||||
|
|
||||||
## Database Engine
|
## Database Engine
|
||||||
|
|
||||||
|
|||||||
235
docs/setup.md
235
docs/setup.md
@@ -44,8 +44,8 @@ account. In short, it automates the [Docker Compose setup](#docker) described be
|
|||||||
|
|
||||||
#### Prerequisites
|
#### Prerequisites
|
||||||
|
|
||||||
- Docker and Docker Compose must be [installed](https://docs.docker.com/engine/install/){:target="\_blank"}.
|
- Docker and Docker Compose must be [installed](https://docs.docker.com/engine/install/){:target="\_blank"}.
|
||||||
- macOS users will need [GNU sed](https://formulae.brew.sh/formula/gnu-sed) with support for running as `sed` as well as [wget](https://formulae.brew.sh/formula/wget).
|
- macOS users will need [GNU sed](https://formulae.brew.sh/formula/gnu-sed) with support for running as `sed` as well as [wget](https://formulae.brew.sh/formula/wget).
|
||||||
|
|
||||||
#### Run the installation script
|
#### Run the installation script
|
||||||
|
|
||||||
@@ -63,7 +63,7 @@ credentials you provided during the installation script.
|
|||||||
|
|
||||||
#### Prerequisites
|
#### Prerequisites
|
||||||
|
|
||||||
- Docker and Docker Compose must be [installed](https://docs.docker.com/engine/install/){:target="\_blank"}.
|
- Docker and Docker Compose must be [installed](https://docs.docker.com/engine/install/){:target="\_blank"}.
|
||||||
|
|
||||||
#### Installation
|
#### Installation
|
||||||
|
|
||||||
@@ -101,7 +101,7 @@ credentials you provided during the installation script.
|
|||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
ports:
|
ports:
|
||||||
- 8010:8000
|
- 8010:8000
|
||||||
```
|
```
|
||||||
|
|
||||||
3. Modify `docker-compose.env` with any configuration options you need.
|
3. Modify `docker-compose.env` with any configuration options you need.
|
||||||
@@ -145,11 +145,11 @@ a [superuser](usage.md#superusers) account.
|
|||||||
If you want to run Paperless as a rootless container, make this
|
If you want to run Paperless as a rootless container, make this
|
||||||
change in `docker-compose.yml`:
|
change in `docker-compose.yml`:
|
||||||
|
|
||||||
- Set the `user` running the container to map to the `paperless`
|
- Set the `user` running the container to map to the `paperless`
|
||||||
user in the container. This value (`user_id` below) should be
|
user in the container. This value (`user_id` below) should be
|
||||||
the same ID that `USERMAP_UID` and `USERMAP_GID` are set to in
|
the same ID that `USERMAP_UID` and `USERMAP_GID` are set to in
|
||||||
`docker-compose.env`. See `USERMAP_UID` and `USERMAP_GID`
|
`docker-compose.env`. See `USERMAP_UID` and `USERMAP_GID`
|
||||||
[here](configuration.md#docker).
|
[here](configuration.md#docker).
|
||||||
|
|
||||||
Your entry for Paperless should contain something like:
|
Your entry for Paperless should contain something like:
|
||||||
|
|
||||||
@@ -171,25 +171,26 @@ to enable polling and disable inotify. See [here](configuration.md#polling).
|
|||||||
|
|
||||||
#### Prerequisites
|
#### Prerequisites
|
||||||
|
|
||||||
- Paperless runs on Linux only, Windows is not supported.
|
- Paperless runs on Linux only, Windows is not supported.
|
||||||
- Python 3.11, 3.12, 3.13, or 3.14 is required. As a policy, Paperless-ngx aims to support at least the three most recent Python versions and drops support for versions as they reach end-of-life. Newer versions may work, but some dependencies may not be fully compatible.
|
- Python 3.11, 3.12, 3.13, or 3.14 is required. As a policy, Paperless-ngx aims to support at least the three most recent Python versions and drops support for versions as they reach end-of-life. Newer versions may work, but some dependencies may not be fully compatible.
|
||||||
|
|
||||||
#### Installation
|
#### Installation
|
||||||
|
|
||||||
1. Install dependencies. Paperless requires the following packages:
|
1. Install dependencies. Paperless requires the following packages:
|
||||||
- `python3`
|
|
||||||
- `python3-pip`
|
- `python3`
|
||||||
- `python3-dev`
|
- `python3-pip`
|
||||||
- `default-libmysqlclient-dev` for MariaDB
|
- `python3-dev`
|
||||||
- `pkg-config` for mysqlclient (python dependency)
|
- `default-libmysqlclient-dev` for MariaDB
|
||||||
- `fonts-liberation` for generating thumbnails for plain text
|
- `pkg-config` for mysqlclient (python dependency)
|
||||||
files
|
- `fonts-liberation` for generating thumbnails for plain text
|
||||||
- `imagemagick` >= 6 for PDF conversion
|
files
|
||||||
- `gnupg` for handling encrypted documents
|
- `imagemagick` >= 6 for PDF conversion
|
||||||
- `libpq-dev` for PostgreSQL
|
- `gnupg` for handling encrypted documents
|
||||||
- `libmagic-dev` for mime type detection
|
- `libpq-dev` for PostgreSQL
|
||||||
- `mariadb-client` for MariaDB compile time
|
- `libmagic-dev` for mime type detection
|
||||||
- `poppler-utils` for barcode detection
|
- `mariadb-client` for MariaDB compile time
|
||||||
|
- `poppler-utils` for barcode detection
|
||||||
|
|
||||||
Use this list for your preferred package management:
|
Use this list for your preferred package management:
|
||||||
|
|
||||||
@@ -199,17 +200,18 @@ to enable polling and disable inotify. See [here](configuration.md#polling).
|
|||||||
|
|
||||||
These dependencies are required for OCRmyPDF, which is used for text
|
These dependencies are required for OCRmyPDF, which is used for text
|
||||||
recognition.
|
recognition.
|
||||||
- `unpaper`
|
|
||||||
- `ghostscript`
|
- `unpaper`
|
||||||
- `icc-profiles-free`
|
- `ghostscript`
|
||||||
- `qpdf`
|
- `icc-profiles-free`
|
||||||
- `liblept5`
|
- `qpdf`
|
||||||
- `libxml2`
|
- `liblept5`
|
||||||
- `pngquant` (suggested for certain PDF image optimizations)
|
- `libxml2`
|
||||||
- `zlib1g`
|
- `pngquant` (suggested for certain PDF image optimizations)
|
||||||
- `tesseract-ocr` >= 4.0.0 for OCR
|
- `zlib1g`
|
||||||
- `tesseract-ocr` language packs (`tesseract-ocr-eng`,
|
- `tesseract-ocr` >= 4.0.0 for OCR
|
||||||
`tesseract-ocr-deu`, etc)
|
- `tesseract-ocr` language packs (`tesseract-ocr-eng`,
|
||||||
|
`tesseract-ocr-deu`, etc)
|
||||||
|
|
||||||
Use this list for your preferred package management:
|
Use this list for your preferred package management:
|
||||||
|
|
||||||
@@ -218,14 +220,16 @@ to enable polling and disable inotify. See [here](configuration.md#polling).
|
|||||||
```
|
```
|
||||||
|
|
||||||
On Raspberry Pi, these libraries are required as well:
|
On Raspberry Pi, these libraries are required as well:
|
||||||
- `libatlas-base-dev`
|
|
||||||
- `libxslt1-dev`
|
- `libatlas-base-dev`
|
||||||
- `mime-support`
|
- `libxslt1-dev`
|
||||||
|
- `mime-support`
|
||||||
|
|
||||||
You will also need these for installing some of the python dependencies:
|
You will also need these for installing some of the python dependencies:
|
||||||
- `build-essential`
|
|
||||||
- `python3-setuptools`
|
- `build-essential`
|
||||||
- `python3-wheel`
|
- `python3-setuptools`
|
||||||
|
- `python3-wheel`
|
||||||
|
|
||||||
Use this list for your preferred package management:
|
Use this list for your preferred package management:
|
||||||
|
|
||||||
@@ -275,41 +279,44 @@ to enable polling and disable inotify. See [here](configuration.md#polling).
|
|||||||
6. Configure Paperless-ngx. See [configuration](configuration.md) for details.
|
6. Configure Paperless-ngx. See [configuration](configuration.md) for details.
|
||||||
Edit the included `paperless.conf` and adjust the settings to your
|
Edit the included `paperless.conf` and adjust the settings to your
|
||||||
needs. Required settings for getting Paperless-ngx running are:
|
needs. Required settings for getting Paperless-ngx running are:
|
||||||
- [`PAPERLESS_REDIS`](configuration.md#PAPERLESS_REDIS) should point to your Redis server, such as
|
|
||||||
`redis://localhost:6379`.
|
- [`PAPERLESS_REDIS`](configuration.md#PAPERLESS_REDIS) should point to your Redis server, such as
|
||||||
- [`PAPERLESS_DBENGINE`](configuration.md#PAPERLESS_DBENGINE) is optional, and should be one of `postgres`,
|
`redis://localhost:6379`.
|
||||||
`mariadb`, or `sqlite`
|
- [`PAPERLESS_DBENGINE`](configuration.md#PAPERLESS_DBENGINE) is optional, and should be one of `postgres`,
|
||||||
- [`PAPERLESS_DBHOST`](configuration.md#PAPERLESS_DBHOST) should be the hostname on which your
|
`mariadb`, or `sqlite`
|
||||||
PostgreSQL server is running. Do not configure this to use
|
- [`PAPERLESS_DBHOST`](configuration.md#PAPERLESS_DBHOST) should be the hostname on which your
|
||||||
SQLite instead. Also configure port, database name, user and
|
PostgreSQL server is running. Do not configure this to use
|
||||||
password as necessary.
|
SQLite instead. Also configure port, database name, user and
|
||||||
- [`PAPERLESS_CONSUMPTION_DIR`](configuration.md#PAPERLESS_CONSUMPTION_DIR) should point to the folder
|
password as necessary.
|
||||||
that Paperless-ngx should watch for incoming documents.
|
- [`PAPERLESS_CONSUMPTION_DIR`](configuration.md#PAPERLESS_CONSUMPTION_DIR) should point to the folder
|
||||||
Likewise, [`PAPERLESS_DATA_DIR`](configuration.md#PAPERLESS_DATA_DIR) and
|
that Paperless-ngx should watch for incoming documents.
|
||||||
[`PAPERLESS_MEDIA_ROOT`](configuration.md#PAPERLESS_MEDIA_ROOT) define where Paperless-ngx stores its data.
|
Likewise, [`PAPERLESS_DATA_DIR`](configuration.md#PAPERLESS_DATA_DIR) and
|
||||||
If needed, these can point to the same directory.
|
[`PAPERLESS_MEDIA_ROOT`](configuration.md#PAPERLESS_MEDIA_ROOT) define where Paperless-ngx stores its data.
|
||||||
- [`PAPERLESS_SECRET_KEY`](configuration.md#PAPERLESS_SECRET_KEY) should be a random sequence of
|
If needed, these can point to the same directory.
|
||||||
characters. It's used for authentication. Failure to do so
|
- [`PAPERLESS_SECRET_KEY`](configuration.md#PAPERLESS_SECRET_KEY) should be a random sequence of
|
||||||
allows third parties to forge authentication credentials.
|
characters. It's used for authentication. Failure to do so
|
||||||
- Set [`PAPERLESS_URL`](configuration.md#PAPERLESS_URL) if you are behind a reverse proxy. This should
|
allows third parties to forge authentication credentials.
|
||||||
point to your domain. Please see
|
- Set [`PAPERLESS_URL`](configuration.md#PAPERLESS_URL) if you are behind a reverse proxy. This should
|
||||||
[configuration](configuration.md) for more
|
point to your domain. Please see
|
||||||
information.
|
[configuration](configuration.md) for more
|
||||||
|
information.
|
||||||
|
|
||||||
You can make many more adjustments, especially for OCR.
|
You can make many more adjustments, especially for OCR.
|
||||||
The following options are recommended for most users:
|
The following options are recommended for most users:
|
||||||
- Set [`PAPERLESS_OCR_LANGUAGE`](configuration.md#PAPERLESS_OCR_LANGUAGE) to the language most of your
|
|
||||||
documents are written in.
|
- Set [`PAPERLESS_OCR_LANGUAGE`](configuration.md#PAPERLESS_OCR_LANGUAGE) to the language most of your
|
||||||
- Set [`PAPERLESS_TIME_ZONE`](configuration.md#PAPERLESS_TIME_ZONE) to your local time zone.
|
documents are written in.
|
||||||
|
- Set [`PAPERLESS_TIME_ZONE`](configuration.md#PAPERLESS_TIME_ZONE) to your local time zone.
|
||||||
|
|
||||||
!!! warning
|
!!! warning
|
||||||
|
|
||||||
Ensure your Redis instance [is secured](https://redis.io/docs/latest/operate/oss_and_stack/management/security/).
|
Ensure your Redis instance [is secured](https://redis.io/docs/latest/operate/oss_and_stack/management/security/).
|
||||||
|
|
||||||
7. Create the following directories if they do not already exist:
|
7. Create the following directories if they do not already exist:
|
||||||
- `/opt/paperless/media`
|
|
||||||
- `/opt/paperless/data`
|
- `/opt/paperless/media`
|
||||||
- `/opt/paperless/consume`
|
- `/opt/paperless/data`
|
||||||
|
- `/opt/paperless/consume`
|
||||||
|
|
||||||
Adjust these paths if you configured different folders.
|
Adjust these paths if you configured different folders.
|
||||||
Then verify that the `paperless` user has write permissions:
|
Then verify that the `paperless` user has write permissions:
|
||||||
@@ -384,10 +391,11 @@ to enable polling and disable inotify. See [here](configuration.md#polling).
|
|||||||
starting point.
|
starting point.
|
||||||
|
|
||||||
Paperless needs:
|
Paperless needs:
|
||||||
- The `webserver` script to run the webserver.
|
|
||||||
- The `consumer` script to watch the input folder.
|
- The `webserver` script to run the webserver.
|
||||||
- The `taskqueue` script for background workers (document consumption, etc.).
|
- The `consumer` script to watch the input folder.
|
||||||
- The `scheduler` script for periodic tasks such as email checking.
|
- The `taskqueue` script for background workers (document consumption, etc.).
|
||||||
|
- The `scheduler` script for periodic tasks such as email checking.
|
||||||
|
|
||||||
!!! note
|
!!! note
|
||||||
|
|
||||||
@@ -493,19 +501,19 @@ your setup depending on how you installed Paperless.
|
|||||||
This section describes how to update an existing Paperless Docker
|
This section describes how to update an existing Paperless Docker
|
||||||
installation. Keep these points in mind:
|
installation. Keep these points in mind:
|
||||||
|
|
||||||
- Read the [changelog](changelog.md) and
|
- Read the [changelog](changelog.md) and
|
||||||
take note of breaking changes.
|
take note of breaking changes.
|
||||||
- Decide whether to stay on SQLite or migrate to PostgreSQL.
|
- Decide whether to stay on SQLite or migrate to PostgreSQL.
|
||||||
Both work fine with Paperless-ngx.
|
Both work fine with Paperless-ngx.
|
||||||
However, if you already have a database server running
|
However, if you already have a database server running
|
||||||
for other services, you might as well use it for Paperless as well.
|
for other services, you might as well use it for Paperless as well.
|
||||||
- The task scheduler of Paperless, which is used to execute periodic
|
- The task scheduler of Paperless, which is used to execute periodic
|
||||||
tasks such as email checking and maintenance, requires a
|
tasks such as email checking and maintenance, requires a
|
||||||
[Redis](https://redis.io/) message broker instance. The
|
[Redis](https://redis.io/) message broker instance. The
|
||||||
Docker Compose route takes care of that.
|
Docker Compose route takes care of that.
|
||||||
- The layout of the folder structure for your documents and data
|
- The layout of the folder structure for your documents and data
|
||||||
remains the same, so you can plug your old Docker volumes into
|
remains the same, so you can plug your old Docker volumes into
|
||||||
paperless-ngx and expect it to find everything where it should be.
|
paperless-ngx and expect it to find everything where it should be.
|
||||||
|
|
||||||
Migration to Paperless-ngx is then performed in a few simple steps:
|
Migration to Paperless-ngx is then performed in a few simple steps:
|
||||||
|
|
||||||
@@ -590,6 +598,7 @@ commands as well.
|
|||||||
1. Stop and remove the Paperless container.
|
1. Stop and remove the Paperless container.
|
||||||
2. If using an external database, stop that container.
|
2. If using an external database, stop that container.
|
||||||
3. Update Redis configuration.
|
3. Update Redis configuration.
|
||||||
|
|
||||||
1. If `REDIS_URL` is already set, change it to [`PAPERLESS_REDIS`](configuration.md#PAPERLESS_REDIS)
|
1. If `REDIS_URL` is already set, change it to [`PAPERLESS_REDIS`](configuration.md#PAPERLESS_REDIS)
|
||||||
and continue to step 4.
|
and continue to step 4.
|
||||||
|
|
||||||
@@ -601,18 +610,22 @@ commands as well.
|
|||||||
the new Redis container.
|
the new Redis container.
|
||||||
|
|
||||||
4. Update user mapping.
|
4. Update user mapping.
|
||||||
|
|
||||||
1. If set, change the environment variable `PUID` to `USERMAP_UID`.
|
1. If set, change the environment variable `PUID` to `USERMAP_UID`.
|
||||||
|
|
||||||
1. If set, change the environment variable `PGID` to `USERMAP_GID`.
|
1. If set, change the environment variable `PGID` to `USERMAP_GID`.
|
||||||
|
|
||||||
5. Update configuration paths.
|
5. Update configuration paths.
|
||||||
|
|
||||||
1. Set the environment variable [`PAPERLESS_DATA_DIR`](configuration.md#PAPERLESS_DATA_DIR) to `/config`.
|
1. Set the environment variable [`PAPERLESS_DATA_DIR`](configuration.md#PAPERLESS_DATA_DIR) to `/config`.
|
||||||
|
|
||||||
6. Update media paths.
|
6. Update media paths.
|
||||||
|
|
||||||
1. Set the environment variable [`PAPERLESS_MEDIA_ROOT`](configuration.md#PAPERLESS_MEDIA_ROOT) to
|
1. Set the environment variable [`PAPERLESS_MEDIA_ROOT`](configuration.md#PAPERLESS_MEDIA_ROOT) to
|
||||||
`/data/media`.
|
`/data/media`.
|
||||||
|
|
||||||
7. Update timezone.
|
7. Update timezone.
|
||||||
|
|
||||||
1. Set the environment variable [`PAPERLESS_TIME_ZONE`](configuration.md#PAPERLESS_TIME_ZONE) to the same
|
1. Set the environment variable [`PAPERLESS_TIME_ZONE`](configuration.md#PAPERLESS_TIME_ZONE) to the same
|
||||||
value as `TZ`.
|
value as `TZ`.
|
||||||
|
|
||||||
@@ -626,33 +639,33 @@ commands as well.
|
|||||||
Paperless runs on Raspberry Pi. Some tasks can be slow on lower-powered
|
Paperless runs on Raspberry Pi. Some tasks can be slow on lower-powered
|
||||||
hardware, but a few settings can improve performance:
|
hardware, but a few settings can improve performance:
|
||||||
|
|
||||||
- Stick with SQLite to save some resources. See [troubleshooting](troubleshooting.md#log-reports-creating-paperlesstask-failed)
|
- Stick with SQLite to save some resources. See [troubleshooting](troubleshooting.md#log-reports-creating-paperlesstask-failed)
|
||||||
if you encounter issues with SQLite locking.
|
if you encounter issues with SQLite locking.
|
||||||
- If you do not need the filesystem-based consumer, consider disabling it
|
- If you do not need the filesystem-based consumer, consider disabling it
|
||||||
entirely by setting [`PAPERLESS_CONSUMER_DISABLE`](configuration.md#PAPERLESS_CONSUMER_DISABLE) to `true`.
|
entirely by setting [`PAPERLESS_CONSUMER_DISABLE`](configuration.md#PAPERLESS_CONSUMER_DISABLE) to `true`.
|
||||||
- Consider setting [`PAPERLESS_OCR_PAGES`](configuration.md#PAPERLESS_OCR_PAGES) to 1, so that Paperless
|
- Consider setting [`PAPERLESS_OCR_PAGES`](configuration.md#PAPERLESS_OCR_PAGES) to 1, so that Paperless
|
||||||
OCRs only the first page of your documents. In most cases, this page
|
OCRs only the first page of your documents. In most cases, this page
|
||||||
contains enough information to be able to find it.
|
contains enough information to be able to find it.
|
||||||
- [`PAPERLESS_TASK_WORKERS`](configuration.md#PAPERLESS_TASK_WORKERS) and [`PAPERLESS_THREADS_PER_WORKER`](configuration.md#PAPERLESS_THREADS_PER_WORKER) are
|
- [`PAPERLESS_TASK_WORKERS`](configuration.md#PAPERLESS_TASK_WORKERS) and [`PAPERLESS_THREADS_PER_WORKER`](configuration.md#PAPERLESS_THREADS_PER_WORKER) are
|
||||||
configured to use all cores. The Raspberry Pi models 3 and up have 4
|
configured to use all cores. The Raspberry Pi models 3 and up have 4
|
||||||
cores, meaning that Paperless will use 2 workers and 2 threads per
|
cores, meaning that Paperless will use 2 workers and 2 threads per
|
||||||
worker. This may result in sluggish response times during
|
worker. This may result in sluggish response times during
|
||||||
consumption, so you might want to lower these settings (example: 2
|
consumption, so you might want to lower these settings (example: 2
|
||||||
workers and 1 thread to always have some computing power left for
|
workers and 1 thread to always have some computing power left for
|
||||||
other tasks).
|
other tasks).
|
||||||
- Keep [`PAPERLESS_OCR_MODE`](configuration.md#PAPERLESS_OCR_MODE) at its default value `skip` and consider
|
- Keep [`PAPERLESS_OCR_MODE`](configuration.md#PAPERLESS_OCR_MODE) at its default value `skip` and consider
|
||||||
OCRing your documents before feeding them into Paperless. Some
|
OCRing your documents before feeding them into Paperless. Some
|
||||||
scanners are able to do this!
|
scanners are able to do this!
|
||||||
- Set [`PAPERLESS_OCR_SKIP_ARCHIVE_FILE`](configuration.md#PAPERLESS_OCR_SKIP_ARCHIVE_FILE) to `with_text` to skip archive
|
- Set [`PAPERLESS_OCR_SKIP_ARCHIVE_FILE`](configuration.md#PAPERLESS_OCR_SKIP_ARCHIVE_FILE) to `with_text` to skip archive
|
||||||
file generation for already OCRed documents, or `always` to skip it
|
file generation for already OCRed documents, or `always` to skip it
|
||||||
for all documents.
|
for all documents.
|
||||||
- If you want to perform OCR on the device, consider using
|
- If you want to perform OCR on the device, consider using
|
||||||
`PAPERLESS_OCR_CLEAN=none`. This will speed up OCR times and use
|
`PAPERLESS_OCR_CLEAN=none`. This will speed up OCR times and use
|
||||||
less memory at the expense of slightly worse OCR results.
|
less memory at the expense of slightly worse OCR results.
|
||||||
- If using Docker, consider setting [`PAPERLESS_WEBSERVER_WORKERS`](configuration.md#PAPERLESS_WEBSERVER_WORKERS) to 1. This will save some memory.
|
- If using Docker, consider setting [`PAPERLESS_WEBSERVER_WORKERS`](configuration.md#PAPERLESS_WEBSERVER_WORKERS) to 1. This will save some memory.
|
||||||
- Consider setting [`PAPERLESS_ENABLE_NLTK`](configuration.md#PAPERLESS_ENABLE_NLTK) to false, to disable the
|
- Consider setting [`PAPERLESS_ENABLE_NLTK`](configuration.md#PAPERLESS_ENABLE_NLTK) to false, to disable the
|
||||||
more advanced language processing, which can take more memory and
|
more advanced language processing, which can take more memory and
|
||||||
processing time.
|
processing time.
|
||||||
|
|
||||||
For details, refer to [configuration](configuration.md).
|
For details, refer to [configuration](configuration.md).
|
||||||
|
|
||||||
|
|||||||
@@ -4,27 +4,27 @@
|
|||||||
|
|
||||||
Check for the following issues:
|
Check for the following issues:
|
||||||
|
|
||||||
- Ensure that the directory you're putting your documents in is the
|
- Ensure that the directory you're putting your documents in is the
|
||||||
folder paperless is watching. With docker, this setting is performed
|
folder paperless is watching. With docker, this setting is performed
|
||||||
in the `docker-compose.yml` file. Without Docker, look at the
|
in the `docker-compose.yml` file. Without Docker, look at the
|
||||||
`CONSUMPTION_DIR` setting. Don't adjust this setting if you're
|
`CONSUMPTION_DIR` setting. Don't adjust this setting if you're
|
||||||
using docker.
|
using docker.
|
||||||
|
|
||||||
- Ensure that redis is up and running. Paperless does its task
|
- Ensure that redis is up and running. Paperless does its task
|
||||||
processing asynchronously, and for documents to arrive at the task
|
processing asynchronously, and for documents to arrive at the task
|
||||||
processor, it needs redis to run.
|
processor, it needs redis to run.
|
||||||
|
|
||||||
- Ensure that the task processor is running. Docker does this
|
- Ensure that the task processor is running. Docker does this
|
||||||
automatically. Manually invoke the task processor by executing
|
automatically. Manually invoke the task processor by executing
|
||||||
|
|
||||||
```shell-session
|
```shell-session
|
||||||
celery --app paperless worker
|
celery --app paperless worker
|
||||||
```
|
```
|
||||||
|
|
||||||
- Look at the output of paperless and inspect it for any errors.
|
- Look at the output of paperless and inspect it for any errors.
|
||||||
|
|
||||||
- Go to the admin interface, and check if there are failed tasks. If
|
- Go to the admin interface, and check if there are failed tasks. If
|
||||||
so, the tasks will contain an error message.
|
so, the tasks will contain an error message.
|
||||||
|
|
||||||
## Consumer warns `OCR for XX failed`
|
## Consumer warns `OCR for XX failed`
|
||||||
|
|
||||||
@@ -78,12 +78,12 @@ Ensure that `chown` is possible on these directories.
|
|||||||
This indicates that the Auto matching algorithm found no documents to
|
This indicates that the Auto matching algorithm found no documents to
|
||||||
learn from. This may have two reasons:
|
learn from. This may have two reasons:
|
||||||
|
|
||||||
- You don't use the Auto matching algorithm: The error can be safely
|
- You don't use the Auto matching algorithm: The error can be safely
|
||||||
ignored in this case.
|
ignored in this case.
|
||||||
- You are using the Auto matching algorithm: The classifier explicitly
|
- You are using the Auto matching algorithm: The classifier explicitly
|
||||||
excludes documents with Inbox tags. Verify that there are documents
|
excludes documents with Inbox tags. Verify that there are documents
|
||||||
in your archive without inbox tags. The algorithm will only learn
|
in your archive without inbox tags. The algorithm will only learn
|
||||||
from documents not in your inbox.
|
from documents not in your inbox.
|
||||||
|
|
||||||
## UserWarning in sklearn on every single document
|
## UserWarning in sklearn on every single document
|
||||||
|
|
||||||
@@ -127,10 +127,10 @@ change in the `docker-compose.yml` file:
|
|||||||
# The gotenberg chromium route is used to convert .eml files. We do not
|
# The gotenberg chromium route is used to convert .eml files. We do not
|
||||||
# want to allow external content like tracking pixels or even javascript.
|
# want to allow external content like tracking pixels or even javascript.
|
||||||
command:
|
command:
|
||||||
- 'gotenberg'
|
- 'gotenberg'
|
||||||
- '--chromium-disable-javascript=true'
|
- '--chromium-disable-javascript=true'
|
||||||
- '--chromium-allow-list=file:///tmp/.*'
|
- '--chromium-allow-list=file:///tmp/.*'
|
||||||
- '--api-timeout=60s'
|
- '--api-timeout=60s'
|
||||||
```
|
```
|
||||||
|
|
||||||
## Permission denied errors in the consumption directory
|
## Permission denied errors in the consumption directory
|
||||||
|
|||||||
404
docs/usage.md
404
docs/usage.md
@@ -14,42 +14,42 @@ for finding and managing your documents.
|
|||||||
Paperless essentially consists of two different parts for managing your
|
Paperless essentially consists of two different parts for managing your
|
||||||
documents:
|
documents:
|
||||||
|
|
||||||
- The _consumer_ watches a specified folder and adds all documents in
|
- The _consumer_ watches a specified folder and adds all documents in
|
||||||
that folder to paperless.
|
that folder to paperless.
|
||||||
- The _web server_ (web UI) provides a UI that you use to manage and
|
- The _web server_ (web UI) provides a UI that you use to manage and
|
||||||
search documents.
|
search documents.
|
||||||
|
|
||||||
Each document has data fields that you can assign to them:
|
Each document has data fields that you can assign to them:
|
||||||
|
|
||||||
- A _Document_ is a piece of paper that sometimes contains valuable
|
- A _Document_ is a piece of paper that sometimes contains valuable
|
||||||
information.
|
information.
|
||||||
- The _correspondent_ of a document is the person, institution or
|
- The _correspondent_ of a document is the person, institution or
|
||||||
company that a document either originates from, or is sent to.
|
company that a document either originates from, or is sent to.
|
||||||
- A _tag_ is a label that you can assign to documents. Think of labels
|
- A _tag_ is a label that you can assign to documents. Think of labels
|
||||||
as more powerful folders: Multiple documents can be grouped together
|
as more powerful folders: Multiple documents can be grouped together
|
||||||
with a single tag, however, a single document can also have multiple
|
with a single tag, however, a single document can also have multiple
|
||||||
tags. This is not possible with folders. The reason folders are not
|
tags. This is not possible with folders. The reason folders are not
|
||||||
implemented in paperless is simply that tags are much more versatile
|
implemented in paperless is simply that tags are much more versatile
|
||||||
than folders.
|
than folders.
|
||||||
- A _document type_ is used to demarcate the type of a document such
|
- A _document type_ is used to demarcate the type of a document such
|
||||||
as letter, bank statement, invoice, contract, etc. It is used to
|
as letter, bank statement, invoice, contract, etc. It is used to
|
||||||
identify what a document is about.
|
identify what a document is about.
|
||||||
- The document _storage path_ is the location where the document files
|
- The document _storage path_ is the location where the document files
|
||||||
are stored. See [Storage Paths](advanced_usage.md#storage-paths) for
|
are stored. See [Storage Paths](advanced_usage.md#storage-paths) for
|
||||||
more information.
|
more information.
|
||||||
- The _date added_ of a document is the date the document was scanned
|
- The _date added_ of a document is the date the document was scanned
|
||||||
into paperless. You cannot and should not change this date.
|
into paperless. You cannot and should not change this date.
|
||||||
- The _date created_ of a document is the date the document was
|
- The _date created_ of a document is the date the document was
|
||||||
initially issued. This can be the date you bought a product, the
|
initially issued. This can be the date you bought a product, the
|
||||||
date you signed a contract, or the date a letter was sent to you.
|
date you signed a contract, or the date a letter was sent to you.
|
||||||
- The _archive serial number_ (short: ASN) of a document is the
|
- The _archive serial number_ (short: ASN) of a document is the
|
||||||
identifier of the document in your physical document binders. See
|
identifier of the document in your physical document binders. See
|
||||||
[recommended workflow](#usage-recommended-workflow) below.
|
[recommended workflow](#usage-recommended-workflow) below.
|
||||||
- The _content_ of a document is the text that was OCR'ed from the
|
- The _content_ of a document is the text that was OCR'ed from the
|
||||||
document. This text is fed into the search engine and is used for
|
document. This text is fed into the search engine and is used for
|
||||||
matching tags, correspondents and document types.
|
matching tags, correspondents and document types.
|
||||||
- Paperless-ngx also supports _custom fields_ which can be used to
|
- Paperless-ngx also supports _custom fields_ which can be used to
|
||||||
store additional metadata about a document.
|
store additional metadata about a document.
|
||||||
|
|
||||||
## The Web UI
|
## The Web UI
|
||||||
|
|
||||||
@@ -93,12 +93,12 @@ download the document or share it via a share link.
|
|||||||
|
|
||||||
Think of versions as **file history** for a document.
|
Think of versions as **file history** for a document.
|
||||||
|
|
||||||
- Versions track the underlying file and extracted text content (OCR/text).
|
- Versions track the underlying file and extracted text content (OCR/text).
|
||||||
- Metadata such as tags, correspondent, document type, storage path and custom fields stay on the "root" document.
|
- Metadata such as tags, correspondent, document type, storage path and custom fields stay on the "root" document.
|
||||||
- Version files follow normal filename formatting (including storage paths) and add a `_vN` suffix (for example `_v1`, `_v2`).
|
- Version files follow normal filename formatting (including storage paths) and add a `_vN` suffix (for example `_v1`, `_v2`).
|
||||||
- By default, search and document content use the latest version.
|
- By default, search and document content use the latest version.
|
||||||
- In document detail, selecting a version switches the preview, file metadata and content (and download etc buttons) to that version.
|
- In document detail, selecting a version switches the preview, file metadata and content (and download etc buttons) to that version.
|
||||||
- Deleting a non-root version keeps metadata and falls back to the latest remaining version.
|
- Deleting a non-root version keeps metadata and falls back to the latest remaining version.
|
||||||
|
|
||||||
### Management Lists
|
### Management Lists
|
||||||
|
|
||||||
@@ -218,20 +218,21 @@ patterns can include wildcards and multiple patterns separated by a comma.
|
|||||||
The actions all ensure that the same mail is not consumed twice by
|
The actions all ensure that the same mail is not consumed twice by
|
||||||
different means. These are as follows:
|
different means. These are as follows:
|
||||||
|
|
||||||
- **Delete:** Immediately deletes mail that paperless has consumed
|
- **Delete:** Immediately deletes mail that paperless has consumed
|
||||||
documents from. Use with caution.
|
documents from. Use with caution.
|
||||||
- **Mark as read:** Mark consumed mail as read. Paperless will not
|
- **Mark as read:** Mark consumed mail as read. Paperless will not
|
||||||
consume documents from already read mails. If you read a mail before
|
consume documents from already read mails. If you read a mail before
|
||||||
paperless sees it, it will be ignored.
|
paperless sees it, it will be ignored.
|
||||||
- **Flag:** Sets the 'important' flag on mails with consumed
|
- **Flag:** Sets the 'important' flag on mails with consumed
|
||||||
documents. Paperless will not consume flagged mails.
|
documents. Paperless will not consume flagged mails.
|
||||||
- **Move to folder:** Moves consumed mails out of the way so that
|
- **Move to folder:** Moves consumed mails out of the way so that
|
||||||
paperless won't consume them again.
|
paperless won't consume them again.
|
||||||
- **Add custom Tag:** Adds a custom tag to mails with consumed
|
- **Add custom Tag:** Adds a custom tag to mails with consumed
|
||||||
documents (the IMAP standard calls these "keywords"). Paperless
|
documents (the IMAP standard calls these "keywords"). Paperless
|
||||||
will not consume mails already tagged. Not all mail servers support
|
will not consume mails already tagged. Not all mail servers support
|
||||||
this feature!
|
this feature!
|
||||||
- **Apple Mail support:** Apple Mail clients allow differently colored tags. For this to work use `apple:<color>` (e.g. _apple:green_) as a custom tag. Available colors are _red_, _orange_, _yellow_, _blue_, _green_, _violet_ and _grey_.
|
|
||||||
|
- **Apple Mail support:** Apple Mail clients allow differently colored tags. For this to work use `apple:<color>` (e.g. _apple:green_) as a custom tag. Available colors are _red_, _orange_, _yellow_, _blue_, _green_, _violet_ and _grey_.
|
||||||
|
|
||||||
!!! warning
|
!!! warning
|
||||||
|
|
||||||
@@ -324,12 +325,12 @@ or using [email](#workflow-action-email) or [webhook](#workflow-action-webhook)
|
|||||||
|
|
||||||
"Share links" are public links to files (or an archive of files) and can be created and managed under the 'Send' button on the document detail screen or from the bulk editor.
|
"Share links" are public links to files (or an archive of files) and can be created and managed under the 'Send' button on the document detail screen or from the bulk editor.
|
||||||
|
|
||||||
- Share links do not require a user to login and thus link directly to a file or bundled download.
|
- Share links do not require a user to login and thus link directly to a file or bundled download.
|
||||||
- Links are unique and are of the form `{paperless-url}/share/{randomly-generated-slug}`.
|
- Links are unique and are of the form `{paperless-url}/share/{randomly-generated-slug}`.
|
||||||
- Links can optionally have an expiration time set.
|
- Links can optionally have an expiration time set.
|
||||||
- After a link expires or is deleted users will be redirected to the regular paperless-ngx login.
|
- After a link expires or is deleted users will be redirected to the regular paperless-ngx login.
|
||||||
- From the document detail screen you can create a share link for that single document.
|
- From the document detail screen you can create a share link for that single document.
|
||||||
- From the bulk editor you can create a **share link bundle** for any selection. Paperless-ngx prepares a ZIP archive in the background and exposes a single share link. You can revisit the "Manage share link bundles" dialog to monitor progress, retry failed bundles, or delete links.
|
- From the bulk editor you can create a **share link bundle** for any selection. Paperless-ngx prepares a ZIP archive in the background and exposes a single share link. You can revisit the "Manage share link bundles" dialog to monitor progress, retry failed bundles, or delete links.
|
||||||
|
|
||||||
!!! tip
|
!!! tip
|
||||||
|
|
||||||
@@ -513,25 +514,25 @@ flowchart TD
|
|||||||
|
|
||||||
Workflows allow you to filter by:
|
Workflows allow you to filter by:
|
||||||
|
|
||||||
- Source, e.g. documents uploaded via consume folder, API (& the web UI) and mail fetch
|
- Source, e.g. documents uploaded via consume folder, API (& the web UI) and mail fetch
|
||||||
- File name, including wildcards e.g. \*.pdf will apply to all pdfs.
|
- File name, including wildcards e.g. \*.pdf will apply to all pdfs.
|
||||||
- File path, including wildcards. Note that enabling `PAPERLESS_CONSUMER_RECURSIVE` would allow, for
|
- File path, including wildcards. Note that enabling `PAPERLESS_CONSUMER_RECURSIVE` would allow, for
|
||||||
example, automatically assigning documents to different owners based on the upload directory.
|
example, automatically assigning documents to different owners based on the upload directory.
|
||||||
- Mail rule. Choosing this option will force 'mail fetch' to be the workflow source.
|
- Mail rule. Choosing this option will force 'mail fetch' to be the workflow source.
|
||||||
- Content matching (`Added`, `Updated` and `Scheduled` triggers only). Filter document content using the matching settings.
|
- Content matching (`Added`, `Updated` and `Scheduled` triggers only). Filter document content using the matching settings.
|
||||||
|
|
||||||
There are also 'advanced' filters available for `Added`, `Updated` and `Scheduled` triggers:
|
There are also 'advanced' filters available for `Added`, `Updated` and `Scheduled` triggers:
|
||||||
|
|
||||||
- Any Tags: Filter for documents with any of the specified tags.
|
- Any Tags: Filter for documents with any of the specified tags.
|
||||||
- All Tags: Filter for documents with all of the specified tags.
|
- All Tags: Filter for documents with all of the specified tags.
|
||||||
- No Tags: Filter for documents with none of the specified tags.
|
- No Tags: Filter for documents with none of the specified tags.
|
||||||
- Document type: Filter documents with this document type.
|
- Document type: Filter documents with this document type.
|
||||||
- Not Document types: Filter documents without any of these document types.
|
- Not Document types: Filter documents without any of these document types.
|
||||||
- Correspondent: Filter documents with this correspondent.
|
- Correspondent: Filter documents with this correspondent.
|
||||||
- Not Correspondents: Filter documents without any of these correspondents.
|
- Not Correspondents: Filter documents without any of these correspondents.
|
||||||
- Storage path: Filter documents with this storage path.
|
- Storage path: Filter documents with this storage path.
|
||||||
- Not Storage paths: Filter documents without any of these storage paths.
|
- Not Storage paths: Filter documents without any of these storage paths.
|
||||||
- Custom field query: Filter documents with a custom field query (the same as used for the document list filters).
|
- Custom field query: Filter documents with a custom field query (the same as used for the document list filters).
|
||||||
|
|
||||||
### Workflow Actions
|
### Workflow Actions
|
||||||
|
|
||||||
@@ -543,37 +544,37 @@ The following workflow action types are available:
|
|||||||
|
|
||||||
"Assignment" actions can assign:
|
"Assignment" actions can assign:
|
||||||
|
|
||||||
- Title, see [workflow placeholders](usage.md#workflow-placeholders) below
|
- Title, see [workflow placeholders](usage.md#workflow-placeholders) below
|
||||||
- Tags, correspondent, document type and storage path
|
- Tags, correspondent, document type and storage path
|
||||||
- Document owner
|
- Document owner
|
||||||
- View and / or edit permissions to users or groups
|
- View and / or edit permissions to users or groups
|
||||||
- Custom fields. Note that no value for the field will be set
|
- Custom fields. Note that no value for the field will be set
|
||||||
|
|
||||||
##### Removal {#workflow-action-removal}
|
##### Removal {#workflow-action-removal}
|
||||||
|
|
||||||
"Removal" actions can remove either all of or specific sets of the following:
|
"Removal" actions can remove either all of or specific sets of the following:
|
||||||
|
|
||||||
- Tags, correspondents, document types or storage paths
|
- Tags, correspondents, document types or storage paths
|
||||||
- Document owner
|
- Document owner
|
||||||
- View and / or edit permissions
|
- View and / or edit permissions
|
||||||
- Custom fields
|
- Custom fields
|
||||||
|
|
||||||
##### Email {#workflow-action-email}
|
##### Email {#workflow-action-email}
|
||||||
|
|
||||||
"Email" actions can send documents via email. This action requires a mail server to be [configured](configuration.md#email-sending). You can specify:
|
"Email" actions can send documents via email. This action requires a mail server to be [configured](configuration.md#email-sending). You can specify:
|
||||||
|
|
||||||
- The recipient email address(es) separated by commas
|
- The recipient email address(es) separated by commas
|
||||||
- The subject and body of the email, which can include placeholders, see [placeholders](usage.md#workflow-placeholders) below
|
- The subject and body of the email, which can include placeholders, see [placeholders](usage.md#workflow-placeholders) below
|
||||||
- Whether to include the document as an attachment
|
- Whether to include the document as an attachment
|
||||||
|
|
||||||
##### Webhook {#workflow-action-webhook}
|
##### Webhook {#workflow-action-webhook}
|
||||||
|
|
||||||
"Webhook" actions send a POST request to a specified URL. You can specify:
|
"Webhook" actions send a POST request to a specified URL. You can specify:
|
||||||
|
|
||||||
- The URL to send the request to
|
- The URL to send the request to
|
||||||
- The request body as text or as key-value pairs, which can include placeholders, see [placeholders](usage.md#workflow-placeholders) below.
|
- The request body as text or as key-value pairs, which can include placeholders, see [placeholders](usage.md#workflow-placeholders) below.
|
||||||
- Encoding for the request body, either JSON or form data
|
- Encoding for the request body, either JSON or form data
|
||||||
- The request headers as key-value pairs
|
- The request headers as key-value pairs
|
||||||
|
|
||||||
For security reasons, webhooks can be limited to specific ports and disallowed from connecting to local URLs. See the relevant
|
For security reasons, webhooks can be limited to specific ports and disallowed from connecting to local URLs. See the relevant
|
||||||
[configuration settings](configuration.md#workflow-webhooks) to change this behavior. If you are allowing non-admins to create workflows,
|
[configuration settings](configuration.md#workflow-webhooks) to change this behavior. If you are allowing non-admins to create workflows,
|
||||||
@@ -604,33 +605,33 @@ The available inputs differ depending on the type of workflow trigger.
|
|||||||
This is because at the time of consumption (when the text is to be set), no automatic tags etc. have been
|
This is because at the time of consumption (when the text is to be set), no automatic tags etc. have been
|
||||||
applied. You can use the following placeholders in the template with any trigger type:
|
applied. You can use the following placeholders in the template with any trigger type:
|
||||||
|
|
||||||
- `{{correspondent}}`: assigned correspondent name
|
- `{{correspondent}}`: assigned correspondent name
|
||||||
- `{{document_type}}`: assigned document type name
|
- `{{document_type}}`: assigned document type name
|
||||||
- `{{owner_username}}`: assigned owner username
|
- `{{owner_username}}`: assigned owner username
|
||||||
- `{{added}}`: added datetime
|
- `{{added}}`: added datetime
|
||||||
- `{{added_year}}`: added year
|
- `{{added_year}}`: added year
|
||||||
- `{{added_year_short}}`: added year
|
- `{{added_year_short}}`: added year
|
||||||
- `{{added_month}}`: added month
|
- `{{added_month}}`: added month
|
||||||
- `{{added_month_name}}`: added month name
|
- `{{added_month_name}}`: added month name
|
||||||
- `{{added_month_name_short}}`: added month short name
|
- `{{added_month_name_short}}`: added month short name
|
||||||
- `{{added_day}}`: added day
|
- `{{added_day}}`: added day
|
||||||
- `{{added_time}}`: added time in HH:MM format
|
- `{{added_time}}`: added time in HH:MM format
|
||||||
- `{{original_filename}}`: original file name without extension
|
- `{{original_filename}}`: original file name without extension
|
||||||
- `{{filename}}`: current file name without extension (for "added" workflows this may not be final yet, you can use `{{original_filename}}`)
|
- `{{filename}}`: current file name without extension (for "added" workflows this may not be final yet, you can use `{{original_filename}}`)
|
||||||
- `{{doc_title}}`: current document title (cannot be used in title assignment)
|
- `{{doc_title}}`: current document title (cannot be used in title assignment)
|
||||||
|
|
||||||
The following placeholders are only available for "added" or "updated" triggers
|
The following placeholders are only available for "added" or "updated" triggers
|
||||||
|
|
||||||
- `{{created}}`: created datetime
|
- `{{created}}`: created datetime
|
||||||
- `{{created_year}}`: created year
|
- `{{created_year}}`: created year
|
||||||
- `{{created_year_short}}`: created year
|
- `{{created_year_short}}`: created year
|
||||||
- `{{created_month}}`: created month
|
- `{{created_month}}`: created month
|
||||||
- `{{created_month_name}}`: created month name
|
- `{{created_month_name}}`: created month name
|
||||||
- `{{created_month_name_short}}`: created month short name
|
- `{{created_month_name_short}}`: created month short name
|
||||||
- `{{created_day}}`: created day
|
- `{{created_day}}`: created day
|
||||||
- `{{created_time}}`: created time in HH:MM format
|
- `{{created_time}}`: created time in HH:MM format
|
||||||
- `{{doc_url}}`: URL to the document in the web UI. Requires the `PAPERLESS_URL` setting to be set.
|
- `{{doc_url}}`: URL to the document in the web UI. Requires the `PAPERLESS_URL` setting to be set.
|
||||||
- `{{doc_id}}`: Document ID
|
- `{{doc_id}}`: Document ID
|
||||||
|
|
||||||
##### Examples
|
##### Examples
|
||||||
|
|
||||||
@@ -675,26 +676,26 @@ Multiple fields may be attached to a document but the same field name cannot be
|
|||||||
|
|
||||||
The following custom field types are supported:
|
The following custom field types are supported:
|
||||||
|
|
||||||
- `Text`: any text
|
- `Text`: any text
|
||||||
- `Boolean`: true / false (check / unchecked) field
|
- `Boolean`: true / false (check / unchecked) field
|
||||||
- `Date`: date
|
- `Date`: date
|
||||||
- `URL`: a valid url
|
- `URL`: a valid url
|
||||||
- `Integer`: integer number e.g. 12
|
- `Integer`: integer number e.g. 12
|
||||||
- `Number`: float number e.g. 12.3456
|
- `Number`: float number e.g. 12.3456
|
||||||
- `Monetary`: [ISO 4217 currency code](https://en.wikipedia.org/wiki/ISO_4217#List_of_ISO_4217_currency_codes) and a number with exactly two decimals, e.g. USD12.30
|
- `Monetary`: [ISO 4217 currency code](https://en.wikipedia.org/wiki/ISO_4217#List_of_ISO_4217_currency_codes) and a number with exactly two decimals, e.g. USD12.30
|
||||||
- `Document Link`: reference(s) to other document(s) displayed as links, automatically creates a symmetrical link in reverse
|
- `Document Link`: reference(s) to other document(s) displayed as links, automatically creates a symmetrical link in reverse
|
||||||
- `Select`: a pre-defined list of strings from which the user can choose
|
- `Select`: a pre-defined list of strings from which the user can choose
|
||||||
|
|
||||||
## PDF Actions
|
## PDF Actions
|
||||||
|
|
||||||
Paperless-ngx supports basic editing operations for PDFs (these operations currently cannot be performed on non-PDF files). When viewing an individual document you can
|
Paperless-ngx supports basic editing operations for PDFs (these operations currently cannot be performed on non-PDF files). When viewing an individual document you can
|
||||||
open the 'PDF Editor' to use a simple UI for re-arranging, rotating, deleting pages and splitting documents.
|
open the 'PDF Editor' to use a simple UI for re-arranging, rotating, deleting pages and splitting documents.
|
||||||
|
|
||||||
- Merging documents: available when selecting multiple documents for 'bulk editing'.
|
- Merging documents: available when selecting multiple documents for 'bulk editing'.
|
||||||
- Rotating documents: available when selecting multiple documents for 'bulk editing' and via the pdf editor on an individual document's details page.
|
- Rotating documents: available when selecting multiple documents for 'bulk editing' and via the pdf editor on an individual document's details page.
|
||||||
- Splitting documents: via the pdf editor on an individual document's details page.
|
- Splitting documents: via the pdf editor on an individual document's details page.
|
||||||
- Deleting pages: via the pdf editor on an individual document's details page.
|
- Deleting pages: via the pdf editor on an individual document's details page.
|
||||||
- Re-arranging pages: via the pdf editor on an individual document's details page.
|
- Re-arranging pages: via the pdf editor on an individual document's details page.
|
||||||
|
|
||||||
!!! important
|
!!! important
|
||||||
|
|
||||||
@@ -772,18 +773,18 @@ the system.
|
|||||||
Here are a couple examples of tags and types that you could use in your
|
Here are a couple examples of tags and types that you could use in your
|
||||||
collection.
|
collection.
|
||||||
|
|
||||||
- An `inbox` tag for newly added documents that you haven't manually
|
- An `inbox` tag for newly added documents that you haven't manually
|
||||||
edited yet.
|
edited yet.
|
||||||
- A tag `car` for everything car related (repairs, registration,
|
- A tag `car` for everything car related (repairs, registration,
|
||||||
insurance, etc)
|
insurance, etc)
|
||||||
- A tag `todo` for documents that you still need to do something with,
|
- A tag `todo` for documents that you still need to do something with,
|
||||||
such as reply, or perform some task online.
|
such as reply, or perform some task online.
|
||||||
- A tag `bank account x` for all bank statement related to that
|
- A tag `bank account x` for all bank statement related to that
|
||||||
account.
|
account.
|
||||||
- A tag `mail` for anything that you added to paperless via its mail
|
- A tag `mail` for anything that you added to paperless via its mail
|
||||||
processing capabilities.
|
processing capabilities.
|
||||||
- A tag `missing_metadata` when you still need to add some metadata to
|
- A tag `missing_metadata` when you still need to add some metadata to
|
||||||
a document, but can't or don't want to do this right now.
|
a document, but can't or don't want to do this right now.
|
||||||
|
|
||||||
## Searching {#basic-usage_searching}
|
## Searching {#basic-usage_searching}
|
||||||
|
|
||||||
@@ -872,8 +873,8 @@ The following diagram shows how easy it is to manage your documents.
|
|||||||
|
|
||||||
### Preparations in paperless
|
### Preparations in paperless
|
||||||
|
|
||||||
- Create an inbox tag that gets assigned to all new documents.
|
- Create an inbox tag that gets assigned to all new documents.
|
||||||
- Create a TODO tag.
|
- Create a TODO tag.
|
||||||
|
|
||||||
### Processing of the physical documents
|
### Processing of the physical documents
|
||||||
|
|
||||||
@@ -947,15 +948,15 @@ Some documents require attention and require you to act on the document.
|
|||||||
You may take two different approaches to handle these documents based on
|
You may take two different approaches to handle these documents based on
|
||||||
how regularly you intend to scan documents and use paperless.
|
how regularly you intend to scan documents and use paperless.
|
||||||
|
|
||||||
- If you scan and process your documents in paperless regularly,
|
- If you scan and process your documents in paperless regularly,
|
||||||
assign a TODO tag to all scanned documents that you need to process.
|
assign a TODO tag to all scanned documents that you need to process.
|
||||||
Create a saved view on the dashboard that shows all documents with
|
Create a saved view on the dashboard that shows all documents with
|
||||||
this tag.
|
this tag.
|
||||||
- If you do not scan documents regularly and use paperless solely for
|
- If you do not scan documents regularly and use paperless solely for
|
||||||
archiving, create a physical todo box next to your physical inbox
|
archiving, create a physical todo box next to your physical inbox
|
||||||
and put documents you need to process in the TODO box. When you
|
and put documents you need to process in the TODO box. When you
|
||||||
performed the task associated with the document, move it to the
|
performed the task associated with the document, move it to the
|
||||||
inbox.
|
inbox.
|
||||||
|
|
||||||
## Remote OCR
|
## Remote OCR
|
||||||
|
|
||||||
@@ -976,63 +977,64 @@ or page limitations (e.g. with a free tier).
|
|||||||
|
|
||||||
Paperless-ngx consists of the following components:
|
Paperless-ngx consists of the following components:
|
||||||
|
|
||||||
- **The webserver:** This serves the administration pages, the API,
|
- **The webserver:** This serves the administration pages, the API,
|
||||||
and the new frontend. This is the main tool you'll be using to interact
|
and the new frontend. This is the main tool you'll be using to interact
|
||||||
with paperless. You may start the webserver directly with
|
with paperless. You may start the webserver directly with
|
||||||
|
|
||||||
```shell-session
|
```shell-session
|
||||||
cd /path/to/paperless/src/
|
cd /path/to/paperless/src/
|
||||||
granian --interface asginl --ws "paperless.asgi:application"
|
granian --interface asginl --ws "paperless.asgi:application"
|
||||||
```
|
```
|
||||||
|
|
||||||
or by any other means such as Apache `mod_wsgi`.
|
or by any other means such as Apache `mod_wsgi`.
|
||||||
|
|
||||||
- **The consumer:** This is what watches your consumption folder for
|
- **The consumer:** This is what watches your consumption folder for
|
||||||
documents. However, the consumer itself does not really consume your
|
documents. However, the consumer itself does not really consume your
|
||||||
documents. Now it notifies a task processor that a new file is ready
|
documents. Now it notifies a task processor that a new file is ready
|
||||||
for consumption. I suppose it should be named differently. This was
|
for consumption. I suppose it should be named differently. This was
|
||||||
also used to check your emails, but that's now done elsewhere as
|
also used to check your emails, but that's now done elsewhere as
|
||||||
well.
|
well.
|
||||||
|
|
||||||
Start the consumer with the management command `document_consumer`:
|
Start the consumer with the management command `document_consumer`:
|
||||||
|
|
||||||
```shell-session
|
```shell-session
|
||||||
cd /path/to/paperless/src/
|
cd /path/to/paperless/src/
|
||||||
python3 manage.py document_consumer
|
python3 manage.py document_consumer
|
||||||
```
|
```
|
||||||
|
|
||||||
- **The task processor:** Paperless relies on [Celery - Distributed
|
- **The task processor:** Paperless relies on [Celery - Distributed
|
||||||
Task Queue](https://docs.celeryq.dev/en/stable/index.html) for doing
|
Task Queue](https://docs.celeryq.dev/en/stable/index.html) for doing
|
||||||
most of the heavy lifting. This is a task queue that accepts tasks
|
most of the heavy lifting. This is a task queue that accepts tasks
|
||||||
from multiple sources and processes these in parallel. It also comes
|
from multiple sources and processes these in parallel. It also comes
|
||||||
with a scheduler that executes certain commands periodically.
|
with a scheduler that executes certain commands periodically.
|
||||||
|
|
||||||
This task processor is responsible for:
|
This task processor is responsible for:
|
||||||
- Consuming documents. When the consumer finds new documents, it
|
|
||||||
notifies the task processor to start a consumption task.
|
|
||||||
- The task processor also performs the consumption of any
|
|
||||||
documents you upload through the web interface.
|
|
||||||
- Consuming emails. It periodically checks your configured
|
|
||||||
accounts for new emails and notifies the task processor to
|
|
||||||
consume the attachment of an email.
|
|
||||||
- Maintaining the search index and the automatic matching
|
|
||||||
algorithm. These are things that paperless needs to do from time
|
|
||||||
to time in order to operate properly.
|
|
||||||
|
|
||||||
This allows paperless to process multiple documents from your
|
- Consuming documents. When the consumer finds new documents, it
|
||||||
consumption folder in parallel! On a modern multi core system, this
|
notifies the task processor to start a consumption task.
|
||||||
makes the consumption process with full OCR blazingly fast.
|
- The task processor also performs the consumption of any
|
||||||
|
documents you upload through the web interface.
|
||||||
|
- Consuming emails. It periodically checks your configured
|
||||||
|
accounts for new emails and notifies the task processor to
|
||||||
|
consume the attachment of an email.
|
||||||
|
- Maintaining the search index and the automatic matching
|
||||||
|
algorithm. These are things that paperless needs to do from time
|
||||||
|
to time in order to operate properly.
|
||||||
|
|
||||||
The task processor comes with a built-in admin interface that you
|
This allows paperless to process multiple documents from your
|
||||||
can use to check whenever any of the tasks fail and inspect the
|
consumption folder in parallel! On a modern multi core system, this
|
||||||
errors (i.e., wrong email credentials, errors during consuming a
|
makes the consumption process with full OCR blazingly fast.
|
||||||
specific file, etc).
|
|
||||||
|
|
||||||
- A [redis](https://redis.io/) message broker: This is a really
|
The task processor comes with a built-in admin interface that you
|
||||||
lightweight service that is responsible for getting the tasks from
|
can use to check whenever any of the tasks fail and inspect the
|
||||||
the webserver and the consumer to the task scheduler. These run in a
|
errors (i.e., wrong email credentials, errors during consuming a
|
||||||
different process (maybe even on different machines!), and
|
specific file, etc).
|
||||||
therefore, this is necessary.
|
|
||||||
|
|
||||||
- Optional: A database server. Paperless supports PostgreSQL, MariaDB
|
- A [redis](https://redis.io/) message broker: This is a really
|
||||||
and SQLite for storing its data.
|
lightweight service that is responsible for getting the tasks from
|
||||||
|
the webserver and the consumer to the task scheduler. These run in a
|
||||||
|
different process (maybe even on different machines!), and
|
||||||
|
therefore, this is necessary.
|
||||||
|
|
||||||
|
- Optional: A database server. Paperless supports PostgreSQL, MariaDB
|
||||||
|
and SQLite for storing its data.
|
||||||
|
|||||||
@@ -42,14 +42,13 @@ dependencies = [
|
|||||||
"djangorestframework~=3.16",
|
"djangorestframework~=3.16",
|
||||||
"djangorestframework-guardian~=0.4.0",
|
"djangorestframework-guardian~=0.4.0",
|
||||||
"drf-spectacular~=0.28",
|
"drf-spectacular~=0.28",
|
||||||
"drf-spectacular-sidecar~=2026.3.1",
|
"drf-spectacular-sidecar~=2026.1.1",
|
||||||
"drf-writable-nested~=0.7.1",
|
"drf-writable-nested~=0.7.1",
|
||||||
"faiss-cpu>=1.10",
|
"faiss-cpu>=1.10",
|
||||||
"filelock~=3.25.2",
|
"filelock~=3.24.3",
|
||||||
"flower~=2.0.1",
|
"flower~=2.0.1",
|
||||||
"gotenberg-client~=0.13.1",
|
"gotenberg-client~=0.13.1",
|
||||||
"httpx-oauth~=0.16",
|
"httpx-oauth~=0.16",
|
||||||
"ijson>=3.2",
|
|
||||||
"imap-tools~=1.11.0",
|
"imap-tools~=1.11.0",
|
||||||
"jinja2~=3.1.5",
|
"jinja2~=3.1.5",
|
||||||
"langdetect~=1.0.9",
|
"langdetect~=1.0.9",
|
||||||
@@ -72,8 +71,9 @@ dependencies = [
|
|||||||
"rapidfuzz~=3.14.0",
|
"rapidfuzz~=3.14.0",
|
||||||
"redis[hiredis]~=5.2.1",
|
"redis[hiredis]~=5.2.1",
|
||||||
"regex>=2025.9.18",
|
"regex>=2025.9.18",
|
||||||
"scikit-learn~=1.8.0",
|
"scikit-learn~=1.7.0",
|
||||||
"sentence-transformers>=4.1",
|
"sentence-transformers>=4.1",
|
||||||
|
"servestatic>=4",
|
||||||
"setproctitle~=1.3.4",
|
"setproctitle~=1.3.4",
|
||||||
"tika-client~=0.10.0",
|
"tika-client~=0.10.0",
|
||||||
"torch~=2.10.0",
|
"torch~=2.10.0",
|
||||||
@@ -111,7 +111,7 @@ docs = [
|
|||||||
testing = [
|
testing = [
|
||||||
"daphne",
|
"daphne",
|
||||||
"factory-boy~=3.3.1",
|
"factory-boy~=3.3.1",
|
||||||
"faker~=40.8.0",
|
"faker~=40.5.1",
|
||||||
"imagehash",
|
"imagehash",
|
||||||
"pytest~=9.0.0",
|
"pytest~=9.0.0",
|
||||||
"pytest-cov~=7.0.0",
|
"pytest-cov~=7.0.0",
|
||||||
|
|||||||
@@ -19,4 +19,6 @@ following additional information about it:
|
|||||||
* Correspondent: ${DOCUMENT_CORRESPONDENT}
|
* Correspondent: ${DOCUMENT_CORRESPONDENT}
|
||||||
* Tags: ${DOCUMENT_TAGS}
|
* Tags: ${DOCUMENT_TAGS}
|
||||||
|
|
||||||
|
It was consumed with the passphrase ${PASSPHRASE}
|
||||||
|
|
||||||
"
|
"
|
||||||
|
|||||||
@@ -1217,7 +1217,7 @@
|
|||||||
</context-group>
|
</context-group>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
|
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
|
||||||
<context context-type="linenumber">1758</context>
|
<context context-type="linenumber">1760</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="1577733187050997705" datatype="html">
|
<trans-unit id="1577733187050997705" datatype="html">
|
||||||
@@ -2802,19 +2802,19 @@
|
|||||||
</context-group>
|
</context-group>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
|
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
|
||||||
<context context-type="linenumber">1759</context>
|
<context context-type="linenumber">1761</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.ts</context>
|
<context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.ts</context>
|
||||||
<context context-type="linenumber">833</context>
|
<context context-type="linenumber">802</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.ts</context>
|
<context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.ts</context>
|
||||||
<context context-type="linenumber">871</context>
|
<context context-type="linenumber">835</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.ts</context>
|
<context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.ts</context>
|
||||||
<context context-type="linenumber">894</context>
|
<context context-type="linenumber">854</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/manage/document-attributes/custom-fields/custom-fields.component.ts</context>
|
<context context-type="sourcefile">src/app/components/manage/document-attributes/custom-fields/custom-fields.component.ts</context>
|
||||||
@@ -3404,27 +3404,27 @@
|
|||||||
</context-group>
|
</context-group>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.ts</context>
|
<context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.ts</context>
|
||||||
<context context-type="linenumber">470</context>
|
<context context-type="linenumber">445</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.ts</context>
|
<context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.ts</context>
|
||||||
<context context-type="linenumber">510</context>
|
<context context-type="linenumber">485</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.ts</context>
|
<context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.ts</context>
|
||||||
<context context-type="linenumber">548</context>
|
<context context-type="linenumber">523</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.ts</context>
|
<context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.ts</context>
|
||||||
<context context-type="linenumber">586</context>
|
<context context-type="linenumber">561</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.ts</context>
|
<context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.ts</context>
|
||||||
<context context-type="linenumber">648</context>
|
<context context-type="linenumber">623</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.ts</context>
|
<context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.ts</context>
|
||||||
<context context-type="linenumber">781</context>
|
<context context-type="linenumber">756</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="994016933065248559" datatype="html">
|
<trans-unit id="994016933065248559" datatype="html">
|
||||||
@@ -3434,46 +3434,39 @@
|
|||||||
<context context-type="linenumber">9</context>
|
<context context-type="linenumber">9</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="6705735915615634619" datatype="html">
|
|
||||||
<source>{VAR_PLURAL, plural, =1 {One page} other {<x id="INTERPOLATION"/> pages}}</source>
|
|
||||||
<context-group purpose="location">
|
|
||||||
<context context-type="sourcefile">src/app/components/common/confirm-dialog/merge-confirm-dialog/merge-confirm-dialog.component.html</context>
|
|
||||||
<context context-type="linenumber">25</context>
|
|
||||||
</context-group>
|
|
||||||
</trans-unit>
|
|
||||||
<trans-unit id="7508164375697837821" datatype="html">
|
<trans-unit id="7508164375697837821" datatype="html">
|
||||||
<source>Use metadata from:</source>
|
<source>Use metadata from:</source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/common/confirm-dialog/merge-confirm-dialog/merge-confirm-dialog.component.html</context>
|
<context context-type="sourcefile">src/app/components/common/confirm-dialog/merge-confirm-dialog/merge-confirm-dialog.component.html</context>
|
||||||
<context context-type="linenumber">34</context>
|
<context context-type="linenumber">22</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="2020403212524346652" datatype="html">
|
<trans-unit id="2020403212524346652" datatype="html">
|
||||||
<source>Regenerate all metadata</source>
|
<source>Regenerate all metadata</source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/common/confirm-dialog/merge-confirm-dialog/merge-confirm-dialog.component.html</context>
|
<context context-type="sourcefile">src/app/components/common/confirm-dialog/merge-confirm-dialog/merge-confirm-dialog.component.html</context>
|
||||||
<context context-type="linenumber">36</context>
|
<context context-type="linenumber">24</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="2710430925353472741" datatype="html">
|
<trans-unit id="2710430925353472741" datatype="html">
|
||||||
<source>Try to include archive version in merge for non-PDF files</source>
|
<source>Try to include archive version in merge for non-PDF files</source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/common/confirm-dialog/merge-confirm-dialog/merge-confirm-dialog.component.html</context>
|
<context context-type="sourcefile">src/app/components/common/confirm-dialog/merge-confirm-dialog/merge-confirm-dialog.component.html</context>
|
||||||
<context context-type="linenumber">44</context>
|
<context context-type="linenumber">32</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="5612366187076076264" datatype="html">
|
<trans-unit id="5612366187076076264" datatype="html">
|
||||||
<source>Delete original documents after successful merge</source>
|
<source>Delete original documents after successful merge</source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/common/confirm-dialog/merge-confirm-dialog/merge-confirm-dialog.component.html</context>
|
<context context-type="sourcefile">src/app/components/common/confirm-dialog/merge-confirm-dialog/merge-confirm-dialog.component.html</context>
|
||||||
<context context-type="linenumber">48</context>
|
<context context-type="linenumber">36</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="5138283234724909648" datatype="html">
|
<trans-unit id="5138283234724909648" datatype="html">
|
||||||
<source>Note that only PDFs will be included.</source>
|
<source>Note that only PDFs will be included.</source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/common/confirm-dialog/merge-confirm-dialog/merge-confirm-dialog.component.html</context>
|
<context context-type="sourcefile">src/app/components/common/confirm-dialog/merge-confirm-dialog/merge-confirm-dialog.component.html</context>
|
||||||
<context context-type="linenumber">51</context>
|
<context context-type="linenumber">39</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="1309641780471803652" datatype="html">
|
<trans-unit id="1309641780471803652" datatype="html">
|
||||||
@@ -3512,7 +3505,7 @@
|
|||||||
</context-group>
|
</context-group>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
|
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
|
||||||
<context context-type="linenumber">1812</context>
|
<context context-type="linenumber">1814</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="6661109599266152398" datatype="html">
|
<trans-unit id="6661109599266152398" datatype="html">
|
||||||
@@ -3523,7 +3516,7 @@
|
|||||||
</context-group>
|
</context-group>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
|
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
|
||||||
<context context-type="linenumber">1813</context>
|
<context context-type="linenumber">1815</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="5162686434580248853" datatype="html">
|
<trans-unit id="5162686434580248853" datatype="html">
|
||||||
@@ -3534,7 +3527,7 @@
|
|||||||
</context-group>
|
</context-group>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
|
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
|
||||||
<context context-type="linenumber">1814</context>
|
<context context-type="linenumber">1816</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="8157388568390631653" datatype="html">
|
<trans-unit id="8157388568390631653" datatype="html">
|
||||||
@@ -5499,7 +5492,7 @@
|
|||||||
</context-group>
|
</context-group>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.ts</context>
|
<context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.ts</context>
|
||||||
<context context-type="linenumber">785</context>
|
<context context-type="linenumber">760</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="4522609911791833187" datatype="html">
|
<trans-unit id="4522609911791833187" datatype="html">
|
||||||
@@ -7327,7 +7320,7 @@
|
|||||||
</context-group>
|
</context-group>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.ts</context>
|
<context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.ts</context>
|
||||||
<context context-type="linenumber">415</context>
|
<context context-type="linenumber">390</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
<note priority="1" from="description">this string is used to separate processing, failed and added on the file upload widget</note>
|
<note priority="1" from="description">this string is used to separate processing, failed and added on the file upload widget</note>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
@@ -7851,7 +7844,7 @@
|
|||||||
</context-group>
|
</context-group>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.ts</context>
|
<context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.ts</context>
|
||||||
<context context-type="linenumber">783</context>
|
<context context-type="linenumber">758</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="7295637485862454066" datatype="html">
|
<trans-unit id="7295637485862454066" datatype="html">
|
||||||
@@ -7869,7 +7862,7 @@
|
|||||||
</context-group>
|
</context-group>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.ts</context>
|
<context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.ts</context>
|
||||||
<context context-type="linenumber">829</context>
|
<context context-type="linenumber">798</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="2951161989614003846" datatype="html">
|
<trans-unit id="2951161989614003846" datatype="html">
|
||||||
@@ -7890,88 +7883,88 @@
|
|||||||
<source>Reprocess operation for "<x id="PH" equiv-text="this.document.title"/>" will begin in the background.</source>
|
<source>Reprocess operation for "<x id="PH" equiv-text="this.document.title"/>" will begin in the background.</source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
|
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
|
||||||
<context context-type="linenumber">1385</context>
|
<context context-type="linenumber">1387</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="4409560272830824468" datatype="html">
|
<trans-unit id="4409560272830824468" datatype="html">
|
||||||
<source>Error executing operation</source>
|
<source>Error executing operation</source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
|
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
|
||||||
<context context-type="linenumber">1396</context>
|
<context context-type="linenumber">1398</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="6030453331794586802" datatype="html">
|
<trans-unit id="6030453331794586802" datatype="html">
|
||||||
<source>Error downloading document</source>
|
<source>Error downloading document</source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
|
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
|
||||||
<context context-type="linenumber">1459</context>
|
<context context-type="linenumber">1461</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="4458954481601077369" datatype="html">
|
<trans-unit id="4458954481601077369" datatype="html">
|
||||||
<source>Page Fit</source>
|
<source>Page Fit</source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
|
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
|
||||||
<context context-type="linenumber">1539</context>
|
<context context-type="linenumber">1541</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="4663705961777238777" datatype="html">
|
<trans-unit id="4663705961777238777" datatype="html">
|
||||||
<source>PDF edit operation for "<x id="PH" equiv-text="this.document.title"/>" will begin in the background.</source>
|
<source>PDF edit operation for "<x id="PH" equiv-text="this.document.title"/>" will begin in the background.</source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
|
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
|
||||||
<context context-type="linenumber">1779</context>
|
<context context-type="linenumber">1781</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="9043972994040261999" datatype="html">
|
<trans-unit id="9043972994040261999" datatype="html">
|
||||||
<source>Error executing PDF edit operation</source>
|
<source>Error executing PDF edit operation</source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
|
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
|
||||||
<context context-type="linenumber">1791</context>
|
<context context-type="linenumber">1793</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="6172690334763056188" datatype="html">
|
<trans-unit id="6172690334763056188" datatype="html">
|
||||||
<source>Please enter the current password before attempting to remove it.</source>
|
<source>Please enter the current password before attempting to remove it.</source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
|
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
|
||||||
<context context-type="linenumber">1802</context>
|
<context context-type="linenumber">1804</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="968660764814228922" datatype="html">
|
<trans-unit id="968660764814228922" datatype="html">
|
||||||
<source>Password removal operation for "<x id="PH" equiv-text="this.document.title"/>" will begin in the background.</source>
|
<source>Password removal operation for "<x id="PH" equiv-text="this.document.title"/>" will begin in the background.</source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
|
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
|
||||||
<context context-type="linenumber">1836</context>
|
<context context-type="linenumber">1838</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="2282118435712883014" datatype="html">
|
<trans-unit id="2282118435712883014" datatype="html">
|
||||||
<source>Error executing password removal operation</source>
|
<source>Error executing password removal operation</source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
|
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
|
||||||
<context context-type="linenumber">1850</context>
|
<context context-type="linenumber">1852</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="3740891324955700797" datatype="html">
|
<trans-unit id="3740891324955700797" datatype="html">
|
||||||
<source>Print failed.</source>
|
<source>Print failed.</source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
|
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
|
||||||
<context context-type="linenumber">1889</context>
|
<context context-type="linenumber">1891</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="6457245677384603573" datatype="html">
|
<trans-unit id="6457245677384603573" datatype="html">
|
||||||
<source>Error loading document for printing.</source>
|
<source>Error loading document for printing.</source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
|
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
|
||||||
<context context-type="linenumber">1901</context>
|
<context context-type="linenumber">1903</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="6085793215710522488" datatype="html">
|
<trans-unit id="6085793215710522488" datatype="html">
|
||||||
<source>An error occurred loading tiff: <x id="PH" equiv-text="err.toString()"/></source>
|
<source>An error occurred loading tiff: <x id="PH" equiv-text="err.toString()"/></source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
|
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
|
||||||
<context context-type="linenumber">1966</context>
|
<context context-type="linenumber">1968</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
|
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
|
||||||
<context context-type="linenumber">1970</context>
|
<context context-type="linenumber">1972</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="4958946940233632319" datatype="html">
|
<trans-unit id="4958946940233632319" datatype="html">
|
||||||
@@ -8215,25 +8208,25 @@
|
|||||||
<source>Error executing bulk operation</source>
|
<source>Error executing bulk operation</source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.ts</context>
|
<context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.ts</context>
|
||||||
<context context-type="linenumber">321</context>
|
<context context-type="linenumber">294</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="7894972847287473517" datatype="html">
|
<trans-unit id="7894972847287473517" datatype="html">
|
||||||
<source>"<x id="PH" equiv-text="items[0].name"/>"</source>
|
<source>"<x id="PH" equiv-text="items[0].name"/>"</source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.ts</context>
|
<context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.ts</context>
|
||||||
<context context-type="linenumber">407</context>
|
<context context-type="linenumber">382</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.ts</context>
|
<context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.ts</context>
|
||||||
<context context-type="linenumber">413</context>
|
<context context-type="linenumber">388</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="8639884465898458690" datatype="html">
|
<trans-unit id="8639884465898458690" datatype="html">
|
||||||
<source>"<x id="PH" equiv-text="items[0].name"/>" and "<x id="PH_1" equiv-text="items[1].name"/>"</source>
|
<source>"<x id="PH" equiv-text="items[0].name"/>" and "<x id="PH_1" equiv-text="items[1].name"/>"</source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.ts</context>
|
<context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.ts</context>
|
||||||
<context context-type="linenumber">409</context>
|
<context context-type="linenumber">384</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
<note priority="1" from="description">This is for messages like 'modify "tag1" and "tag2"'</note>
|
<note priority="1" from="description">This is for messages like 'modify "tag1" and "tag2"'</note>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
@@ -8241,7 +8234,7 @@
|
|||||||
<source><x id="PH" equiv-text="list"/> and "<x id="PH_1" equiv-text="items[items.length - 1].name"/>"</source>
|
<source><x id="PH" equiv-text="list"/> and "<x id="PH_1" equiv-text="items[items.length - 1].name"/>"</source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.ts</context>
|
<context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.ts</context>
|
||||||
<context context-type="linenumber">417,419</context>
|
<context context-type="linenumber">392,394</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
<note priority="1" from="description">this is for messages like 'modify "tag1", "tag2" and "tag3"'</note>
|
<note priority="1" from="description">this is for messages like 'modify "tag1", "tag2" and "tag3"'</note>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
@@ -8249,14 +8242,14 @@
|
|||||||
<source>Confirm tags assignment</source>
|
<source>Confirm tags assignment</source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.ts</context>
|
<context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.ts</context>
|
||||||
<context context-type="linenumber">434</context>
|
<context context-type="linenumber">409</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="6619516195038467207" datatype="html">
|
<trans-unit id="6619516195038467207" datatype="html">
|
||||||
<source>This operation will add the tag "<x id="PH" equiv-text="tag.name"/>" to <x id="PH_1" equiv-text="this.list.selected.size"/> selected document(s).</source>
|
<source>This operation will add the tag "<x id="PH" equiv-text="tag.name"/>" to <x id="PH_1" equiv-text="this.list.selected.size"/> selected document(s).</source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.ts</context>
|
<context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.ts</context>
|
||||||
<context context-type="linenumber">440</context>
|
<context context-type="linenumber">415</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="1894412783609570695" datatype="html">
|
<trans-unit id="1894412783609570695" datatype="html">
|
||||||
@@ -8265,14 +8258,14 @@
|
|||||||
)"/> to <x id="PH_1" equiv-text="this.list.selected.size"/> selected document(s).</source>
|
)"/> to <x id="PH_1" equiv-text="this.list.selected.size"/> selected document(s).</source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.ts</context>
|
<context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.ts</context>
|
||||||
<context context-type="linenumber">445,447</context>
|
<context context-type="linenumber">420,422</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="7181166515756808573" datatype="html">
|
<trans-unit id="7181166515756808573" datatype="html">
|
||||||
<source>This operation will remove the tag "<x id="PH" equiv-text="tag.name"/>" from <x id="PH_1" equiv-text="this.list.selected.size"/> selected document(s).</source>
|
<source>This operation will remove the tag "<x id="PH" equiv-text="tag.name"/>" from <x id="PH_1" equiv-text="this.list.selected.size"/> selected document(s).</source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.ts</context>
|
<context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.ts</context>
|
||||||
<context context-type="linenumber">453</context>
|
<context context-type="linenumber">428</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="3819792277998068944" datatype="html">
|
<trans-unit id="3819792277998068944" datatype="html">
|
||||||
@@ -8281,7 +8274,7 @@
|
|||||||
)"/> from <x id="PH_1" equiv-text="this.list.selected.size"/> selected document(s).</source>
|
)"/> from <x id="PH_1" equiv-text="this.list.selected.size"/> selected document(s).</source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.ts</context>
|
<context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.ts</context>
|
||||||
<context context-type="linenumber">458,460</context>
|
<context context-type="linenumber">433,435</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="2739066218579571288" datatype="html">
|
<trans-unit id="2739066218579571288" datatype="html">
|
||||||
@@ -8292,84 +8285,84 @@
|
|||||||
)"/> on <x id="PH_2" equiv-text="this.list.selected.size"/> selected document(s).</source>
|
)"/> on <x id="PH_2" equiv-text="this.list.selected.size"/> selected document(s).</source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.ts</context>
|
<context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.ts</context>
|
||||||
<context context-type="linenumber">462,466</context>
|
<context context-type="linenumber">437,441</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="2996713129519325161" datatype="html">
|
<trans-unit id="2996713129519325161" datatype="html">
|
||||||
<source>Confirm correspondent assignment</source>
|
<source>Confirm correspondent assignment</source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.ts</context>
|
<context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.ts</context>
|
||||||
<context context-type="linenumber">503</context>
|
<context context-type="linenumber">478</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="6900893559485781849" datatype="html">
|
<trans-unit id="6900893559485781849" datatype="html">
|
||||||
<source>This operation will assign the correspondent "<x id="PH" equiv-text="correspondent.name"/>" to <x id="PH_1" equiv-text="this.list.selected.size"/> selected document(s).</source>
|
<source>This operation will assign the correspondent "<x id="PH" equiv-text="correspondent.name"/>" to <x id="PH_1" equiv-text="this.list.selected.size"/> selected document(s).</source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.ts</context>
|
<context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.ts</context>
|
||||||
<context context-type="linenumber">505</context>
|
<context context-type="linenumber">480</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="1257522660364398440" datatype="html">
|
<trans-unit id="1257522660364398440" datatype="html">
|
||||||
<source>This operation will remove the correspondent from <x id="PH" equiv-text="this.list.selected.size"/> selected document(s).</source>
|
<source>This operation will remove the correspondent from <x id="PH" equiv-text="this.list.selected.size"/> selected document(s).</source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.ts</context>
|
<context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.ts</context>
|
||||||
<context context-type="linenumber">507</context>
|
<context context-type="linenumber">482</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="5393409374423140648" datatype="html">
|
<trans-unit id="5393409374423140648" datatype="html">
|
||||||
<source>Confirm document type assignment</source>
|
<source>Confirm document type assignment</source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.ts</context>
|
<context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.ts</context>
|
||||||
<context context-type="linenumber">541</context>
|
<context context-type="linenumber">516</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="332180123895325027" datatype="html">
|
<trans-unit id="332180123895325027" datatype="html">
|
||||||
<source>This operation will assign the document type "<x id="PH" equiv-text="documentType.name"/>" to <x id="PH_1" equiv-text="this.list.selected.size"/> selected document(s).</source>
|
<source>This operation will assign the document type "<x id="PH" equiv-text="documentType.name"/>" to <x id="PH_1" equiv-text="this.list.selected.size"/> selected document(s).</source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.ts</context>
|
<context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.ts</context>
|
||||||
<context context-type="linenumber">543</context>
|
<context context-type="linenumber">518</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="2236642492594872779" datatype="html">
|
<trans-unit id="2236642492594872779" datatype="html">
|
||||||
<source>This operation will remove the document type from <x id="PH" equiv-text="this.list.selected.size"/> selected document(s).</source>
|
<source>This operation will remove the document type from <x id="PH" equiv-text="this.list.selected.size"/> selected document(s).</source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.ts</context>
|
<context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.ts</context>
|
||||||
<context context-type="linenumber">545</context>
|
<context context-type="linenumber">520</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="6386555513013840736" datatype="html">
|
<trans-unit id="6386555513013840736" datatype="html">
|
||||||
<source>Confirm storage path assignment</source>
|
<source>Confirm storage path assignment</source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.ts</context>
|
<context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.ts</context>
|
||||||
<context context-type="linenumber">579</context>
|
<context context-type="linenumber">554</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="8750527458618415924" datatype="html">
|
<trans-unit id="8750527458618415924" datatype="html">
|
||||||
<source>This operation will assign the storage path "<x id="PH" equiv-text="storagePath.name"/>" to <x id="PH_1" equiv-text="this.list.selected.size"/> selected document(s).</source>
|
<source>This operation will assign the storage path "<x id="PH" equiv-text="storagePath.name"/>" to <x id="PH_1" equiv-text="this.list.selected.size"/> selected document(s).</source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.ts</context>
|
<context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.ts</context>
|
||||||
<context context-type="linenumber">581</context>
|
<context context-type="linenumber">556</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="60728365335056946" datatype="html">
|
<trans-unit id="60728365335056946" datatype="html">
|
||||||
<source>This operation will remove the storage path from <x id="PH" equiv-text="this.list.selected.size"/> selected document(s).</source>
|
<source>This operation will remove the storage path from <x id="PH" equiv-text="this.list.selected.size"/> selected document(s).</source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.ts</context>
|
<context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.ts</context>
|
||||||
<context context-type="linenumber">583</context>
|
<context context-type="linenumber">558</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="4187352575310415704" datatype="html">
|
<trans-unit id="4187352575310415704" datatype="html">
|
||||||
<source>Confirm custom field assignment</source>
|
<source>Confirm custom field assignment</source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.ts</context>
|
<context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.ts</context>
|
||||||
<context context-type="linenumber">612</context>
|
<context context-type="linenumber">587</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="7966494636326273856" datatype="html">
|
<trans-unit id="7966494636326273856" datatype="html">
|
||||||
<source>This operation will assign the custom field "<x id="PH" equiv-text="customField.name"/>" to <x id="PH_1" equiv-text="this.list.selected.size"/> selected document(s).</source>
|
<source>This operation will assign the custom field "<x id="PH" equiv-text="customField.name"/>" to <x id="PH_1" equiv-text="this.list.selected.size"/> selected document(s).</source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.ts</context>
|
<context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.ts</context>
|
||||||
<context context-type="linenumber">618</context>
|
<context context-type="linenumber">593</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="5789455969634598553" datatype="html">
|
<trans-unit id="5789455969634598553" datatype="html">
|
||||||
@@ -8378,14 +8371,14 @@
|
|||||||
)"/> to <x id="PH_1" equiv-text="this.list.selected.size"/> selected document(s).</source>
|
)"/> to <x id="PH_1" equiv-text="this.list.selected.size"/> selected document(s).</source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.ts</context>
|
<context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.ts</context>
|
||||||
<context context-type="linenumber">623,625</context>
|
<context context-type="linenumber">598,600</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="5648572354333199245" datatype="html">
|
<trans-unit id="5648572354333199245" datatype="html">
|
||||||
<source>This operation will remove the custom field "<x id="PH" equiv-text="customField.name"/>" from <x id="PH_1" equiv-text="this.list.selected.size"/> selected document(s).</source>
|
<source>This operation will remove the custom field "<x id="PH" equiv-text="customField.name"/>" from <x id="PH_1" equiv-text="this.list.selected.size"/> selected document(s).</source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.ts</context>
|
<context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.ts</context>
|
||||||
<context context-type="linenumber">631</context>
|
<context context-type="linenumber">606</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="6666899594015948817" datatype="html">
|
<trans-unit id="6666899594015948817" datatype="html">
|
||||||
@@ -8394,7 +8387,7 @@
|
|||||||
)"/> from <x id="PH_1" equiv-text="this.list.selected.size"/> selected document(s).</source>
|
)"/> from <x id="PH_1" equiv-text="this.list.selected.size"/> selected document(s).</source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.ts</context>
|
<context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.ts</context>
|
||||||
<context context-type="linenumber">636,638</context>
|
<context context-type="linenumber">611,613</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="8050047262594964176" datatype="html">
|
<trans-unit id="8050047262594964176" datatype="html">
|
||||||
@@ -8405,91 +8398,91 @@
|
|||||||
)"/> on <x id="PH_2" equiv-text="this.list.selected.size"/> selected document(s).</source>
|
)"/> on <x id="PH_2" equiv-text="this.list.selected.size"/> selected document(s).</source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.ts</context>
|
<context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.ts</context>
|
||||||
<context context-type="linenumber">640,644</context>
|
<context context-type="linenumber">615,619</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="8615059324209654051" datatype="html">
|
<trans-unit id="8615059324209654051" datatype="html">
|
||||||
<source>Move <x id="PH" equiv-text="this.list.selected.size"/> selected document(s) to the trash?</source>
|
<source>Move <x id="PH" equiv-text="this.list.selected.size"/> selected document(s) to the trash?</source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.ts</context>
|
<context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.ts</context>
|
||||||
<context context-type="linenumber">782</context>
|
<context context-type="linenumber">757</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="8585195717323764335" datatype="html">
|
<trans-unit id="8585195717323764335" datatype="html">
|
||||||
<source>This operation will permanently recreate the archive files for <x id="PH" equiv-text="this.list.selected.size"/> selected document(s).</source>
|
<source>This operation will permanently recreate the archive files for <x id="PH" equiv-text="this.list.selected.size"/> selected document(s).</source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.ts</context>
|
<context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.ts</context>
|
||||||
<context context-type="linenumber">830</context>
|
<context context-type="linenumber">799</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="7366623494074776040" datatype="html">
|
<trans-unit id="7366623494074776040" datatype="html">
|
||||||
<source>The archive files will be re-generated with the current settings.</source>
|
<source>The archive files will be re-generated with the current settings.</source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.ts</context>
|
<context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.ts</context>
|
||||||
<context context-type="linenumber">831</context>
|
<context context-type="linenumber">800</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="6555329262222566158" datatype="html">
|
<trans-unit id="6555329262222566158" datatype="html">
|
||||||
<source>Rotate confirm</source>
|
<source>Rotate confirm</source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.ts</context>
|
<context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.ts</context>
|
||||||
<context context-type="linenumber">868</context>
|
<context context-type="linenumber">832</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="5203024009814367559" datatype="html">
|
<trans-unit id="5203024009814367559" datatype="html">
|
||||||
<source>This operation will add rotated versions of the <x id="PH" equiv-text="this.list.selected.size"/> document(s).</source>
|
<source>This operation will add rotated versions of the <x id="PH" equiv-text="this.list.selected.size"/> document(s).</source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.ts</context>
|
<context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.ts</context>
|
||||||
<context context-type="linenumber">869</context>
|
<context context-type="linenumber">833</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="7910756456450124185" datatype="html">
|
<trans-unit id="7910756456450124185" datatype="html">
|
||||||
<source>Merge confirm</source>
|
<source>Merge confirm</source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.ts</context>
|
<context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.ts</context>
|
||||||
<context context-type="linenumber">892</context>
|
<context context-type="linenumber">852</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="7643543647233874431" datatype="html">
|
<trans-unit id="7643543647233874431" datatype="html">
|
||||||
<source>This operation will merge <x id="PH" equiv-text="this.list.selected.size"/> selected documents into a new document.</source>
|
<source>This operation will merge <x id="PH" equiv-text="this.list.selected.size"/> selected documents into a new document.</source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.ts</context>
|
<context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.ts</context>
|
||||||
<context context-type="linenumber">893</context>
|
<context context-type="linenumber">853</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="7869008840945899895" datatype="html">
|
<trans-unit id="7869008840945899895" datatype="html">
|
||||||
<source>Merged document will be queued for consumption.</source>
|
<source>Merged document will be queued for consumption.</source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.ts</context>
|
<context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.ts</context>
|
||||||
<context context-type="linenumber">916</context>
|
<context context-type="linenumber">872</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="476913782630693351" datatype="html">
|
<trans-unit id="476913782630693351" datatype="html">
|
||||||
<source>Custom fields updated.</source>
|
<source>Custom fields updated.</source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.ts</context>
|
<context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.ts</context>
|
||||||
<context context-type="linenumber">940</context>
|
<context context-type="linenumber">896</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="3873496751167944011" datatype="html">
|
<trans-unit id="3873496751167944011" datatype="html">
|
||||||
<source>Error updating custom fields.</source>
|
<source>Error updating custom fields.</source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.ts</context>
|
<context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.ts</context>
|
||||||
<context context-type="linenumber">949</context>
|
<context context-type="linenumber">905</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="6144801143088984138" datatype="html">
|
<trans-unit id="6144801143088984138" datatype="html">
|
||||||
<source>Share link bundle creation requested.</source>
|
<source>Share link bundle creation requested.</source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.ts</context>
|
<context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.ts</context>
|
||||||
<context context-type="linenumber">989</context>
|
<context context-type="linenumber">945</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="46019676931295023" datatype="html">
|
<trans-unit id="46019676931295023" datatype="html">
|
||||||
<source>Share link bundle creation is not available yet.</source>
|
<source>Share link bundle creation is not available yet.</source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.ts</context>
|
<context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.ts</context>
|
||||||
<context context-type="linenumber">996</context>
|
<context context-type="linenumber">952</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="6307402210351946694" datatype="html">
|
<trans-unit id="6307402210351946694" datatype="html">
|
||||||
|
|||||||
@@ -10,22 +10,10 @@
|
|||||||
<ul class="list-group"
|
<ul class="list-group"
|
||||||
cdkDropList
|
cdkDropList
|
||||||
(cdkDropListDropped)="onDrop($event)">
|
(cdkDropListDropped)="onDrop($event)">
|
||||||
@for (document of documents; track document.id) {
|
@for (documentID of documentIDs; track documentID) {
|
||||||
<li class="list-group-item d-flex align-items-center" cdkDrag>
|
<li class="list-group-item" cdkDrag>
|
||||||
<i-bs name="grip-vertical" class="me-2"></i-bs>
|
<i-bs name="grip-vertical" class="me-2"></i-bs>
|
||||||
<div class="d-flex flex-column">
|
{{getDocument(documentID)?.title}}
|
||||||
<div>
|
|
||||||
@if (document.correspondent) {
|
|
||||||
<b>{{document.correspondent | correspondentName | async}}: </b>
|
|
||||||
}{{document.title}}
|
|
||||||
</div>
|
|
||||||
<small class="text-muted">
|
|
||||||
{{document.created | customDate:'mediumDate'}}
|
|
||||||
@if (document.page_count) {
|
|
||||||
| {document.page_count, plural, =1 {One page} other {{{document.page_count}} pages}}
|
|
||||||
}
|
|
||||||
</small>
|
|
||||||
</div>
|
|
||||||
</li>
|
</li>
|
||||||
}
|
}
|
||||||
</ul>
|
</ul>
|
||||||
|
|||||||
@@ -3,14 +3,11 @@ import {
|
|||||||
DragDropModule,
|
DragDropModule,
|
||||||
moveItemInArray,
|
moveItemInArray,
|
||||||
} from '@angular/cdk/drag-drop'
|
} from '@angular/cdk/drag-drop'
|
||||||
import { AsyncPipe } from '@angular/common'
|
|
||||||
import { Component, OnInit, inject } from '@angular/core'
|
import { Component, OnInit, inject } from '@angular/core'
|
||||||
import { FormsModule, ReactiveFormsModule } from '@angular/forms'
|
import { FormsModule, ReactiveFormsModule } from '@angular/forms'
|
||||||
import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons'
|
import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons'
|
||||||
import { takeUntil } from 'rxjs'
|
import { takeUntil } from 'rxjs'
|
||||||
import { Document } from 'src/app/data/document'
|
import { Document } from 'src/app/data/document'
|
||||||
import { CorrespondentNamePipe } from 'src/app/pipes/correspondent-name.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 { DocumentService } from 'src/app/services/rest/document.service'
|
import { DocumentService } from 'src/app/services/rest/document.service'
|
||||||
import { ConfirmDialogComponent } from '../confirm-dialog.component'
|
import { ConfirmDialogComponent } from '../confirm-dialog.component'
|
||||||
@@ -20,9 +17,6 @@ import { ConfirmDialogComponent } from '../confirm-dialog.component'
|
|||||||
templateUrl: './merge-confirm-dialog.component.html',
|
templateUrl: './merge-confirm-dialog.component.html',
|
||||||
styleUrl: './merge-confirm-dialog.component.scss',
|
styleUrl: './merge-confirm-dialog.component.scss',
|
||||||
imports: [
|
imports: [
|
||||||
AsyncPipe,
|
|
||||||
CorrespondentNamePipe,
|
|
||||||
CustomDatePipe,
|
|
||||||
DragDropModule,
|
DragDropModule,
|
||||||
FormsModule,
|
FormsModule,
|
||||||
ReactiveFormsModule,
|
ReactiveFormsModule,
|
||||||
|
|||||||
@@ -31,8 +31,8 @@ export enum EditDialogMode {
|
|||||||
|
|
||||||
@Directive()
|
@Directive()
|
||||||
export abstract class EditDialogComponent<
|
export abstract class EditDialogComponent<
|
||||||
T extends ObjectWithPermissions | ObjectWithId,
|
T extends ObjectWithPermissions | ObjectWithId,
|
||||||
>
|
>
|
||||||
extends LoadingComponentWithPermissions
|
extends LoadingComponentWithPermissions
|
||||||
implements OnInit
|
implements OnInit
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -950,8 +950,8 @@ describe('DocumentDetailComponent', () => {
|
|||||||
|
|
||||||
it('should support reprocess, confirm and close modal after started', () => {
|
it('should support reprocess, confirm and close modal after started', () => {
|
||||||
initNormally()
|
initNormally()
|
||||||
const reprocessSpy = jest.spyOn(documentService, 'reprocessDocuments')
|
const bulkEditSpy = jest.spyOn(documentService, 'bulkEdit')
|
||||||
reprocessSpy.mockReturnValue(of(true))
|
bulkEditSpy.mockReturnValue(of(true))
|
||||||
let openModal: NgbModalRef
|
let openModal: NgbModalRef
|
||||||
modalService.activeInstances.subscribe((modal) => (openModal = modal[0]))
|
modalService.activeInstances.subscribe((modal) => (openModal = modal[0]))
|
||||||
const modalSpy = jest.spyOn(modalService, 'open')
|
const modalSpy = jest.spyOn(modalService, 'open')
|
||||||
@@ -959,7 +959,7 @@ describe('DocumentDetailComponent', () => {
|
|||||||
component.reprocess()
|
component.reprocess()
|
||||||
const modalCloseSpy = jest.spyOn(openModal, 'close')
|
const modalCloseSpy = jest.spyOn(openModal, 'close')
|
||||||
openModal.componentInstance.confirmClicked.next()
|
openModal.componentInstance.confirmClicked.next()
|
||||||
expect(reprocessSpy).toHaveBeenCalledWith([doc.id])
|
expect(bulkEditSpy).toHaveBeenCalledWith([doc.id], 'reprocess', {})
|
||||||
expect(modalSpy).toHaveBeenCalled()
|
expect(modalSpy).toHaveBeenCalled()
|
||||||
expect(toastSpy).toHaveBeenCalled()
|
expect(toastSpy).toHaveBeenCalled()
|
||||||
expect(modalCloseSpy).toHaveBeenCalled()
|
expect(modalCloseSpy).toHaveBeenCalled()
|
||||||
@@ -967,13 +967,13 @@ describe('DocumentDetailComponent', () => {
|
|||||||
|
|
||||||
it('should show error if redo ocr call fails', () => {
|
it('should show error if redo ocr call fails', () => {
|
||||||
initNormally()
|
initNormally()
|
||||||
const reprocessSpy = jest.spyOn(documentService, 'reprocessDocuments')
|
const bulkEditSpy = jest.spyOn(documentService, 'bulkEdit')
|
||||||
let openModal: NgbModalRef
|
let openModal: NgbModalRef
|
||||||
modalService.activeInstances.subscribe((modal) => (openModal = modal[0]))
|
modalService.activeInstances.subscribe((modal) => (openModal = modal[0]))
|
||||||
const toastSpy = jest.spyOn(toastService, 'showError')
|
const toastSpy = jest.spyOn(toastService, 'showError')
|
||||||
component.reprocess()
|
component.reprocess()
|
||||||
const modalCloseSpy = jest.spyOn(openModal, 'close')
|
const modalCloseSpy = jest.spyOn(openModal, 'close')
|
||||||
reprocessSpy.mockReturnValue(throwError(() => new Error('error occurred')))
|
bulkEditSpy.mockReturnValue(throwError(() => new Error('error occurred')))
|
||||||
openModal.componentInstance.confirmClicked.next()
|
openModal.componentInstance.confirmClicked.next()
|
||||||
expect(toastSpy).toHaveBeenCalled()
|
expect(toastSpy).toHaveBeenCalled()
|
||||||
expect(modalCloseSpy).not.toHaveBeenCalled()
|
expect(modalCloseSpy).not.toHaveBeenCalled()
|
||||||
@@ -1644,9 +1644,9 @@ describe('DocumentDetailComponent', () => {
|
|||||||
expect(
|
expect(
|
||||||
fixture.debugElement.query(By.css('.preview-sticky img'))
|
fixture.debugElement.query(By.css('.preview-sticky img'))
|
||||||
).not.toBeUndefined()
|
).not.toBeUndefined()
|
||||||
;((component.document.mime_type =
|
;(component.document.mime_type =
|
||||||
'application/vnd.openxmlformats-officedocument.wordprocessingml.document'),
|
'application/vnd.openxmlformats-officedocument.wordprocessingml.document'),
|
||||||
fixture.detectChanges())
|
fixture.detectChanges()
|
||||||
expect(component.archiveContentRenderType).toEqual(
|
expect(component.archiveContentRenderType).toEqual(
|
||||||
component.ContentRenderType.Other
|
component.ContentRenderType.Other
|
||||||
)
|
)
|
||||||
@@ -1669,15 +1669,18 @@ describe('DocumentDetailComponent', () => {
|
|||||||
modal.componentInstance.pages = [{ page: 1, rotate: 0, splitAfter: false }]
|
modal.componentInstance.pages = [{ page: 1, rotate: 0, splitAfter: false }]
|
||||||
modal.componentInstance.confirm()
|
modal.componentInstance.confirm()
|
||||||
let req = httpTestingController.expectOne(
|
let req = httpTestingController.expectOne(
|
||||||
`${environment.apiBaseUrl}documents/edit_pdf/`
|
`${environment.apiBaseUrl}documents/bulk_edit/`
|
||||||
)
|
)
|
||||||
expect(req.request.body).toEqual({
|
expect(req.request.body).toEqual({
|
||||||
documents: [10],
|
documents: [10],
|
||||||
operations: [{ page: 1, rotate: 0, doc: 0 }],
|
method: 'edit_pdf',
|
||||||
delete_original: false,
|
parameters: {
|
||||||
update_document: false,
|
operations: [{ page: 1, rotate: 0, doc: 0 }],
|
||||||
include_metadata: true,
|
delete_original: false,
|
||||||
source_mode: 'explicit_selection',
|
update_document: false,
|
||||||
|
include_metadata: true,
|
||||||
|
source_mode: 'explicit_selection',
|
||||||
|
},
|
||||||
})
|
})
|
||||||
req.error(new ErrorEvent('failed'))
|
req.error(new ErrorEvent('failed'))
|
||||||
expect(errorSpy).toHaveBeenCalled()
|
expect(errorSpy).toHaveBeenCalled()
|
||||||
@@ -1688,7 +1691,7 @@ describe('DocumentDetailComponent', () => {
|
|||||||
modal.componentInstance.deleteOriginal = true
|
modal.componentInstance.deleteOriginal = true
|
||||||
modal.componentInstance.confirm()
|
modal.componentInstance.confirm()
|
||||||
req = httpTestingController.expectOne(
|
req = httpTestingController.expectOne(
|
||||||
`${environment.apiBaseUrl}documents/edit_pdf/`
|
`${environment.apiBaseUrl}documents/bulk_edit/`
|
||||||
)
|
)
|
||||||
req.flush(true)
|
req.flush(true)
|
||||||
expect(closeSpy).toHaveBeenCalled()
|
expect(closeSpy).toHaveBeenCalled()
|
||||||
@@ -1708,15 +1711,18 @@ describe('DocumentDetailComponent', () => {
|
|||||||
dialog.deleteOriginal = true
|
dialog.deleteOriginal = true
|
||||||
dialog.confirm()
|
dialog.confirm()
|
||||||
const req = httpTestingController.expectOne(
|
const req = httpTestingController.expectOne(
|
||||||
`${environment.apiBaseUrl}documents/remove_password/`
|
`${environment.apiBaseUrl}documents/bulk_edit/`
|
||||||
)
|
)
|
||||||
expect(req.request.body).toEqual({
|
expect(req.request.body).toEqual({
|
||||||
documents: [10],
|
documents: [10],
|
||||||
password: 'secret',
|
method: 'remove_password',
|
||||||
update_document: false,
|
parameters: {
|
||||||
include_metadata: false,
|
password: 'secret',
|
||||||
delete_original: true,
|
update_document: false,
|
||||||
source_mode: 'explicit_selection',
|
include_metadata: false,
|
||||||
|
delete_original: true,
|
||||||
|
source_mode: 'explicit_selection',
|
||||||
|
},
|
||||||
})
|
})
|
||||||
req.flush(true)
|
req.flush(true)
|
||||||
})
|
})
|
||||||
@@ -1731,7 +1737,7 @@ describe('DocumentDetailComponent', () => {
|
|||||||
|
|
||||||
expect(errorSpy).toHaveBeenCalled()
|
expect(errorSpy).toHaveBeenCalled()
|
||||||
httpTestingController.expectNone(
|
httpTestingController.expectNone(
|
||||||
`${environment.apiBaseUrl}documents/remove_password/`
|
`${environment.apiBaseUrl}documents/bulk_edit/`
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -1747,7 +1753,7 @@ describe('DocumentDetailComponent', () => {
|
|||||||
modal.componentInstance as PasswordRemovalConfirmDialogComponent
|
modal.componentInstance as PasswordRemovalConfirmDialogComponent
|
||||||
dialog.confirm()
|
dialog.confirm()
|
||||||
const req = httpTestingController.expectOne(
|
const req = httpTestingController.expectOne(
|
||||||
`${environment.apiBaseUrl}documents/remove_password/`
|
`${environment.apiBaseUrl}documents/bulk_edit/`
|
||||||
)
|
)
|
||||||
req.error(new ErrorEvent('failed'))
|
req.error(new ErrorEvent('failed'))
|
||||||
|
|
||||||
@@ -1768,7 +1774,7 @@ describe('DocumentDetailComponent', () => {
|
|||||||
modal.componentInstance as PasswordRemovalConfirmDialogComponent
|
modal.componentInstance as PasswordRemovalConfirmDialogComponent
|
||||||
dialog.confirm()
|
dialog.confirm()
|
||||||
const req = httpTestingController.expectOne(
|
const req = httpTestingController.expectOne(
|
||||||
`${environment.apiBaseUrl}documents/remove_password/`
|
`${environment.apiBaseUrl}documents/bulk_edit/`
|
||||||
)
|
)
|
||||||
req.flush(true)
|
req.flush(true)
|
||||||
|
|
||||||
|
|||||||
@@ -1379,25 +1379,27 @@ export class DocumentDetailComponent
|
|||||||
modal.componentInstance.btnCaption = $localize`Proceed`
|
modal.componentInstance.btnCaption = $localize`Proceed`
|
||||||
modal.componentInstance.confirmClicked.subscribe(() => {
|
modal.componentInstance.confirmClicked.subscribe(() => {
|
||||||
modal.componentInstance.buttonsEnabled = false
|
modal.componentInstance.buttonsEnabled = false
|
||||||
this.documentsService.reprocessDocuments([this.document.id]).subscribe({
|
this.documentsService
|
||||||
next: () => {
|
.bulkEdit([this.document.id], 'reprocess', {})
|
||||||
this.toastService.showInfo(
|
.subscribe({
|
||||||
$localize`Reprocess operation for "${this.document.title}" will begin in the background.`
|
next: () => {
|
||||||
)
|
this.toastService.showInfo(
|
||||||
if (modal) {
|
$localize`Reprocess operation for "${this.document.title}" will begin in the background.`
|
||||||
modal.close()
|
)
|
||||||
}
|
if (modal) {
|
||||||
},
|
modal.close()
|
||||||
error: (error) => {
|
}
|
||||||
if (modal) {
|
},
|
||||||
modal.componentInstance.buttonsEnabled = true
|
error: (error) => {
|
||||||
}
|
if (modal) {
|
||||||
this.toastService.showError(
|
modal.componentInstance.buttonsEnabled = true
|
||||||
$localize`Error executing operation`,
|
}
|
||||||
error
|
this.toastService.showError(
|
||||||
)
|
$localize`Error executing operation`,
|
||||||
},
|
error
|
||||||
})
|
)
|
||||||
|
},
|
||||||
|
})
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1764,7 +1766,7 @@ export class DocumentDetailComponent
|
|||||||
.subscribe(() => {
|
.subscribe(() => {
|
||||||
modal.componentInstance.buttonsEnabled = false
|
modal.componentInstance.buttonsEnabled = false
|
||||||
this.documentsService
|
this.documentsService
|
||||||
.editPdfDocuments([sourceDocumentId], {
|
.bulkEdit([sourceDocumentId], 'edit_pdf', {
|
||||||
operations: modal.componentInstance.getOperations(),
|
operations: modal.componentInstance.getOperations(),
|
||||||
delete_original: modal.componentInstance.deleteOriginal,
|
delete_original: modal.componentInstance.deleteOriginal,
|
||||||
update_document:
|
update_document:
|
||||||
@@ -1822,7 +1824,7 @@ export class DocumentDetailComponent
|
|||||||
dialog.buttonsEnabled = false
|
dialog.buttonsEnabled = false
|
||||||
this.networkActive = true
|
this.networkActive = true
|
||||||
this.documentsService
|
this.documentsService
|
||||||
.removePasswordDocuments([sourceDocumentId], {
|
.bulkEdit([sourceDocumentId], 'remove_password', {
|
||||||
password: this.password,
|
password: this.password,
|
||||||
update_document: dialog.updateDocument,
|
update_document: dialog.updateDocument,
|
||||||
include_metadata: dialog.includeMetadata,
|
include_metadata: dialog.includeMetadata,
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
import { DatePipe } from '@angular/common'
|
|
||||||
import { provideHttpClient, withInterceptorsFromDi } from '@angular/common/http'
|
import { provideHttpClient, withInterceptorsFromDi } from '@angular/common/http'
|
||||||
import {
|
import {
|
||||||
HttpTestingController,
|
HttpTestingController,
|
||||||
@@ -139,7 +138,6 @@ describe('BulkEditorComponent', () => {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
FilterPipe,
|
FilterPipe,
|
||||||
DatePipe,
|
|
||||||
SettingsService,
|
SettingsService,
|
||||||
{
|
{
|
||||||
provide: UserService,
|
provide: UserService,
|
||||||
@@ -851,11 +849,13 @@ describe('BulkEditorComponent', () => {
|
|||||||
expect(modal).not.toBeUndefined()
|
expect(modal).not.toBeUndefined()
|
||||||
modal.componentInstance.confirm()
|
modal.componentInstance.confirm()
|
||||||
let req = httpTestingController.expectOne(
|
let req = httpTestingController.expectOne(
|
||||||
`${environment.apiBaseUrl}documents/delete/`
|
`${environment.apiBaseUrl}documents/bulk_edit/`
|
||||||
)
|
)
|
||||||
req.flush(true)
|
req.flush(true)
|
||||||
expect(req.request.body).toEqual({
|
expect(req.request.body).toEqual({
|
||||||
documents: [3, 4],
|
documents: [3, 4],
|
||||||
|
method: 'delete',
|
||||||
|
parameters: {},
|
||||||
})
|
})
|
||||||
httpTestingController.match(
|
httpTestingController.match(
|
||||||
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true`
|
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true`
|
||||||
@@ -868,7 +868,7 @@ describe('BulkEditorComponent', () => {
|
|||||||
fixture.detectChanges()
|
fixture.detectChanges()
|
||||||
component.applyDelete()
|
component.applyDelete()
|
||||||
req = httpTestingController.expectOne(
|
req = httpTestingController.expectOne(
|
||||||
`${environment.apiBaseUrl}documents/delete/`
|
`${environment.apiBaseUrl}documents/bulk_edit/`
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -944,11 +944,13 @@ describe('BulkEditorComponent', () => {
|
|||||||
expect(modal).not.toBeUndefined()
|
expect(modal).not.toBeUndefined()
|
||||||
modal.componentInstance.confirm()
|
modal.componentInstance.confirm()
|
||||||
let req = httpTestingController.expectOne(
|
let req = httpTestingController.expectOne(
|
||||||
`${environment.apiBaseUrl}documents/reprocess/`
|
`${environment.apiBaseUrl}documents/bulk_edit/`
|
||||||
)
|
)
|
||||||
req.flush(true)
|
req.flush(true)
|
||||||
expect(req.request.body).toEqual({
|
expect(req.request.body).toEqual({
|
||||||
documents: [3, 4],
|
documents: [3, 4],
|
||||||
|
method: 'reprocess',
|
||||||
|
parameters: {},
|
||||||
})
|
})
|
||||||
httpTestingController.match(
|
httpTestingController.match(
|
||||||
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true`
|
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true`
|
||||||
@@ -977,13 +979,13 @@ describe('BulkEditorComponent', () => {
|
|||||||
modal.componentInstance.rotate()
|
modal.componentInstance.rotate()
|
||||||
modal.componentInstance.confirm()
|
modal.componentInstance.confirm()
|
||||||
let req = httpTestingController.expectOne(
|
let req = httpTestingController.expectOne(
|
||||||
`${environment.apiBaseUrl}documents/rotate/`
|
`${environment.apiBaseUrl}documents/bulk_edit/`
|
||||||
)
|
)
|
||||||
req.flush(true)
|
req.flush(true)
|
||||||
expect(req.request.body).toEqual({
|
expect(req.request.body).toEqual({
|
||||||
documents: [3, 4],
|
documents: [3, 4],
|
||||||
degrees: 90,
|
method: 'rotate',
|
||||||
source_mode: 'latest_version',
|
parameters: { degrees: 90 },
|
||||||
})
|
})
|
||||||
httpTestingController.match(
|
httpTestingController.match(
|
||||||
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true`
|
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true`
|
||||||
@@ -1019,12 +1021,13 @@ describe('BulkEditorComponent', () => {
|
|||||||
modal.componentInstance.metadataDocumentID = 3
|
modal.componentInstance.metadataDocumentID = 3
|
||||||
modal.componentInstance.confirm()
|
modal.componentInstance.confirm()
|
||||||
let req = httpTestingController.expectOne(
|
let req = httpTestingController.expectOne(
|
||||||
`${environment.apiBaseUrl}documents/merge/`
|
`${environment.apiBaseUrl}documents/bulk_edit/`
|
||||||
)
|
)
|
||||||
req.flush(true)
|
req.flush(true)
|
||||||
expect(req.request.body).toEqual({
|
expect(req.request.body).toEqual({
|
||||||
documents: [3, 4],
|
documents: [3, 4],
|
||||||
metadata_document_id: 3,
|
method: 'merge',
|
||||||
|
parameters: { metadata_document_id: 3 },
|
||||||
})
|
})
|
||||||
httpTestingController.match(
|
httpTestingController.match(
|
||||||
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true`
|
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true`
|
||||||
@@ -1037,13 +1040,13 @@ describe('BulkEditorComponent', () => {
|
|||||||
modal.componentInstance.deleteOriginals = true
|
modal.componentInstance.deleteOriginals = true
|
||||||
modal.componentInstance.confirm()
|
modal.componentInstance.confirm()
|
||||||
req = httpTestingController.expectOne(
|
req = httpTestingController.expectOne(
|
||||||
`${environment.apiBaseUrl}documents/merge/`
|
`${environment.apiBaseUrl}documents/bulk_edit/`
|
||||||
)
|
)
|
||||||
req.flush(true)
|
req.flush(true)
|
||||||
expect(req.request.body).toEqual({
|
expect(req.request.body).toEqual({
|
||||||
documents: [3, 4],
|
documents: [3, 4],
|
||||||
metadata_document_id: 3,
|
method: 'merge',
|
||||||
delete_originals: true,
|
parameters: { metadata_document_id: 3, delete_originals: true },
|
||||||
})
|
})
|
||||||
httpTestingController.match(
|
httpTestingController.match(
|
||||||
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true`
|
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true`
|
||||||
@@ -1058,13 +1061,13 @@ describe('BulkEditorComponent', () => {
|
|||||||
modal.componentInstance.archiveFallback = true
|
modal.componentInstance.archiveFallback = true
|
||||||
modal.componentInstance.confirm()
|
modal.componentInstance.confirm()
|
||||||
req = httpTestingController.expectOne(
|
req = httpTestingController.expectOne(
|
||||||
`${environment.apiBaseUrl}documents/merge/`
|
`${environment.apiBaseUrl}documents/bulk_edit/`
|
||||||
)
|
)
|
||||||
req.flush(true)
|
req.flush(true)
|
||||||
expect(req.request.body).toEqual({
|
expect(req.request.body).toEqual({
|
||||||
documents: [3, 4],
|
documents: [3, 4],
|
||||||
metadata_document_id: 3,
|
method: 'merge',
|
||||||
archive_fallback: true,
|
parameters: { metadata_document_id: 3, archive_fallback: true },
|
||||||
})
|
})
|
||||||
httpTestingController.match(
|
httpTestingController.match(
|
||||||
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true`
|
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true`
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ import {
|
|||||||
} from '@ng-bootstrap/ng-bootstrap'
|
} from '@ng-bootstrap/ng-bootstrap'
|
||||||
import { saveAs } from 'file-saver'
|
import { saveAs } from 'file-saver'
|
||||||
import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons'
|
import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons'
|
||||||
import { first, map, Observable, Subject, switchMap, takeUntil } from 'rxjs'
|
import { first, map, Subject, switchMap, takeUntil } from 'rxjs'
|
||||||
import { ConfirmDialogComponent } from 'src/app/components/common/confirm-dialog/confirm-dialog.component'
|
import { ConfirmDialogComponent } from 'src/app/components/common/confirm-dialog/confirm-dialog.component'
|
||||||
import { CustomField } from 'src/app/data/custom-field'
|
import { CustomField } from 'src/app/data/custom-field'
|
||||||
import { MatchingModel } from 'src/app/data/matching-model'
|
import { MatchingModel } from 'src/app/data/matching-model'
|
||||||
@@ -29,9 +29,7 @@ import { CorrespondentService } from 'src/app/services/rest/correspondent.servic
|
|||||||
import { CustomFieldsService } from 'src/app/services/rest/custom-fields.service'
|
import { CustomFieldsService } from 'src/app/services/rest/custom-fields.service'
|
||||||
import { DocumentTypeService } from 'src/app/services/rest/document-type.service'
|
import { DocumentTypeService } from 'src/app/services/rest/document-type.service'
|
||||||
import {
|
import {
|
||||||
DocumentBulkEditMethod,
|
|
||||||
DocumentService,
|
DocumentService,
|
||||||
MergeDocumentsRequest,
|
|
||||||
SelectionDataItem,
|
SelectionDataItem,
|
||||||
} from 'src/app/services/rest/document.service'
|
} from 'src/app/services/rest/document.service'
|
||||||
import { SavedViewService } from 'src/app/services/rest/saved-view.service'
|
import { SavedViewService } from 'src/app/services/rest/saved-view.service'
|
||||||
@@ -257,9 +255,9 @@ export class BulkEditorComponent
|
|||||||
this.unsubscribeNotifier.complete()
|
this.unsubscribeNotifier.complete()
|
||||||
}
|
}
|
||||||
|
|
||||||
private executeBulkEditMethod(
|
private executeBulkOperation(
|
||||||
modal: NgbModalRef,
|
modal: NgbModalRef,
|
||||||
method: DocumentBulkEditMethod,
|
method: string,
|
||||||
args: any,
|
args: any,
|
||||||
overrideDocumentIDs?: number[]
|
overrideDocumentIDs?: number[]
|
||||||
) {
|
) {
|
||||||
@@ -274,55 +272,32 @@ export class BulkEditorComponent
|
|||||||
)
|
)
|
||||||
.pipe(first())
|
.pipe(first())
|
||||||
.subscribe({
|
.subscribe({
|
||||||
next: () => this.handleOperationSuccess(modal),
|
next: () => {
|
||||||
error: (error) => this.handleOperationError(modal, error),
|
if (args['delete_originals']) {
|
||||||
|
this.list.selected.clear()
|
||||||
|
}
|
||||||
|
this.list.reload()
|
||||||
|
this.list.reduceSelectionToFilter()
|
||||||
|
this.list.selected.forEach((id) => {
|
||||||
|
this.openDocumentService.refreshDocument(id)
|
||||||
|
})
|
||||||
|
this.savedViewService.maybeRefreshDocumentCounts()
|
||||||
|
if (modal) {
|
||||||
|
modal.close()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
error: (error) => {
|
||||||
|
if (modal) {
|
||||||
|
modal.componentInstance.buttonsEnabled = true
|
||||||
|
}
|
||||||
|
this.toastService.showError(
|
||||||
|
$localize`Error executing bulk operation`,
|
||||||
|
error
|
||||||
|
)
|
||||||
|
},
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
private executeDocumentAction(
|
|
||||||
modal: NgbModalRef,
|
|
||||||
request: Observable<any>,
|
|
||||||
options: { deleteOriginals?: boolean } = {}
|
|
||||||
) {
|
|
||||||
if (modal) {
|
|
||||||
modal.componentInstance.buttonsEnabled = false
|
|
||||||
}
|
|
||||||
request.pipe(first()).subscribe({
|
|
||||||
next: () => {
|
|
||||||
this.handleOperationSuccess(modal, options.deleteOriginals ?? false)
|
|
||||||
},
|
|
||||||
error: (error) => this.handleOperationError(modal, error),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
private handleOperationSuccess(
|
|
||||||
modal: NgbModalRef,
|
|
||||||
clearSelection: boolean = false
|
|
||||||
) {
|
|
||||||
if (clearSelection) {
|
|
||||||
this.list.selected.clear()
|
|
||||||
}
|
|
||||||
this.list.reload()
|
|
||||||
this.list.reduceSelectionToFilter()
|
|
||||||
this.list.selected.forEach((id) => {
|
|
||||||
this.openDocumentService.refreshDocument(id)
|
|
||||||
})
|
|
||||||
this.savedViewService.maybeRefreshDocumentCounts()
|
|
||||||
if (modal) {
|
|
||||||
modal.close()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private handleOperationError(modal: NgbModalRef, error: any) {
|
|
||||||
if (modal) {
|
|
||||||
modal.componentInstance.buttonsEnabled = true
|
|
||||||
}
|
|
||||||
this.toastService.showError(
|
|
||||||
$localize`Error executing bulk operation`,
|
|
||||||
error
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
private applySelectionData(
|
private applySelectionData(
|
||||||
items: SelectionDataItem[],
|
items: SelectionDataItem[],
|
||||||
selectionModel: FilterableDropdownSelectionModel
|
selectionModel: FilterableDropdownSelectionModel
|
||||||
@@ -471,13 +446,13 @@ export class BulkEditorComponent
|
|||||||
modal.componentInstance.confirmClicked
|
modal.componentInstance.confirmClicked
|
||||||
.pipe(takeUntil(this.unsubscribeNotifier))
|
.pipe(takeUntil(this.unsubscribeNotifier))
|
||||||
.subscribe(() => {
|
.subscribe(() => {
|
||||||
this.executeBulkEditMethod(modal, 'modify_tags', {
|
this.executeBulkOperation(modal, 'modify_tags', {
|
||||||
add_tags: changedTags.itemsToAdd.map((t) => t.id),
|
add_tags: changedTags.itemsToAdd.map((t) => t.id),
|
||||||
remove_tags: changedTags.itemsToRemove.map((t) => t.id),
|
remove_tags: changedTags.itemsToRemove.map((t) => t.id),
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
} else {
|
} else {
|
||||||
this.executeBulkEditMethod(null, 'modify_tags', {
|
this.executeBulkOperation(null, 'modify_tags', {
|
||||||
add_tags: changedTags.itemsToAdd.map((t) => t.id),
|
add_tags: changedTags.itemsToAdd.map((t) => t.id),
|
||||||
remove_tags: changedTags.itemsToRemove.map((t) => t.id),
|
remove_tags: changedTags.itemsToRemove.map((t) => t.id),
|
||||||
})
|
})
|
||||||
@@ -511,12 +486,12 @@ export class BulkEditorComponent
|
|||||||
modal.componentInstance.confirmClicked
|
modal.componentInstance.confirmClicked
|
||||||
.pipe(takeUntil(this.unsubscribeNotifier))
|
.pipe(takeUntil(this.unsubscribeNotifier))
|
||||||
.subscribe(() => {
|
.subscribe(() => {
|
||||||
this.executeBulkEditMethod(modal, 'set_correspondent', {
|
this.executeBulkOperation(modal, 'set_correspondent', {
|
||||||
correspondent: correspondent ? correspondent.id : null,
|
correspondent: correspondent ? correspondent.id : null,
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
} else {
|
} else {
|
||||||
this.executeBulkEditMethod(null, 'set_correspondent', {
|
this.executeBulkOperation(null, 'set_correspondent', {
|
||||||
correspondent: correspondent ? correspondent.id : null,
|
correspondent: correspondent ? correspondent.id : null,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -549,12 +524,12 @@ export class BulkEditorComponent
|
|||||||
modal.componentInstance.confirmClicked
|
modal.componentInstance.confirmClicked
|
||||||
.pipe(takeUntil(this.unsubscribeNotifier))
|
.pipe(takeUntil(this.unsubscribeNotifier))
|
||||||
.subscribe(() => {
|
.subscribe(() => {
|
||||||
this.executeBulkEditMethod(modal, 'set_document_type', {
|
this.executeBulkOperation(modal, 'set_document_type', {
|
||||||
document_type: documentType ? documentType.id : null,
|
document_type: documentType ? documentType.id : null,
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
} else {
|
} else {
|
||||||
this.executeBulkEditMethod(null, 'set_document_type', {
|
this.executeBulkOperation(null, 'set_document_type', {
|
||||||
document_type: documentType ? documentType.id : null,
|
document_type: documentType ? documentType.id : null,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -587,12 +562,12 @@ export class BulkEditorComponent
|
|||||||
modal.componentInstance.confirmClicked
|
modal.componentInstance.confirmClicked
|
||||||
.pipe(takeUntil(this.unsubscribeNotifier))
|
.pipe(takeUntil(this.unsubscribeNotifier))
|
||||||
.subscribe(() => {
|
.subscribe(() => {
|
||||||
this.executeBulkEditMethod(modal, 'set_storage_path', {
|
this.executeBulkOperation(modal, 'set_storage_path', {
|
||||||
storage_path: storagePath ? storagePath.id : null,
|
storage_path: storagePath ? storagePath.id : null,
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
} else {
|
} else {
|
||||||
this.executeBulkEditMethod(null, 'set_storage_path', {
|
this.executeBulkOperation(null, 'set_storage_path', {
|
||||||
storage_path: storagePath ? storagePath.id : null,
|
storage_path: storagePath ? storagePath.id : null,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -649,7 +624,7 @@ export class BulkEditorComponent
|
|||||||
modal.componentInstance.confirmClicked
|
modal.componentInstance.confirmClicked
|
||||||
.pipe(takeUntil(this.unsubscribeNotifier))
|
.pipe(takeUntil(this.unsubscribeNotifier))
|
||||||
.subscribe(() => {
|
.subscribe(() => {
|
||||||
this.executeBulkEditMethod(modal, 'modify_custom_fields', {
|
this.executeBulkOperation(modal, 'modify_custom_fields', {
|
||||||
add_custom_fields: changedCustomFields.itemsToAdd.map((f) => f.id),
|
add_custom_fields: changedCustomFields.itemsToAdd.map((f) => f.id),
|
||||||
remove_custom_fields: changedCustomFields.itemsToRemove.map(
|
remove_custom_fields: changedCustomFields.itemsToRemove.map(
|
||||||
(f) => f.id
|
(f) => f.id
|
||||||
@@ -657,7 +632,7 @@ export class BulkEditorComponent
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
} else {
|
} else {
|
||||||
this.executeBulkEditMethod(null, 'modify_custom_fields', {
|
this.executeBulkOperation(null, 'modify_custom_fields', {
|
||||||
add_custom_fields: changedCustomFields.itemsToAdd.map((f) => f.id),
|
add_custom_fields: changedCustomFields.itemsToAdd.map((f) => f.id),
|
||||||
remove_custom_fields: changedCustomFields.itemsToRemove.map(
|
remove_custom_fields: changedCustomFields.itemsToRemove.map(
|
||||||
(f) => f.id
|
(f) => f.id
|
||||||
@@ -787,16 +762,10 @@ export class BulkEditorComponent
|
|||||||
.pipe(takeUntil(this.unsubscribeNotifier))
|
.pipe(takeUntil(this.unsubscribeNotifier))
|
||||||
.subscribe(() => {
|
.subscribe(() => {
|
||||||
modal.componentInstance.buttonsEnabled = false
|
modal.componentInstance.buttonsEnabled = false
|
||||||
this.executeDocumentAction(
|
this.executeBulkOperation(modal, 'delete', {})
|
||||||
modal,
|
|
||||||
this.documentService.deleteDocuments(Array.from(this.list.selected))
|
|
||||||
)
|
|
||||||
})
|
})
|
||||||
} else {
|
} else {
|
||||||
this.executeDocumentAction(
|
this.executeBulkOperation(null, 'delete', {})
|
||||||
null,
|
|
||||||
this.documentService.deleteDocuments(Array.from(this.list.selected))
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -835,12 +804,7 @@ export class BulkEditorComponent
|
|||||||
.pipe(takeUntil(this.unsubscribeNotifier))
|
.pipe(takeUntil(this.unsubscribeNotifier))
|
||||||
.subscribe(() => {
|
.subscribe(() => {
|
||||||
modal.componentInstance.buttonsEnabled = false
|
modal.componentInstance.buttonsEnabled = false
|
||||||
this.executeDocumentAction(
|
this.executeBulkOperation(modal, 'reprocess', {})
|
||||||
modal,
|
|
||||||
this.documentService.reprocessDocuments(
|
|
||||||
Array.from(this.list.selected)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -851,7 +815,7 @@ export class BulkEditorComponent
|
|||||||
modal.componentInstance.confirmClicked.subscribe(
|
modal.componentInstance.confirmClicked.subscribe(
|
||||||
({ permissions, merge }) => {
|
({ permissions, merge }) => {
|
||||||
modal.componentInstance.buttonsEnabled = false
|
modal.componentInstance.buttonsEnabled = false
|
||||||
this.executeBulkEditMethod(modal, 'set_permissions', {
|
this.executeBulkOperation(modal, 'set_permissions', {
|
||||||
...permissions,
|
...permissions,
|
||||||
merge,
|
merge,
|
||||||
})
|
})
|
||||||
@@ -874,13 +838,9 @@ export class BulkEditorComponent
|
|||||||
.pipe(takeUntil(this.unsubscribeNotifier))
|
.pipe(takeUntil(this.unsubscribeNotifier))
|
||||||
.subscribe(() => {
|
.subscribe(() => {
|
||||||
rotateDialog.buttonsEnabled = false
|
rotateDialog.buttonsEnabled = false
|
||||||
this.executeDocumentAction(
|
this.executeBulkOperation(modal, 'rotate', {
|
||||||
modal,
|
degrees: rotateDialog.degrees,
|
||||||
this.documentService.rotateDocuments(
|
})
|
||||||
Array.from(this.list.selected),
|
|
||||||
rotateDialog.degrees
|
|
||||||
)
|
|
||||||
)
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -896,22 +856,18 @@ export class BulkEditorComponent
|
|||||||
mergeDialog.confirmClicked
|
mergeDialog.confirmClicked
|
||||||
.pipe(takeUntil(this.unsubscribeNotifier))
|
.pipe(takeUntil(this.unsubscribeNotifier))
|
||||||
.subscribe(() => {
|
.subscribe(() => {
|
||||||
const args: MergeDocumentsRequest = {}
|
const args = {}
|
||||||
if (mergeDialog.metadataDocumentID > -1) {
|
if (mergeDialog.metadataDocumentID > -1) {
|
||||||
args.metadata_document_id = mergeDialog.metadataDocumentID
|
args['metadata_document_id'] = mergeDialog.metadataDocumentID
|
||||||
}
|
}
|
||||||
if (mergeDialog.deleteOriginals) {
|
if (mergeDialog.deleteOriginals) {
|
||||||
args.delete_originals = true
|
args['delete_originals'] = true
|
||||||
}
|
}
|
||||||
if (mergeDialog.archiveFallback) {
|
if (mergeDialog.archiveFallback) {
|
||||||
args.archive_fallback = true
|
args['archive_fallback'] = true
|
||||||
}
|
}
|
||||||
mergeDialog.buttonsEnabled = false
|
mergeDialog.buttonsEnabled = false
|
||||||
this.executeDocumentAction(
|
this.executeBulkOperation(modal, 'merge', args, mergeDialog.documentIDs)
|
||||||
modal,
|
|
||||||
this.documentService.mergeDocuments(mergeDialog.documentIDs, args),
|
|
||||||
{ deleteOriginals: !!args.delete_originals }
|
|
||||||
)
|
|
||||||
this.toastService.showInfo(
|
this.toastService.showInfo(
|
||||||
$localize`Merged document will be queued for consumption.`
|
$localize`Merged document will be queued for consumption.`
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -230,88 +230,6 @@ describe(`DocumentService`, () => {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should call appropriate api endpoint for delete documents', () => {
|
|
||||||
const ids = [1, 2, 3]
|
|
||||||
subscription = service.deleteDocuments(ids).subscribe()
|
|
||||||
const req = httpTestingController.expectOne(
|
|
||||||
`${environment.apiBaseUrl}${endpoint}/delete/`
|
|
||||||
)
|
|
||||||
expect(req.request.method).toEqual('POST')
|
|
||||||
expect(req.request.body).toEqual({
|
|
||||||
documents: ids,
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
it('should call appropriate api endpoint for reprocess documents', () => {
|
|
||||||
const ids = [1, 2, 3]
|
|
||||||
subscription = service.reprocessDocuments(ids).subscribe()
|
|
||||||
const req = httpTestingController.expectOne(
|
|
||||||
`${environment.apiBaseUrl}${endpoint}/reprocess/`
|
|
||||||
)
|
|
||||||
expect(req.request.method).toEqual('POST')
|
|
||||||
expect(req.request.body).toEqual({
|
|
||||||
documents: ids,
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
it('should call appropriate api endpoint for rotate documents', () => {
|
|
||||||
const ids = [1, 2, 3]
|
|
||||||
subscription = service.rotateDocuments(ids, 90).subscribe()
|
|
||||||
const req = httpTestingController.expectOne(
|
|
||||||
`${environment.apiBaseUrl}${endpoint}/rotate/`
|
|
||||||
)
|
|
||||||
expect(req.request.method).toEqual('POST')
|
|
||||||
expect(req.request.body).toEqual({
|
|
||||||
documents: ids,
|
|
||||||
degrees: 90,
|
|
||||||
source_mode: 'latest_version',
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
it('should call appropriate api endpoint for merge documents', () => {
|
|
||||||
const ids = [1, 2, 3]
|
|
||||||
const args = { metadata_document_id: 1, delete_originals: true }
|
|
||||||
subscription = service.mergeDocuments(ids, args).subscribe()
|
|
||||||
const req = httpTestingController.expectOne(
|
|
||||||
`${environment.apiBaseUrl}${endpoint}/merge/`
|
|
||||||
)
|
|
||||||
expect(req.request.method).toEqual('POST')
|
|
||||||
expect(req.request.body).toEqual({
|
|
||||||
documents: ids,
|
|
||||||
metadata_document_id: 1,
|
|
||||||
delete_originals: true,
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
it('should call appropriate api endpoint for edit pdf', () => {
|
|
||||||
const ids = [1]
|
|
||||||
const args = { operations: [{ page: 1, rotate: 90, doc: 0 }] }
|
|
||||||
subscription = service.editPdfDocuments(ids, args).subscribe()
|
|
||||||
const req = httpTestingController.expectOne(
|
|
||||||
`${environment.apiBaseUrl}${endpoint}/edit_pdf/`
|
|
||||||
)
|
|
||||||
expect(req.request.method).toEqual('POST')
|
|
||||||
expect(req.request.body).toEqual({
|
|
||||||
documents: ids,
|
|
||||||
operations: [{ page: 1, rotate: 90, doc: 0 }],
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
it('should call appropriate api endpoint for remove password', () => {
|
|
||||||
const ids = [1]
|
|
||||||
const args = { password: 'secret', update_document: true }
|
|
||||||
subscription = service.removePasswordDocuments(ids, args).subscribe()
|
|
||||||
const req = httpTestingController.expectOne(
|
|
||||||
`${environment.apiBaseUrl}${endpoint}/remove_password/`
|
|
||||||
)
|
|
||||||
expect(req.request.method).toEqual('POST')
|
|
||||||
expect(req.request.body).toEqual({
|
|
||||||
documents: ids,
|
|
||||||
password: 'secret',
|
|
||||||
update_document: true,
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
it('should return the correct preview URL for a single document', () => {
|
it('should return the correct preview URL for a single document', () => {
|
||||||
let url = service.getPreviewUrl(documents[0].id)
|
let url = service.getPreviewUrl(documents[0].id)
|
||||||
expect(url).toEqual(
|
expect(url).toEqual(
|
||||||
|
|||||||
@@ -42,45 +42,6 @@ export enum BulkEditSourceMode {
|
|||||||
EXPLICIT_SELECTION = 'explicit_selection',
|
EXPLICIT_SELECTION = 'explicit_selection',
|
||||||
}
|
}
|
||||||
|
|
||||||
export type DocumentBulkEditMethod =
|
|
||||||
| 'set_correspondent'
|
|
||||||
| 'set_document_type'
|
|
||||||
| 'set_storage_path'
|
|
||||||
| 'add_tag'
|
|
||||||
| 'remove_tag'
|
|
||||||
| 'modify_tags'
|
|
||||||
| 'modify_custom_fields'
|
|
||||||
| 'set_permissions'
|
|
||||||
|
|
||||||
export interface MergeDocumentsRequest {
|
|
||||||
metadata_document_id?: number
|
|
||||||
delete_originals?: boolean
|
|
||||||
archive_fallback?: boolean
|
|
||||||
source_mode?: BulkEditSourceMode
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface EditPdfOperation {
|
|
||||||
page: number
|
|
||||||
rotate?: number
|
|
||||||
doc?: number
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface EditPdfDocumentsRequest {
|
|
||||||
operations: EditPdfOperation[]
|
|
||||||
delete_original?: boolean
|
|
||||||
update_document?: boolean
|
|
||||||
include_metadata?: boolean
|
|
||||||
source_mode?: BulkEditSourceMode
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface RemovePasswordDocumentsRequest {
|
|
||||||
password: string
|
|
||||||
update_document?: boolean
|
|
||||||
delete_original?: boolean
|
|
||||||
include_metadata?: boolean
|
|
||||||
source_mode?: BulkEditSourceMode
|
|
||||||
}
|
|
||||||
|
|
||||||
@Injectable({
|
@Injectable({
|
||||||
providedIn: 'root',
|
providedIn: 'root',
|
||||||
})
|
})
|
||||||
@@ -338,7 +299,7 @@ export class DocumentService extends AbstractPaperlessService<Document> {
|
|||||||
return this.http.get<DocumentMetadata>(url.toString())
|
return this.http.get<DocumentMetadata>(url.toString())
|
||||||
}
|
}
|
||||||
|
|
||||||
bulkEdit(ids: number[], method: DocumentBulkEditMethod, args: any) {
|
bulkEdit(ids: number[], method: string, args: any) {
|
||||||
return this.http.post(this.getResourceUrl(null, 'bulk_edit'), {
|
return this.http.post(this.getResourceUrl(null, 'bulk_edit'), {
|
||||||
documents: ids,
|
documents: ids,
|
||||||
method: method,
|
method: method,
|
||||||
@@ -346,54 +307,6 @@ export class DocumentService extends AbstractPaperlessService<Document> {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
deleteDocuments(ids: number[]) {
|
|
||||||
return this.http.post(this.getResourceUrl(null, 'delete'), {
|
|
||||||
documents: ids,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
reprocessDocuments(ids: number[]) {
|
|
||||||
return this.http.post(this.getResourceUrl(null, 'reprocess'), {
|
|
||||||
documents: ids,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
rotateDocuments(
|
|
||||||
ids: number[],
|
|
||||||
degrees: number,
|
|
||||||
sourceMode: BulkEditSourceMode = BulkEditSourceMode.LATEST_VERSION
|
|
||||||
) {
|
|
||||||
return this.http.post(this.getResourceUrl(null, 'rotate'), {
|
|
||||||
documents: ids,
|
|
||||||
degrees,
|
|
||||||
source_mode: sourceMode,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
mergeDocuments(ids: number[], request: MergeDocumentsRequest = {}) {
|
|
||||||
return this.http.post(this.getResourceUrl(null, 'merge'), {
|
|
||||||
documents: ids,
|
|
||||||
...request,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
editPdfDocuments(ids: number[], request: EditPdfDocumentsRequest) {
|
|
||||||
return this.http.post(this.getResourceUrl(null, 'edit_pdf'), {
|
|
||||||
documents: ids,
|
|
||||||
...request,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
removePasswordDocuments(
|
|
||||||
ids: number[],
|
|
||||||
request: RemovePasswordDocumentsRequest
|
|
||||||
) {
|
|
||||||
return this.http.post(this.getResourceUrl(null, 'remove_password'), {
|
|
||||||
documents: ids,
|
|
||||||
...request,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
getSelectionData(ids: number[]): Observable<SelectionData> {
|
getSelectionData(ids: number[]): Observable<SelectionData> {
|
||||||
return this.http.post<SelectionData>(
|
return this.http.post<SelectionData>(
|
||||||
this.getResourceUrl(null, 'selection_data'),
|
this.getResourceUrl(null, 'selection_data'),
|
||||||
|
|||||||
@@ -62,7 +62,7 @@ export function hslToRgb(h, s, l) {
|
|||||||
* @return Array The HSL representation
|
* @return Array The HSL representation
|
||||||
*/
|
*/
|
||||||
export function rgbToHsl(r, g, b) {
|
export function rgbToHsl(r, g, b) {
|
||||||
;((r /= 255), (g /= 255), (b /= 255))
|
;(r /= 255), (g /= 255), (b /= 255)
|
||||||
var max = Math.max(r, g, b),
|
var max = Math.max(r, g, b),
|
||||||
min = Math.min(r, g, b)
|
min = Math.min(r, g, b)
|
||||||
var h,
|
var h,
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
export const environment = {
|
export const environment = {
|
||||||
production: false,
|
production: false,
|
||||||
apiBaseUrl: 'http://localhost:8000/api/',
|
apiBaseUrl: 'http://localhost:8000/api/',
|
||||||
apiVersion: '10',
|
apiVersion: '9',
|
||||||
appTitle: 'Paperless-ngx',
|
appTitle: 'Paperless-ngx',
|
||||||
tag: 'dev',
|
tag: 'dev',
|
||||||
version: 'DEVELOPMENT',
|
version: 'DEVELOPMENT',
|
||||||
|
|||||||
@@ -304,7 +304,7 @@ class PaperlessCommand(RichCommand):
|
|||||||
|
|
||||||
Progress output is directed to stderr to match the convention that
|
Progress output is directed to stderr to match the convention that
|
||||||
progress bars are transient UI feedback, not command output. This
|
progress bars are transient UI feedback, not command output. This
|
||||||
mirrors the convention that progress bars are transient UI feedback and prevents progress bar rendering
|
mirrors tqdm's default behavior and prevents progress bar rendering
|
||||||
from interfering with stdout-based assertions in tests or piped
|
from interfering with stdout-based assertions in tests or piped
|
||||||
command output.
|
command output.
|
||||||
|
|
||||||
|
|||||||
@@ -17,7 +17,6 @@ class Command(PaperlessCommand):
|
|||||||
"modified) after their initial import."
|
"modified) after their initial import."
|
||||||
)
|
)
|
||||||
|
|
||||||
supports_progress_bar = True
|
|
||||||
supports_multiprocessing = True
|
supports_multiprocessing = True
|
||||||
|
|
||||||
def add_arguments(self, parser):
|
def add_arguments(self, parser):
|
||||||
|
|||||||
@@ -3,10 +3,12 @@ import json
|
|||||||
import os
|
import os
|
||||||
import shutil
|
import shutil
|
||||||
import tempfile
|
import tempfile
|
||||||
|
from itertools import chain
|
||||||
from itertools import islice
|
from itertools import islice
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import TYPE_CHECKING
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
|
import tqdm
|
||||||
from allauth.mfa.models import Authenticator
|
from allauth.mfa.models import Authenticator
|
||||||
from allauth.socialaccount.models import SocialAccount
|
from allauth.socialaccount.models import SocialAccount
|
||||||
from allauth.socialaccount.models import SocialApp
|
from allauth.socialaccount.models import SocialApp
|
||||||
@@ -17,6 +19,7 @@ from django.contrib.auth.models import Permission
|
|||||||
from django.contrib.auth.models import User
|
from django.contrib.auth.models import User
|
||||||
from django.contrib.contenttypes.models import ContentType
|
from django.contrib.contenttypes.models import ContentType
|
||||||
from django.core import serializers
|
from django.core import serializers
|
||||||
|
from django.core.management.base import BaseCommand
|
||||||
from django.core.management.base import CommandError
|
from django.core.management.base import CommandError
|
||||||
from django.core.serializers.json import DjangoJSONEncoder
|
from django.core.serializers.json import DjangoJSONEncoder
|
||||||
from django.db import transaction
|
from django.db import transaction
|
||||||
@@ -35,7 +38,6 @@ if settings.AUDIT_LOG_ENABLED:
|
|||||||
|
|
||||||
from documents.file_handling import delete_empty_directories
|
from documents.file_handling import delete_empty_directories
|
||||||
from documents.file_handling import generate_filename
|
from documents.file_handling import generate_filename
|
||||||
from documents.management.commands.base import PaperlessCommand
|
|
||||||
from documents.management.commands.mixins import CryptMixin
|
from documents.management.commands.mixins import CryptMixin
|
||||||
from documents.models import Correspondent
|
from documents.models import Correspondent
|
||||||
from documents.models import CustomField
|
from documents.models import CustomField
|
||||||
@@ -79,99 +81,14 @@ def serialize_queryset_batched(
|
|||||||
yield serializers.serialize("python", chunk)
|
yield serializers.serialize("python", chunk)
|
||||||
|
|
||||||
|
|
||||||
class StreamingManifestWriter:
|
class Command(CryptMixin, BaseCommand):
|
||||||
"""Incrementally writes a JSON array to a file, one record at a time.
|
|
||||||
|
|
||||||
Writes to <target>.tmp first; on close(), optionally BLAKE2b-compares
|
|
||||||
with the existing file (--compare-json) and renames or discards accordingly.
|
|
||||||
On exception, discard() deletes the tmp file and leaves the original intact.
|
|
||||||
"""
|
|
||||||
|
|
||||||
def __init__(
|
|
||||||
self,
|
|
||||||
path: Path,
|
|
||||||
*,
|
|
||||||
compare_json: bool = False,
|
|
||||||
files_in_export_dir: "set[Path] | None" = None,
|
|
||||||
) -> None:
|
|
||||||
self._path = path.resolve()
|
|
||||||
self._tmp_path = self._path.with_suffix(self._path.suffix + ".tmp")
|
|
||||||
self._compare_json = compare_json
|
|
||||||
self._files_in_export_dir: set[Path] = (
|
|
||||||
files_in_export_dir if files_in_export_dir is not None else set()
|
|
||||||
)
|
|
||||||
self._file = None
|
|
||||||
self._first = True
|
|
||||||
|
|
||||||
def open(self) -> None:
|
|
||||||
self._path.parent.mkdir(parents=True, exist_ok=True)
|
|
||||||
self._file = self._tmp_path.open("w", encoding="utf-8")
|
|
||||||
self._file.write("[")
|
|
||||||
self._first = True
|
|
||||||
|
|
||||||
def write_record(self, record: dict) -> None:
|
|
||||||
if not self._first:
|
|
||||||
self._file.write(",\n")
|
|
||||||
else:
|
|
||||||
self._first = False
|
|
||||||
self._file.write(
|
|
||||||
json.dumps(record, cls=DjangoJSONEncoder, indent=2, ensure_ascii=False),
|
|
||||||
)
|
|
||||||
|
|
||||||
def write_batch(self, records: list[dict]) -> None:
|
|
||||||
for record in records:
|
|
||||||
self.write_record(record)
|
|
||||||
|
|
||||||
def close(self) -> None:
|
|
||||||
if self._file is None:
|
|
||||||
return
|
|
||||||
self._file.write("\n]")
|
|
||||||
self._file.close()
|
|
||||||
self._file = None
|
|
||||||
self._finalize()
|
|
||||||
|
|
||||||
def discard(self) -> None:
|
|
||||||
if self._file is not None:
|
|
||||||
self._file.close()
|
|
||||||
self._file = None
|
|
||||||
if self._tmp_path.exists():
|
|
||||||
self._tmp_path.unlink()
|
|
||||||
|
|
||||||
def _finalize(self) -> None:
|
|
||||||
"""Compare with existing file (if --compare-json) then rename or discard tmp."""
|
|
||||||
if self._path in self._files_in_export_dir:
|
|
||||||
self._files_in_export_dir.remove(self._path)
|
|
||||||
if self._compare_json:
|
|
||||||
existing_hash = hashlib.blake2b(self._path.read_bytes()).hexdigest()
|
|
||||||
new_hash = hashlib.blake2b(self._tmp_path.read_bytes()).hexdigest()
|
|
||||||
if existing_hash == new_hash:
|
|
||||||
self._tmp_path.unlink()
|
|
||||||
return
|
|
||||||
self._tmp_path.rename(self._path)
|
|
||||||
|
|
||||||
def __enter__(self) -> "StreamingManifestWriter":
|
|
||||||
self.open()
|
|
||||||
return self
|
|
||||||
|
|
||||||
def __exit__(self, exc_type, exc_val, exc_tb) -> None:
|
|
||||||
if exc_type is not None:
|
|
||||||
self.discard()
|
|
||||||
else:
|
|
||||||
self.close()
|
|
||||||
|
|
||||||
|
|
||||||
class Command(CryptMixin, PaperlessCommand):
|
|
||||||
help = (
|
help = (
|
||||||
"Decrypt and rename all files in our collection into a given target "
|
"Decrypt and rename all files in our collection into a given target "
|
||||||
"directory. And include a manifest file containing document data for "
|
"directory. And include a manifest file containing document data for "
|
||||||
"easy import."
|
"easy import."
|
||||||
)
|
)
|
||||||
|
|
||||||
supports_progress_bar = True
|
|
||||||
supports_multiprocessing = False
|
|
||||||
|
|
||||||
def add_arguments(self, parser) -> None:
|
def add_arguments(self, parser) -> None:
|
||||||
super().add_arguments(parser)
|
|
||||||
parser.add_argument("target")
|
parser.add_argument("target")
|
||||||
|
|
||||||
parser.add_argument(
|
parser.add_argument(
|
||||||
@@ -278,6 +195,13 @@ class Command(CryptMixin, PaperlessCommand):
|
|||||||
help="If set, only the database will be imported, not files",
|
help="If set, only the database will be imported, not files",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
parser.add_argument(
|
||||||
|
"--no-progress-bar",
|
||||||
|
default=False,
|
||||||
|
action="store_true",
|
||||||
|
help="If set, the progress bar will not be shown",
|
||||||
|
)
|
||||||
|
|
||||||
parser.add_argument(
|
parser.add_argument(
|
||||||
"--passphrase",
|
"--passphrase",
|
||||||
help="If provided, is used to encrypt sensitive data in the export",
|
help="If provided, is used to encrypt sensitive data in the export",
|
||||||
@@ -306,6 +230,7 @@ class Command(CryptMixin, PaperlessCommand):
|
|||||||
self.no_thumbnail: bool = options["no_thumbnail"]
|
self.no_thumbnail: bool = options["no_thumbnail"]
|
||||||
self.zip_export: bool = options["zip"]
|
self.zip_export: bool = options["zip"]
|
||||||
self.data_only: bool = options["data_only"]
|
self.data_only: bool = options["data_only"]
|
||||||
|
self.no_progress_bar: bool = options["no_progress_bar"]
|
||||||
self.passphrase: str | None = options.get("passphrase")
|
self.passphrase: str | None = options.get("passphrase")
|
||||||
self.batch_size: int = options["batch_size"]
|
self.batch_size: int = options["batch_size"]
|
||||||
|
|
||||||
@@ -397,85 +322,95 @@ class Command(CryptMixin, PaperlessCommand):
|
|||||||
if settings.AUDIT_LOG_ENABLED:
|
if settings.AUDIT_LOG_ENABLED:
|
||||||
manifest_key_to_object_query["log_entries"] = LogEntry.objects.all()
|
manifest_key_to_object_query["log_entries"] = LogEntry.objects.all()
|
||||||
|
|
||||||
# Crypto setup before streaming begins
|
with transaction.atomic():
|
||||||
if self.passphrase:
|
manifest_dict = {}
|
||||||
self.setup_crypto(passphrase=self.passphrase)
|
|
||||||
elif MailAccount.objects.count() > 0 or SocialToken.objects.count() > 0:
|
|
||||||
self.stdout.write(
|
|
||||||
self.style.NOTICE(
|
|
||||||
"No passphrase was given, sensitive fields will be in plaintext",
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
document_manifest: list[dict] = []
|
# Build an overall manifest
|
||||||
manifest_path = (self.target / "manifest.json").resolve()
|
for key, object_query in manifest_key_to_object_query.items():
|
||||||
|
manifest_dict[key] = list(
|
||||||
with StreamingManifestWriter(
|
chain.from_iterable(
|
||||||
manifest_path,
|
serialize_queryset_batched(
|
||||||
compare_json=self.compare_json,
|
object_query,
|
||||||
files_in_export_dir=self.files_in_export_dir,
|
|
||||||
) as writer:
|
|
||||||
with transaction.atomic():
|
|
||||||
for key, qs in manifest_key_to_object_query.items():
|
|
||||||
if key == "documents":
|
|
||||||
# Accumulate for file-copy loop; written to manifest after
|
|
||||||
for batch in serialize_queryset_batched(
|
|
||||||
qs,
|
|
||||||
batch_size=self.batch_size,
|
batch_size=self.batch_size,
|
||||||
):
|
),
|
||||||
for record in batch:
|
),
|
||||||
self._encrypt_record_inline(record)
|
|
||||||
document_manifest.extend(batch)
|
|
||||||
elif self.split_manifest and key in (
|
|
||||||
"notes",
|
|
||||||
"custom_field_instances",
|
|
||||||
):
|
|
||||||
# Written per-document in _write_split_manifest
|
|
||||||
pass
|
|
||||||
else:
|
|
||||||
for batch in serialize_queryset_batched(
|
|
||||||
qs,
|
|
||||||
batch_size=self.batch_size,
|
|
||||||
):
|
|
||||||
for record in batch:
|
|
||||||
self._encrypt_record_inline(record)
|
|
||||||
writer.write_batch(batch)
|
|
||||||
|
|
||||||
document_map: dict[int, Document] = {
|
|
||||||
d.pk: d for d in Document.objects.order_by("id")
|
|
||||||
}
|
|
||||||
|
|
||||||
# 3. Export files from each document
|
|
||||||
for index, document_dict in enumerate(
|
|
||||||
self.track(
|
|
||||||
document_manifest,
|
|
||||||
description="Exporting documents...",
|
|
||||||
total=len(document_manifest),
|
|
||||||
),
|
|
||||||
):
|
|
||||||
document = document_map[document_dict["pk"]]
|
|
||||||
|
|
||||||
# 3.1. generate a unique filename
|
|
||||||
base_name = self.generate_base_name(document)
|
|
||||||
|
|
||||||
# 3.2. write filenames into manifest
|
|
||||||
original_target, thumbnail_target, archive_target = (
|
|
||||||
self.generate_document_targets(document, base_name, document_dict)
|
|
||||||
)
|
)
|
||||||
|
|
||||||
# 3.3. write files to target folder
|
self.encrypt_secret_fields(manifest_dict)
|
||||||
if not self.data_only:
|
|
||||||
self.copy_document_files(
|
|
||||||
document,
|
|
||||||
original_target,
|
|
||||||
thumbnail_target,
|
|
||||||
archive_target,
|
|
||||||
)
|
|
||||||
|
|
||||||
if self.split_manifest:
|
# These are treated specially and included in the per-document manifest
|
||||||
self._write_split_manifest(document_dict, document, base_name)
|
# if that setting is enabled. Otherwise, they are just exported to the bulk
|
||||||
else:
|
# manifest
|
||||||
writer.write_record(document_dict)
|
document_map: dict[int, Document] = {
|
||||||
|
d.pk: d for d in manifest_key_to_object_query["documents"]
|
||||||
|
}
|
||||||
|
document_manifest = manifest_dict["documents"]
|
||||||
|
|
||||||
|
# 3. Export files from each document
|
||||||
|
for index, document_dict in tqdm.tqdm(
|
||||||
|
enumerate(document_manifest),
|
||||||
|
total=len(document_manifest),
|
||||||
|
disable=self.no_progress_bar,
|
||||||
|
):
|
||||||
|
document = document_map[document_dict["pk"]]
|
||||||
|
|
||||||
|
# 3.1. generate a unique filename
|
||||||
|
base_name = self.generate_base_name(document)
|
||||||
|
|
||||||
|
# 3.2. write filenames into manifest
|
||||||
|
original_target, thumbnail_target, archive_target = (
|
||||||
|
self.generate_document_targets(document, base_name, document_dict)
|
||||||
|
)
|
||||||
|
|
||||||
|
# 3.3. write files to target folder
|
||||||
|
if not self.data_only:
|
||||||
|
self.copy_document_files(
|
||||||
|
document,
|
||||||
|
original_target,
|
||||||
|
thumbnail_target,
|
||||||
|
archive_target,
|
||||||
|
)
|
||||||
|
|
||||||
|
if self.split_manifest:
|
||||||
|
manifest_name = base_name.with_name(f"{base_name.stem}-manifest.json")
|
||||||
|
if self.use_folder_prefix:
|
||||||
|
manifest_name = Path("json") / manifest_name
|
||||||
|
manifest_name = (self.target / manifest_name).resolve()
|
||||||
|
manifest_name.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
content = [document_manifest[index]]
|
||||||
|
content += list(
|
||||||
|
filter(
|
||||||
|
lambda d: d["fields"]["document"] == document_dict["pk"],
|
||||||
|
manifest_dict["notes"],
|
||||||
|
),
|
||||||
|
)
|
||||||
|
content += list(
|
||||||
|
filter(
|
||||||
|
lambda d: d["fields"]["document"] == document_dict["pk"],
|
||||||
|
manifest_dict["custom_field_instances"],
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
self.check_and_write_json(
|
||||||
|
content,
|
||||||
|
manifest_name,
|
||||||
|
)
|
||||||
|
|
||||||
|
# These were exported already
|
||||||
|
if self.split_manifest:
|
||||||
|
del manifest_dict["documents"]
|
||||||
|
del manifest_dict["notes"]
|
||||||
|
del manifest_dict["custom_field_instances"]
|
||||||
|
|
||||||
|
# 4.1 write primary manifest to target folder
|
||||||
|
manifest = []
|
||||||
|
for key, item in manifest_dict.items():
|
||||||
|
manifest.extend(item)
|
||||||
|
manifest_path = (self.target / "manifest.json").resolve()
|
||||||
|
self.check_and_write_json(
|
||||||
|
manifest,
|
||||||
|
manifest_path,
|
||||||
|
)
|
||||||
|
|
||||||
# 4.2 write version information to target folder
|
# 4.2 write version information to target folder
|
||||||
extra_metadata_path = (self.target / "metadata.json").resolve()
|
extra_metadata_path = (self.target / "metadata.json").resolve()
|
||||||
@@ -597,42 +532,6 @@ class Command(CryptMixin, PaperlessCommand):
|
|||||||
archive_target,
|
archive_target,
|
||||||
)
|
)
|
||||||
|
|
||||||
def _encrypt_record_inline(self, record: dict) -> None:
|
|
||||||
"""Encrypt sensitive fields in a single record, if passphrase is set."""
|
|
||||||
if not self.passphrase:
|
|
||||||
return
|
|
||||||
fields = self.CRYPT_FIELDS_BY_MODEL.get(record.get("model", ""))
|
|
||||||
if fields:
|
|
||||||
for field in fields:
|
|
||||||
if record["fields"].get(field):
|
|
||||||
record["fields"][field] = self.encrypt_string(
|
|
||||||
value=record["fields"][field],
|
|
||||||
)
|
|
||||||
|
|
||||||
def _write_split_manifest(
|
|
||||||
self,
|
|
||||||
document_dict: dict,
|
|
||||||
document: Document,
|
|
||||||
base_name: Path,
|
|
||||||
) -> None:
|
|
||||||
"""Write per-document manifest file for --split-manifest mode."""
|
|
||||||
content = [document_dict]
|
|
||||||
content.extend(
|
|
||||||
serializers.serialize("python", Note.objects.filter(document=document)),
|
|
||||||
)
|
|
||||||
content.extend(
|
|
||||||
serializers.serialize(
|
|
||||||
"python",
|
|
||||||
CustomFieldInstance.objects.filter(document=document),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
manifest_name = base_name.with_name(f"{base_name.stem}-manifest.json")
|
|
||||||
if self.use_folder_prefix:
|
|
||||||
manifest_name = Path("json") / manifest_name
|
|
||||||
manifest_name = (self.target / manifest_name).resolve()
|
|
||||||
manifest_name.parent.mkdir(parents=True, exist_ok=True)
|
|
||||||
self.check_and_write_json(content, manifest_name)
|
|
||||||
|
|
||||||
def check_and_write_json(
|
def check_and_write_json(
|
||||||
self,
|
self,
|
||||||
content: list[dict] | dict,
|
content: list[dict] | dict,
|
||||||
@@ -650,14 +549,14 @@ class Command(CryptMixin, PaperlessCommand):
|
|||||||
if target in self.files_in_export_dir:
|
if target in self.files_in_export_dir:
|
||||||
self.files_in_export_dir.remove(target)
|
self.files_in_export_dir.remove(target)
|
||||||
if self.compare_json:
|
if self.compare_json:
|
||||||
target_checksum = hashlib.blake2b(target.read_bytes()).hexdigest()
|
target_checksum = hashlib.md5(target.read_bytes()).hexdigest()
|
||||||
src_str = json.dumps(
|
src_str = json.dumps(
|
||||||
content,
|
content,
|
||||||
cls=DjangoJSONEncoder,
|
cls=DjangoJSONEncoder,
|
||||||
indent=2,
|
indent=2,
|
||||||
ensure_ascii=False,
|
ensure_ascii=False,
|
||||||
)
|
)
|
||||||
src_checksum = hashlib.blake2b(src_str.encode("utf-8")).hexdigest()
|
src_checksum = hashlib.md5(src_str.encode("utf-8")).hexdigest()
|
||||||
if src_checksum == target_checksum:
|
if src_checksum == target_checksum:
|
||||||
perform_write = False
|
perform_write = False
|
||||||
|
|
||||||
@@ -707,3 +606,28 @@ class Command(CryptMixin, PaperlessCommand):
|
|||||||
if perform_copy:
|
if perform_copy:
|
||||||
target.parent.mkdir(parents=True, exist_ok=True)
|
target.parent.mkdir(parents=True, exist_ok=True)
|
||||||
copy_file_with_basic_stats(source, target)
|
copy_file_with_basic_stats(source, target)
|
||||||
|
|
||||||
|
def encrypt_secret_fields(self, manifest: dict) -> None:
|
||||||
|
"""
|
||||||
|
Encrypts certain fields in the export. Currently limited to the mail account password
|
||||||
|
"""
|
||||||
|
|
||||||
|
if self.passphrase:
|
||||||
|
self.setup_crypto(passphrase=self.passphrase)
|
||||||
|
|
||||||
|
for crypt_config in self.CRYPT_FIELDS:
|
||||||
|
exporter_key = crypt_config["exporter_key"]
|
||||||
|
crypt_fields = crypt_config["fields"]
|
||||||
|
for manifest_record in manifest[exporter_key]:
|
||||||
|
for field in crypt_fields:
|
||||||
|
if manifest_record["fields"][field]:
|
||||||
|
manifest_record["fields"][field] = self.encrypt_string(
|
||||||
|
value=manifest_record["fields"][field],
|
||||||
|
)
|
||||||
|
|
||||||
|
elif MailAccount.objects.count() > 0 or SocialToken.objects.count() > 0:
|
||||||
|
self.stdout.write(
|
||||||
|
self.style.NOTICE(
|
||||||
|
"No passphrase was given, sensitive fields will be in plaintext",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|||||||
@@ -40,7 +40,6 @@ def _process_and_match(work: _WorkPackage) -> _WorkResult:
|
|||||||
class Command(PaperlessCommand):
|
class Command(PaperlessCommand):
|
||||||
help = "Searches for documents where the content almost matches"
|
help = "Searches for documents where the content almost matches"
|
||||||
|
|
||||||
supports_progress_bar = True
|
|
||||||
supports_multiprocessing = True
|
supports_multiprocessing = True
|
||||||
|
|
||||||
def add_arguments(self, parser):
|
def add_arguments(self, parser):
|
||||||
|
|||||||
@@ -8,13 +8,14 @@ from pathlib import Path
|
|||||||
from zipfile import ZipFile
|
from zipfile import ZipFile
|
||||||
from zipfile import is_zipfile
|
from zipfile import is_zipfile
|
||||||
|
|
||||||
import ijson
|
import tqdm
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.contrib.auth.models import Permission
|
from django.contrib.auth.models import Permission
|
||||||
from django.contrib.auth.models import User
|
from django.contrib.auth.models import User
|
||||||
from django.contrib.contenttypes.models import ContentType
|
from django.contrib.contenttypes.models import ContentType
|
||||||
from django.core.exceptions import FieldDoesNotExist
|
from django.core.exceptions import FieldDoesNotExist
|
||||||
from django.core.management import call_command
|
from django.core.management import call_command
|
||||||
|
from django.core.management.base import BaseCommand
|
||||||
from django.core.management.base import CommandError
|
from django.core.management.base import CommandError
|
||||||
from django.core.serializers.base import DeserializationError
|
from django.core.serializers.base import DeserializationError
|
||||||
from django.db import IntegrityError
|
from django.db import IntegrityError
|
||||||
@@ -24,7 +25,6 @@ from django.db.models.signals import post_save
|
|||||||
from filelock import FileLock
|
from filelock import FileLock
|
||||||
|
|
||||||
from documents.file_handling import create_source_path_directory
|
from documents.file_handling import create_source_path_directory
|
||||||
from documents.management.commands.base import PaperlessCommand
|
|
||||||
from documents.management.commands.mixins import CryptMixin
|
from documents.management.commands.mixins import CryptMixin
|
||||||
from documents.models import Correspondent
|
from documents.models import Correspondent
|
||||||
from documents.models import CustomField
|
from documents.models import CustomField
|
||||||
@@ -33,6 +33,7 @@ from documents.models import Document
|
|||||||
from documents.models import DocumentType
|
from documents.models import DocumentType
|
||||||
from documents.models import Note
|
from documents.models import Note
|
||||||
from documents.models import Tag
|
from documents.models import Tag
|
||||||
|
from documents.parsers import run_convert
|
||||||
from documents.settings import EXPORTER_ARCHIVE_NAME
|
from documents.settings import EXPORTER_ARCHIVE_NAME
|
||||||
from documents.settings import EXPORTER_CRYPTO_SETTINGS_NAME
|
from documents.settings import EXPORTER_CRYPTO_SETTINGS_NAME
|
||||||
from documents.settings import EXPORTER_FILE_NAME
|
from documents.settings import EXPORTER_FILE_NAME
|
||||||
@@ -46,15 +47,6 @@ if settings.AUDIT_LOG_ENABLED:
|
|||||||
from auditlog.registry import auditlog
|
from auditlog.registry import auditlog
|
||||||
|
|
||||||
|
|
||||||
def iter_manifest_records(path: Path) -> Generator[dict, None, None]:
|
|
||||||
"""Yield records one at a time from a manifest JSON array via ijson."""
|
|
||||||
try:
|
|
||||||
with path.open("rb") as f:
|
|
||||||
yield from ijson.items(f, "item")
|
|
||||||
except ijson.JSONError as e:
|
|
||||||
raise CommandError(f"Failed to parse manifest file {path}: {e}") from e
|
|
||||||
|
|
||||||
|
|
||||||
@contextmanager
|
@contextmanager
|
||||||
def disable_signal(sig, receiver, sender, *, weak: bool | None = None) -> Generator:
|
def disable_signal(sig, receiver, sender, *, weak: bool | None = None) -> Generator:
|
||||||
try:
|
try:
|
||||||
@@ -65,19 +57,22 @@ def disable_signal(sig, receiver, sender, *, weak: bool | None = None) -> Genera
|
|||||||
sig.connect(receiver=receiver, sender=sender, **kwargs)
|
sig.connect(receiver=receiver, sender=sender, **kwargs)
|
||||||
|
|
||||||
|
|
||||||
class Command(CryptMixin, PaperlessCommand):
|
class Command(CryptMixin, BaseCommand):
|
||||||
help = (
|
help = (
|
||||||
"Using a manifest.json file, load the data from there, and import the "
|
"Using a manifest.json file, load the data from there, and import the "
|
||||||
"documents it refers to."
|
"documents it refers to."
|
||||||
)
|
)
|
||||||
|
|
||||||
supports_progress_bar = True
|
|
||||||
supports_multiprocessing = False
|
|
||||||
|
|
||||||
def add_arguments(self, parser) -> None:
|
def add_arguments(self, parser) -> None:
|
||||||
super().add_arguments(parser)
|
|
||||||
parser.add_argument("source")
|
parser.add_argument("source")
|
||||||
|
|
||||||
|
parser.add_argument(
|
||||||
|
"--no-progress-bar",
|
||||||
|
default=False,
|
||||||
|
action="store_true",
|
||||||
|
help="If set, the progress bar will not be shown",
|
||||||
|
)
|
||||||
|
|
||||||
parser.add_argument(
|
parser.add_argument(
|
||||||
"--data-only",
|
"--data-only",
|
||||||
default=False,
|
default=False,
|
||||||
@@ -152,9 +147,14 @@ class Command(CryptMixin, PaperlessCommand):
|
|||||||
Loads manifest data from the various JSON files for parsing and loading the database
|
Loads manifest data from the various JSON files for parsing and loading the database
|
||||||
"""
|
"""
|
||||||
main_manifest_path: Path = self.source / "manifest.json"
|
main_manifest_path: Path = self.source / "manifest.json"
|
||||||
|
|
||||||
|
with main_manifest_path.open() as infile:
|
||||||
|
self.manifest = json.load(infile)
|
||||||
self.manifest_paths.append(main_manifest_path)
|
self.manifest_paths.append(main_manifest_path)
|
||||||
|
|
||||||
for file in Path(self.source).glob("**/*-manifest.json"):
|
for file in Path(self.source).glob("**/*-manifest.json"):
|
||||||
|
with file.open() as infile:
|
||||||
|
self.manifest += json.load(infile)
|
||||||
self.manifest_paths.append(file)
|
self.manifest_paths.append(file)
|
||||||
|
|
||||||
def load_metadata(self) -> None:
|
def load_metadata(self) -> None:
|
||||||
@@ -231,10 +231,12 @@ class Command(CryptMixin, PaperlessCommand):
|
|||||||
|
|
||||||
self.source = Path(options["source"]).resolve()
|
self.source = Path(options["source"]).resolve()
|
||||||
self.data_only: bool = options["data_only"]
|
self.data_only: bool = options["data_only"]
|
||||||
|
self.no_progress_bar: bool = options["no_progress_bar"]
|
||||||
self.passphrase: str | None = options.get("passphrase")
|
self.passphrase: str | None = options.get("passphrase")
|
||||||
self.version: str | None = None
|
self.version: str | None = None
|
||||||
self.salt: str | None = None
|
self.salt: str | None = None
|
||||||
self.manifest_paths = []
|
self.manifest_paths = []
|
||||||
|
self.manifest = []
|
||||||
|
|
||||||
# Create a temporary directory for extracting a zip file into it, even if supplied source is no zip file to keep code cleaner.
|
# Create a temporary directory for extracting a zip file into it, even if supplied source is no zip file to keep code cleaner.
|
||||||
with tempfile.TemporaryDirectory() as tmp_dir:
|
with tempfile.TemporaryDirectory() as tmp_dir:
|
||||||
@@ -294,9 +296,6 @@ class Command(CryptMixin, PaperlessCommand):
|
|||||||
else:
|
else:
|
||||||
self.stdout.write(self.style.NOTICE("Data only import completed"))
|
self.stdout.write(self.style.NOTICE("Data only import completed"))
|
||||||
|
|
||||||
for tmp in getattr(self, "_decrypted_tmp_paths", []):
|
|
||||||
tmp.unlink(missing_ok=True)
|
|
||||||
|
|
||||||
self.stdout.write("Updating search index...")
|
self.stdout.write("Updating search index...")
|
||||||
call_command(
|
call_command(
|
||||||
"document_index",
|
"document_index",
|
||||||
@@ -349,12 +348,11 @@ class Command(CryptMixin, PaperlessCommand):
|
|||||||
) from e
|
) from e
|
||||||
|
|
||||||
self.stdout.write("Checking the manifest")
|
self.stdout.write("Checking the manifest")
|
||||||
for manifest_path in self.manifest_paths:
|
for record in self.manifest:
|
||||||
for record in iter_manifest_records(manifest_path):
|
# Only check if the document files exist if this is not data only
|
||||||
# Only check if the document files exist if this is not data only
|
# We don't care about documents for a data only import
|
||||||
# We don't care about documents for a data only import
|
if not self.data_only and record["model"] == "documents.document":
|
||||||
if not self.data_only and record["model"] == "documents.document":
|
check_document_validity(record)
|
||||||
check_document_validity(record)
|
|
||||||
|
|
||||||
def _import_files_from_manifest(self) -> None:
|
def _import_files_from_manifest(self) -> None:
|
||||||
settings.ORIGINALS_DIR.mkdir(parents=True, exist_ok=True)
|
settings.ORIGINALS_DIR.mkdir(parents=True, exist_ok=True)
|
||||||
@@ -363,31 +361,23 @@ class Command(CryptMixin, PaperlessCommand):
|
|||||||
|
|
||||||
self.stdout.write("Copy files into paperless...")
|
self.stdout.write("Copy files into paperless...")
|
||||||
|
|
||||||
document_records = [
|
manifest_documents = list(
|
||||||
{
|
filter(lambda r: r["model"] == "documents.document", self.manifest),
|
||||||
"pk": record["pk"],
|
)
|
||||||
EXPORTER_FILE_NAME: record[EXPORTER_FILE_NAME],
|
|
||||||
EXPORTER_THUMBNAIL_NAME: record.get(EXPORTER_THUMBNAIL_NAME),
|
|
||||||
EXPORTER_ARCHIVE_NAME: record.get(EXPORTER_ARCHIVE_NAME),
|
|
||||||
}
|
|
||||||
for manifest_path in self.manifest_paths
|
|
||||||
for record in iter_manifest_records(manifest_path)
|
|
||||||
if record["model"] == "documents.document"
|
|
||||||
]
|
|
||||||
|
|
||||||
for record in self.track(document_records, description="Copying files..."):
|
for record in tqdm.tqdm(manifest_documents, disable=self.no_progress_bar):
|
||||||
document = Document.objects.get(pk=record["pk"])
|
document = Document.objects.get(pk=record["pk"])
|
||||||
|
|
||||||
doc_file = record[EXPORTER_FILE_NAME]
|
doc_file = record[EXPORTER_FILE_NAME]
|
||||||
document_path = self.source / doc_file
|
document_path = self.source / doc_file
|
||||||
|
|
||||||
if record[EXPORTER_THUMBNAIL_NAME]:
|
if EXPORTER_THUMBNAIL_NAME in record:
|
||||||
thumb_file = record[EXPORTER_THUMBNAIL_NAME]
|
thumb_file = record[EXPORTER_THUMBNAIL_NAME]
|
||||||
thumbnail_path = (self.source / thumb_file).resolve()
|
thumbnail_path = (self.source / thumb_file).resolve()
|
||||||
else:
|
else:
|
||||||
thumbnail_path = None
|
thumbnail_path = None
|
||||||
|
|
||||||
if record[EXPORTER_ARCHIVE_NAME]:
|
if EXPORTER_ARCHIVE_NAME in record:
|
||||||
archive_file = record[EXPORTER_ARCHIVE_NAME]
|
archive_file = record[EXPORTER_ARCHIVE_NAME]
|
||||||
archive_path = self.source / archive_file
|
archive_path = self.source / archive_file
|
||||||
else:
|
else:
|
||||||
@@ -402,10 +392,22 @@ class Command(CryptMixin, PaperlessCommand):
|
|||||||
copy_file_with_basic_stats(document_path, document.source_path)
|
copy_file_with_basic_stats(document_path, document.source_path)
|
||||||
|
|
||||||
if thumbnail_path:
|
if thumbnail_path:
|
||||||
copy_file_with_basic_stats(
|
if thumbnail_path.suffix in {".png", ".PNG"}:
|
||||||
thumbnail_path,
|
run_convert(
|
||||||
document.thumbnail_path,
|
density=300,
|
||||||
)
|
scale="500x5000>",
|
||||||
|
alpha="remove",
|
||||||
|
strip=True,
|
||||||
|
trim=False,
|
||||||
|
auto_orient=True,
|
||||||
|
input_file=f"{thumbnail_path}[0]",
|
||||||
|
output_file=str(document.thumbnail_path),
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
copy_file_with_basic_stats(
|
||||||
|
thumbnail_path,
|
||||||
|
document.thumbnail_path,
|
||||||
|
)
|
||||||
|
|
||||||
if archive_path:
|
if archive_path:
|
||||||
create_source_path_directory(document.archive_path)
|
create_source_path_directory(document.archive_path)
|
||||||
@@ -416,43 +418,33 @@ class Command(CryptMixin, PaperlessCommand):
|
|||||||
|
|
||||||
document.save()
|
document.save()
|
||||||
|
|
||||||
def _decrypt_record_if_needed(self, record: dict) -> dict:
|
|
||||||
fields = self.CRYPT_FIELDS_BY_MODEL.get(record.get("model", ""))
|
|
||||||
if fields:
|
|
||||||
for field in fields:
|
|
||||||
if record["fields"].get(field):
|
|
||||||
record["fields"][field] = self.decrypt_string(
|
|
||||||
value=record["fields"][field],
|
|
||||||
)
|
|
||||||
return record
|
|
||||||
|
|
||||||
def decrypt_secret_fields(self) -> None:
|
def decrypt_secret_fields(self) -> None:
|
||||||
"""
|
"""
|
||||||
The converse decryption of some fields out of the export before importing to database.
|
The converse decryption of some fields out of the export before importing to database
|
||||||
Streams records from each manifest path and writes decrypted content to a temp file.
|
|
||||||
"""
|
"""
|
||||||
if not self.passphrase:
|
if self.passphrase:
|
||||||
return
|
# Salt has been loaded from metadata.json at this point, so it cannot be None
|
||||||
# Salt has been loaded from metadata.json at this point, so it cannot be None
|
self.setup_crypto(passphrase=self.passphrase, salt=self.salt)
|
||||||
self.setup_crypto(passphrase=self.passphrase, salt=self.salt)
|
|
||||||
self._decrypted_tmp_paths: list[Path] = []
|
had_at_least_one_record = False
|
||||||
new_paths: list[Path] = []
|
|
||||||
for manifest_path in self.manifest_paths:
|
for crypt_config in self.CRYPT_FIELDS:
|
||||||
tmp = manifest_path.with_name(manifest_path.stem + ".decrypted.json")
|
importer_model: str = crypt_config["model_name"]
|
||||||
with tmp.open("w", encoding="utf-8") as out:
|
crypt_fields: str = crypt_config["fields"]
|
||||||
out.write("[\n")
|
for record in filter(
|
||||||
first = True
|
lambda x: x["model"] == importer_model,
|
||||||
for record in iter_manifest_records(manifest_path):
|
self.manifest,
|
||||||
if not first:
|
):
|
||||||
out.write(",\n")
|
had_at_least_one_record = True
|
||||||
json.dump(
|
for field in crypt_fields:
|
||||||
self._decrypt_record_if_needed(record),
|
if record["fields"][field]:
|
||||||
out,
|
record["fields"][field] = self.decrypt_string(
|
||||||
indent=2,
|
value=record["fields"][field],
|
||||||
ensure_ascii=False,
|
)
|
||||||
)
|
|
||||||
first = False
|
if had_at_least_one_record:
|
||||||
out.write("\n]\n")
|
# It's annoying, but the DB is loaded from the JSON directly
|
||||||
self._decrypted_tmp_paths.append(tmp)
|
# Maybe could change that in the future?
|
||||||
new_paths.append(tmp)
|
(self.source / "manifest.json").write_text(
|
||||||
self.manifest_paths = new_paths
|
json.dumps(self.manifest, indent=2, ensure_ascii=False),
|
||||||
|
)
|
||||||
|
|||||||
@@ -8,9 +8,6 @@ from documents.tasks import index_reindex
|
|||||||
class Command(PaperlessCommand):
|
class Command(PaperlessCommand):
|
||||||
help = "Manages the document index."
|
help = "Manages the document index."
|
||||||
|
|
||||||
supports_progress_bar = True
|
|
||||||
supports_multiprocessing = False
|
|
||||||
|
|
||||||
def add_arguments(self, parser):
|
def add_arguments(self, parser):
|
||||||
super().add_arguments(parser)
|
super().add_arguments(parser)
|
||||||
parser.add_argument("command", choices=["reindex", "optimize"])
|
parser.add_argument("command", choices=["reindex", "optimize"])
|
||||||
|
|||||||
@@ -7,9 +7,6 @@ from documents.tasks import llmindex_index
|
|||||||
class Command(PaperlessCommand):
|
class Command(PaperlessCommand):
|
||||||
help = "Manages the LLM-based vector index for Paperless."
|
help = "Manages the LLM-based vector index for Paperless."
|
||||||
|
|
||||||
supports_progress_bar = True
|
|
||||||
supports_multiprocessing = False
|
|
||||||
|
|
||||||
def add_arguments(self, parser: Any) -> None:
|
def add_arguments(self, parser: Any) -> None:
|
||||||
super().add_arguments(parser)
|
super().add_arguments(parser)
|
||||||
parser.add_argument("command", choices=["rebuild", "update"])
|
parser.add_argument("command", choices=["rebuild", "update"])
|
||||||
|
|||||||
@@ -7,9 +7,6 @@ from documents.models import Document
|
|||||||
class Command(PaperlessCommand):
|
class Command(PaperlessCommand):
|
||||||
help = "Rename all documents"
|
help = "Rename all documents"
|
||||||
|
|
||||||
supports_progress_bar = True
|
|
||||||
supports_multiprocessing = False
|
|
||||||
|
|
||||||
def handle(self, *args, **options):
|
def handle(self, *args, **options):
|
||||||
for document in self.track(Document.objects.all(), description="Renaming..."):
|
for document in self.track(Document.objects.all(), description="Renaming..."):
|
||||||
post_save.send(Document, instance=document, created=False)
|
post_save.send(Document, instance=document, created=False)
|
||||||
|
|||||||
@@ -180,9 +180,6 @@ class Command(PaperlessCommand):
|
|||||||
"modified) after their initial import."
|
"modified) after their initial import."
|
||||||
)
|
)
|
||||||
|
|
||||||
supports_progress_bar = True
|
|
||||||
supports_multiprocessing = False
|
|
||||||
|
|
||||||
def add_arguments(self, parser) -> None:
|
def add_arguments(self, parser) -> None:
|
||||||
super().add_arguments(parser)
|
super().add_arguments(parser)
|
||||||
parser.add_argument("-c", "--correspondent", default=False, action="store_true")
|
parser.add_argument("-c", "--correspondent", default=False, action="store_true")
|
||||||
|
|||||||
@@ -24,9 +24,6 @@ _LEVEL_STYLE: dict[int, tuple[str, str]] = {
|
|||||||
class Command(PaperlessCommand):
|
class Command(PaperlessCommand):
|
||||||
help = "This command checks your document archive for issues."
|
help = "This command checks your document archive for issues."
|
||||||
|
|
||||||
supports_progress_bar = True
|
|
||||||
supports_multiprocessing = False
|
|
||||||
|
|
||||||
def _render_results(self, messages: SanityCheckMessages) -> None:
|
def _render_results(self, messages: SanityCheckMessages) -> None:
|
||||||
"""Render sanity check results as a Rich table."""
|
"""Render sanity check results as a Rich table."""
|
||||||
|
|
||||||
|
|||||||
@@ -36,7 +36,6 @@ def _process_document(doc_id: int) -> None:
|
|||||||
class Command(PaperlessCommand):
|
class Command(PaperlessCommand):
|
||||||
help = "This will regenerate the thumbnails for all documents."
|
help = "This will regenerate the thumbnails for all documents."
|
||||||
|
|
||||||
supports_progress_bar = True
|
|
||||||
supports_multiprocessing = True
|
supports_multiprocessing = True
|
||||||
|
|
||||||
def add_arguments(self, parser) -> None:
|
def add_arguments(self, parser) -> None:
|
||||||
|
|||||||
22
src/documents/management/commands/loaddata_stdin.py
Normal file
22
src/documents/management/commands/loaddata_stdin.py
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
import sys
|
||||||
|
|
||||||
|
from django.core.management.commands.loaddata import Command as LoadDataCommand
|
||||||
|
|
||||||
|
|
||||||
|
# This class is used to migrate data between databases
|
||||||
|
# That's difficult to test
|
||||||
|
class Command(LoadDataCommand): # pragma: no cover
|
||||||
|
"""
|
||||||
|
Allow the loading of data from standard in. Sourced originally from:
|
||||||
|
https://gist.github.com/bmispelon/ad5a2c333443b3a1d051 (MIT licensed)
|
||||||
|
"""
|
||||||
|
|
||||||
|
def parse_name(self, fixture_name):
|
||||||
|
self.compression_formats["stdin"] = (lambda x, y: sys.stdin, None)
|
||||||
|
if fixture_name == "-":
|
||||||
|
return "-", "json", "stdin"
|
||||||
|
|
||||||
|
def find_fixtures(self, fixture_label):
|
||||||
|
if fixture_label == "-":
|
||||||
|
return [("-", None, "-")]
|
||||||
|
return super().find_fixtures(fixture_label)
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
import base64
|
import base64
|
||||||
import os
|
import os
|
||||||
|
from argparse import ArgumentParser
|
||||||
from typing import TypedDict
|
from typing import TypedDict
|
||||||
|
|
||||||
from cryptography.fernet import Fernet
|
from cryptography.fernet import Fernet
|
||||||
@@ -20,6 +21,25 @@ class CryptFields(TypedDict):
|
|||||||
fields: list[str]
|
fields: list[str]
|
||||||
|
|
||||||
|
|
||||||
|
class ProgressBarMixin:
|
||||||
|
"""
|
||||||
|
Many commands use a progress bar, which can be disabled
|
||||||
|
via this class
|
||||||
|
"""
|
||||||
|
|
||||||
|
def add_argument_progress_bar_mixin(self, parser: ArgumentParser) -> None:
|
||||||
|
parser.add_argument(
|
||||||
|
"--no-progress-bar",
|
||||||
|
default=False,
|
||||||
|
action="store_true",
|
||||||
|
help="If set, the progress bar will not be shown",
|
||||||
|
)
|
||||||
|
|
||||||
|
def handle_progress_bar_mixin(self, *args, **options) -> None:
|
||||||
|
self.no_progress_bar = options["no_progress_bar"]
|
||||||
|
self.use_progress_bar = not self.no_progress_bar
|
||||||
|
|
||||||
|
|
||||||
class CryptMixin:
|
class CryptMixin:
|
||||||
"""
|
"""
|
||||||
Fully based on:
|
Fully based on:
|
||||||
@@ -51,7 +71,7 @@ class CryptMixin:
|
|||||||
key_size = 32
|
key_size = 32
|
||||||
kdf_algorithm = "pbkdf2_sha256"
|
kdf_algorithm = "pbkdf2_sha256"
|
||||||
|
|
||||||
CRYPT_FIELDS: list[CryptFields] = [
|
CRYPT_FIELDS: CryptFields = [
|
||||||
{
|
{
|
||||||
"exporter_key": "mail_accounts",
|
"exporter_key": "mail_accounts",
|
||||||
"model_name": "paperless_mail.mailaccount",
|
"model_name": "paperless_mail.mailaccount",
|
||||||
@@ -69,10 +89,6 @@ class CryptMixin:
|
|||||||
],
|
],
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
# O(1) lookup for per-record encryption; derived from CRYPT_FIELDS at class definition time
|
|
||||||
CRYPT_FIELDS_BY_MODEL: dict[str, list[str]] = {
|
|
||||||
cfg["model_name"]: cfg["fields"] for cfg in CRYPT_FIELDS
|
|
||||||
}
|
|
||||||
|
|
||||||
def get_crypt_params(self) -> dict[str, dict[str, str | int]]:
|
def get_crypt_params(self) -> dict[str, dict[str, str | int]]:
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -9,9 +9,6 @@ class Command(PaperlessCommand):
|
|||||||
|
|
||||||
help = "Prunes the audit logs of objects that no longer exist."
|
help = "Prunes the audit logs of objects that no longer exist."
|
||||||
|
|
||||||
supports_progress_bar = True
|
|
||||||
supports_multiprocessing = False
|
|
||||||
|
|
||||||
def handle(self, *args, **options):
|
def handle(self, *args, **options):
|
||||||
with transaction.atomic():
|
with transaction.atomic():
|
||||||
for log_entry in self.track(
|
for log_entry in self.track(
|
||||||
|
|||||||
@@ -169,7 +169,7 @@ def match_storage_paths(document: Document, classifier: DocumentClassifier, user
|
|||||||
def matches(matching_model: MatchingModel, document: Document):
|
def matches(matching_model: MatchingModel, document: Document):
|
||||||
search_flags = 0
|
search_flags = 0
|
||||||
|
|
||||||
document_content = document.get_effective_content() or ""
|
document_content = document.content
|
||||||
|
|
||||||
# Check that match is not empty
|
# Check that match is not empty
|
||||||
if not matching_model.match.strip():
|
if not matching_model.match.strip():
|
||||||
|
|||||||
18
src/documents/migrations/0003_workflowaction_order.py
Normal file
18
src/documents/migrations/0003_workflowaction_order.py
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
# Generated by Django 5.2.9 on 2026-01-20 20:06
|
||||||
|
|
||||||
|
from django.db import migrations
|
||||||
|
from django.db import models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
dependencies = [
|
||||||
|
("documents", "0002_squashed"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="workflowaction",
|
||||||
|
name="order",
|
||||||
|
field=models.PositiveIntegerField(default=0, verbose_name="order"),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -5,7 +5,7 @@ from django.db import migrations
|
|||||||
|
|
||||||
class Migration(migrations.Migration):
|
class Migration(migrations.Migration):
|
||||||
dependencies = [
|
dependencies = [
|
||||||
("documents", "0002_squashed"),
|
("documents", "0003_workflowaction_order"),
|
||||||
]
|
]
|
||||||
|
|
||||||
operations = [
|
operations = [
|
||||||
@@ -6,7 +6,7 @@ from django.db import models
|
|||||||
|
|
||||||
class Migration(migrations.Migration):
|
class Migration(migrations.Migration):
|
||||||
dependencies = [
|
dependencies = [
|
||||||
("documents", "0003_remove_document_storage_type"),
|
("documents", "0004_remove_document_storage_type"),
|
||||||
]
|
]
|
||||||
|
|
||||||
operations = [
|
operations = [
|
||||||
@@ -6,7 +6,7 @@ from django.db import models
|
|||||||
|
|
||||||
class Migration(migrations.Migration):
|
class Migration(migrations.Migration):
|
||||||
dependencies = [
|
dependencies = [
|
||||||
("documents", "0004_workflowtrigger_filter_has_any_correspondents_and_more"),
|
("documents", "0005_workflowtrigger_filter_has_any_correspondents_and_more"),
|
||||||
]
|
]
|
||||||
|
|
||||||
operations = [
|
operations = [
|
||||||
@@ -7,7 +7,7 @@ from django.db import models
|
|||||||
|
|
||||||
class Migration(migrations.Migration):
|
class Migration(migrations.Migration):
|
||||||
dependencies = [
|
dependencies = [
|
||||||
("documents", "0005_alter_document_checksum_unique"),
|
("documents", "0006_alter_document_checksum_unique"),
|
||||||
]
|
]
|
||||||
|
|
||||||
operations = [
|
operations = [
|
||||||
@@ -46,7 +46,7 @@ def revoke_share_link_bundle_permissions(apps, schema_editor):
|
|||||||
class Migration(migrations.Migration):
|
class Migration(migrations.Migration):
|
||||||
dependencies = [
|
dependencies = [
|
||||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||||
("documents", "0006_document_content_length"),
|
("documents", "0007_document_content_length"),
|
||||||
]
|
]
|
||||||
|
|
||||||
operations = [
|
operations = [
|
||||||
@@ -6,7 +6,7 @@ from django.db import models
|
|||||||
|
|
||||||
class Migration(migrations.Migration):
|
class Migration(migrations.Migration):
|
||||||
dependencies = [
|
dependencies = [
|
||||||
("documents", "0007_sharelinkbundle"),
|
("documents", "0008_sharelinkbundle"),
|
||||||
]
|
]
|
||||||
|
|
||||||
operations = [
|
operations = [
|
||||||
@@ -7,7 +7,7 @@ from django.db import models
|
|||||||
|
|
||||||
class Migration(migrations.Migration):
|
class Migration(migrations.Migration):
|
||||||
dependencies = [
|
dependencies = [
|
||||||
("documents", "0008_workflowaction_passwords_alter_workflowaction_type"),
|
("documents", "0009_workflowaction_passwords_alter_workflowaction_type"),
|
||||||
]
|
]
|
||||||
|
|
||||||
operations = [
|
operations = [
|
||||||
@@ -7,7 +7,7 @@ from django.db import models
|
|||||||
|
|
||||||
class Migration(migrations.Migration):
|
class Migration(migrations.Migration):
|
||||||
dependencies = [
|
dependencies = [
|
||||||
("documents", "0009_alter_document_content_length"),
|
("documents", "0010_alter_document_content_length"),
|
||||||
]
|
]
|
||||||
|
|
||||||
operations = [
|
operations = [
|
||||||
@@ -6,7 +6,7 @@ from django.db import models
|
|||||||
|
|
||||||
class Migration(migrations.Migration):
|
class Migration(migrations.Migration):
|
||||||
dependencies = [
|
dependencies = [
|
||||||
("documents", "0010_optimize_integer_field_sizes"),
|
("documents", "0011_optimize_integer_field_sizes"),
|
||||||
]
|
]
|
||||||
|
|
||||||
operations = [
|
operations = [
|
||||||
@@ -7,7 +7,7 @@ from django.db import models
|
|||||||
|
|
||||||
class Migration(migrations.Migration):
|
class Migration(migrations.Migration):
|
||||||
dependencies = [
|
dependencies = [
|
||||||
("documents", "0011_alter_workflowaction_type"),
|
("documents", "0012_alter_workflowaction_type"),
|
||||||
]
|
]
|
||||||
|
|
||||||
operations = [
|
operations = [
|
||||||
@@ -6,7 +6,7 @@ from django.db import models
|
|||||||
|
|
||||||
class Migration(migrations.Migration):
|
class Migration(migrations.Migration):
|
||||||
dependencies = [
|
dependencies = [
|
||||||
("documents", "0012_document_root_document"),
|
("documents", "0013_document_root_document"),
|
||||||
]
|
]
|
||||||
|
|
||||||
operations = [
|
operations = [
|
||||||
@@ -124,7 +124,7 @@ def _restore_visibility_fields(apps, schema_editor):
|
|||||||
|
|
||||||
class Migration(migrations.Migration):
|
class Migration(migrations.Migration):
|
||||||
dependencies = [
|
dependencies = [
|
||||||
("documents", "0013_alter_paperlesstask_task_name"),
|
("documents", "0014_alter_paperlesstask_task_name"),
|
||||||
]
|
]
|
||||||
|
|
||||||
operations = [
|
operations = [
|
||||||
@@ -7,7 +7,7 @@ from django.db import models
|
|||||||
|
|
||||||
class Migration(migrations.Migration):
|
class Migration(migrations.Migration):
|
||||||
dependencies = [
|
dependencies = [
|
||||||
("documents", "0014_savedview_visibility_to_ui_settings"),
|
("documents", "0015_savedview_visibility_to_ui_settings"),
|
||||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -361,42 +361,6 @@ class Document(SoftDeleteModel, ModelWithOwner): # type: ignore[django-manager-
|
|||||||
res += f" {self.title}"
|
res += f" {self.title}"
|
||||||
return res
|
return res
|
||||||
|
|
||||||
def get_effective_content(self) -> str | None:
|
|
||||||
"""
|
|
||||||
Returns the effective content for the document.
|
|
||||||
|
|
||||||
For root documents, this is the latest version's content when available.
|
|
||||||
For version documents, this is always the document's own content.
|
|
||||||
If the queryset already annotated ``effective_content``, that value is used.
|
|
||||||
"""
|
|
||||||
if hasattr(self, "effective_content"):
|
|
||||||
return getattr(self, "effective_content")
|
|
||||||
|
|
||||||
if self.root_document_id is not None or self.pk is None:
|
|
||||||
return self.content
|
|
||||||
|
|
||||||
prefetched_cache = getattr(self, "_prefetched_objects_cache", None)
|
|
||||||
prefetched_versions = (
|
|
||||||
prefetched_cache.get("versions")
|
|
||||||
if isinstance(prefetched_cache, dict)
|
|
||||||
else None
|
|
||||||
)
|
|
||||||
if prefetched_versions:
|
|
||||||
latest_prefetched = max(prefetched_versions, key=lambda doc: doc.id)
|
|
||||||
return latest_prefetched.content
|
|
||||||
|
|
||||||
latest_version_content = (
|
|
||||||
Document.objects.filter(root_document=self)
|
|
||||||
.order_by("-id")
|
|
||||||
.values_list("content", flat=True)
|
|
||||||
.first()
|
|
||||||
)
|
|
||||||
return (
|
|
||||||
latest_version_content
|
|
||||||
if latest_version_content is not None
|
|
||||||
else self.content
|
|
||||||
)
|
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def suggestion_content(self):
|
def suggestion_content(self):
|
||||||
"""
|
"""
|
||||||
@@ -409,21 +373,15 @@ class Document(SoftDeleteModel, ModelWithOwner): # type: ignore[django-manager-
|
|||||||
This improves processing speed for large documents while keeping
|
This improves processing speed for large documents while keeping
|
||||||
enough context for accurate suggestions.
|
enough context for accurate suggestions.
|
||||||
"""
|
"""
|
||||||
effective_content = self.get_effective_content()
|
if not self.content or len(self.content) <= 1200000:
|
||||||
if not effective_content or len(effective_content) <= 1200000:
|
return self.content
|
||||||
return effective_content
|
|
||||||
else:
|
else:
|
||||||
# Use 80% from the start and 20% from the end
|
# Use 80% from the start and 20% from the end
|
||||||
# to preserve both opening and closing context.
|
# to preserve both opening and closing context.
|
||||||
head_len = 800000
|
head_len = 800000
|
||||||
tail_len = 200000
|
tail_len = 200000
|
||||||
|
|
||||||
return " ".join(
|
return " ".join((self.content[:head_len], self.content[-tail_len:]))
|
||||||
(
|
|
||||||
effective_content[:head_len],
|
|
||||||
effective_content[-tail_len:],
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def source_path(self) -> Path:
|
def source_path(self) -> Path:
|
||||||
|
|||||||
@@ -703,6 +703,15 @@ class StoragePathField(serializers.PrimaryKeyRelatedField):
|
|||||||
|
|
||||||
|
|
||||||
class CustomFieldSerializer(serializers.ModelSerializer):
|
class CustomFieldSerializer(serializers.ModelSerializer):
|
||||||
|
def __init__(self, *args, **kwargs):
|
||||||
|
context = kwargs.get("context")
|
||||||
|
self.api_version = int(
|
||||||
|
context.get("request").version
|
||||||
|
if context and context.get("request")
|
||||||
|
else settings.REST_FRAMEWORK["DEFAULT_VERSION"],
|
||||||
|
)
|
||||||
|
super().__init__(*args, **kwargs)
|
||||||
|
|
||||||
data_type = serializers.ChoiceField(
|
data_type = serializers.ChoiceField(
|
||||||
choices=CustomField.FieldDataType,
|
choices=CustomField.FieldDataType,
|
||||||
read_only=False,
|
read_only=False,
|
||||||
@@ -782,6 +791,38 @@ class CustomFieldSerializer(serializers.ModelSerializer):
|
|||||||
)
|
)
|
||||||
return super().validate(attrs)
|
return super().validate(attrs)
|
||||||
|
|
||||||
|
def to_internal_value(self, data):
|
||||||
|
ret = super().to_internal_value(data)
|
||||||
|
|
||||||
|
if (
|
||||||
|
self.api_version < 7
|
||||||
|
and ret.get("data_type", "") == CustomField.FieldDataType.SELECT
|
||||||
|
and isinstance(ret.get("extra_data", {}).get("select_options"), list)
|
||||||
|
):
|
||||||
|
ret["extra_data"]["select_options"] = [
|
||||||
|
{
|
||||||
|
"label": option,
|
||||||
|
"id": get_random_string(length=16),
|
||||||
|
}
|
||||||
|
for option in ret["extra_data"]["select_options"]
|
||||||
|
]
|
||||||
|
|
||||||
|
return ret
|
||||||
|
|
||||||
|
def to_representation(self, instance):
|
||||||
|
ret = super().to_representation(instance)
|
||||||
|
|
||||||
|
if (
|
||||||
|
self.api_version < 7
|
||||||
|
and instance.data_type == CustomField.FieldDataType.SELECT
|
||||||
|
):
|
||||||
|
# Convert the select options with ids to a list of strings
|
||||||
|
ret["extra_data"]["select_options"] = [
|
||||||
|
option["label"] for option in ret["extra_data"]["select_options"]
|
||||||
|
]
|
||||||
|
|
||||||
|
return ret
|
||||||
|
|
||||||
|
|
||||||
class ReadWriteSerializerMethodField(serializers.SerializerMethodField):
|
class ReadWriteSerializerMethodField(serializers.SerializerMethodField):
|
||||||
"""
|
"""
|
||||||
@@ -896,6 +937,50 @@ class CustomFieldInstanceSerializer(serializers.ModelSerializer):
|
|||||||
|
|
||||||
return data
|
return data
|
||||||
|
|
||||||
|
def get_api_version(self):
|
||||||
|
return int(
|
||||||
|
self.context.get("request").version
|
||||||
|
if self.context.get("request")
|
||||||
|
else settings.REST_FRAMEWORK["DEFAULT_VERSION"],
|
||||||
|
)
|
||||||
|
|
||||||
|
def to_internal_value(self, data):
|
||||||
|
ret = super().to_internal_value(data)
|
||||||
|
|
||||||
|
if (
|
||||||
|
self.get_api_version() < 7
|
||||||
|
and ret.get("field").data_type == CustomField.FieldDataType.SELECT
|
||||||
|
and ret.get("value") is not None
|
||||||
|
):
|
||||||
|
# Convert the index of the option in the field.extra_data["select_options"]
|
||||||
|
# list to the options unique id
|
||||||
|
ret["value"] = ret.get("field").extra_data["select_options"][ret["value"]][
|
||||||
|
"id"
|
||||||
|
]
|
||||||
|
|
||||||
|
return ret
|
||||||
|
|
||||||
|
def to_representation(self, instance):
|
||||||
|
ret = super().to_representation(instance)
|
||||||
|
|
||||||
|
if (
|
||||||
|
self.get_api_version() < 7
|
||||||
|
and instance.field.data_type == CustomField.FieldDataType.SELECT
|
||||||
|
):
|
||||||
|
# return the index of the option in the field.extra_data["select_options"] list
|
||||||
|
ret["value"] = next(
|
||||||
|
(
|
||||||
|
idx
|
||||||
|
for idx, option in enumerate(
|
||||||
|
instance.field.extra_data["select_options"],
|
||||||
|
)
|
||||||
|
if option["id"] == instance.value
|
||||||
|
),
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
|
||||||
|
return ret
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = CustomFieldInstance
|
model = CustomFieldInstance
|
||||||
fields = [
|
fields = [
|
||||||
@@ -919,6 +1004,20 @@ class NotesSerializer(serializers.ModelSerializer):
|
|||||||
fields = ["id", "note", "created", "user"]
|
fields = ["id", "note", "created", "user"]
|
||||||
ordering = ["-created"]
|
ordering = ["-created"]
|
||||||
|
|
||||||
|
def to_representation(self, instance):
|
||||||
|
ret = super().to_representation(instance)
|
||||||
|
|
||||||
|
request = self.context.get("request")
|
||||||
|
api_version = int(
|
||||||
|
request.version if request else settings.REST_FRAMEWORK["DEFAULT_VERSION"],
|
||||||
|
)
|
||||||
|
|
||||||
|
if api_version < 8 and "user" in ret:
|
||||||
|
user_id = ret["user"]["id"]
|
||||||
|
ret["user"] = user_id
|
||||||
|
|
||||||
|
return ret
|
||||||
|
|
||||||
|
|
||||||
def _get_viewable_duplicates(
|
def _get_viewable_duplicates(
|
||||||
document: Document,
|
document: Document,
|
||||||
@@ -1073,6 +1172,22 @@ class DocumentSerializer(
|
|||||||
doc["content"] = getattr(instance, "effective_content") or ""
|
doc["content"] = getattr(instance, "effective_content") or ""
|
||||||
if self.truncate_content and "content" in self.fields:
|
if self.truncate_content and "content" in self.fields:
|
||||||
doc["content"] = doc.get("content")[0:550]
|
doc["content"] = doc.get("content")[0:550]
|
||||||
|
|
||||||
|
request = self.context.get("request")
|
||||||
|
api_version = int(
|
||||||
|
request.version if request else settings.REST_FRAMEWORK["DEFAULT_VERSION"],
|
||||||
|
)
|
||||||
|
|
||||||
|
if api_version < 9 and "created" in self.fields:
|
||||||
|
# provide created as a datetime for backwards compatibility
|
||||||
|
from django.utils import timezone
|
||||||
|
|
||||||
|
doc["created"] = timezone.make_aware(
|
||||||
|
datetime.combine(
|
||||||
|
instance.created,
|
||||||
|
datetime.min.time(),
|
||||||
|
),
|
||||||
|
).isoformat()
|
||||||
return doc
|
return doc
|
||||||
|
|
||||||
def to_internal_value(self, data):
|
def to_internal_value(self, data):
|
||||||
@@ -1325,124 +1440,6 @@ class SavedViewSerializer(OwnedObjectSerializer):
|
|||||||
"set_permissions",
|
"set_permissions",
|
||||||
]
|
]
|
||||||
|
|
||||||
def _get_api_version(self) -> int:
|
|
||||||
request = self.context.get("request")
|
|
||||||
return int(
|
|
||||||
request.version if request else settings.REST_FRAMEWORK["DEFAULT_VERSION"],
|
|
||||||
)
|
|
||||||
|
|
||||||
def _update_legacy_visibility_preferences(
|
|
||||||
self,
|
|
||||||
saved_view_id: int,
|
|
||||||
*,
|
|
||||||
show_on_dashboard: bool | None,
|
|
||||||
show_in_sidebar: bool | None,
|
|
||||||
) -> UiSettings | None:
|
|
||||||
if show_on_dashboard is None and show_in_sidebar is None:
|
|
||||||
return None
|
|
||||||
|
|
||||||
request = self.context.get("request")
|
|
||||||
user = request.user if request else self.user
|
|
||||||
if user is None:
|
|
||||||
return None
|
|
||||||
|
|
||||||
ui_settings, _ = UiSettings.objects.get_or_create(
|
|
||||||
user=user,
|
|
||||||
defaults={"settings": {}},
|
|
||||||
)
|
|
||||||
current_settings = (
|
|
||||||
ui_settings.settings if isinstance(ui_settings.settings, dict) else {}
|
|
||||||
)
|
|
||||||
current_settings = dict(current_settings)
|
|
||||||
|
|
||||||
saved_views_settings = current_settings.get("saved_views")
|
|
||||||
if isinstance(saved_views_settings, dict):
|
|
||||||
saved_views_settings = dict(saved_views_settings)
|
|
||||||
else:
|
|
||||||
saved_views_settings = {}
|
|
||||||
|
|
||||||
dashboard_ids = {
|
|
||||||
int(raw_id)
|
|
||||||
for raw_id in saved_views_settings.get("dashboard_views_visible_ids", [])
|
|
||||||
if str(raw_id).isdigit()
|
|
||||||
}
|
|
||||||
sidebar_ids = {
|
|
||||||
int(raw_id)
|
|
||||||
for raw_id in saved_views_settings.get("sidebar_views_visible_ids", [])
|
|
||||||
if str(raw_id).isdigit()
|
|
||||||
}
|
|
||||||
|
|
||||||
if show_on_dashboard is not None:
|
|
||||||
if show_on_dashboard:
|
|
||||||
dashboard_ids.add(saved_view_id)
|
|
||||||
else:
|
|
||||||
dashboard_ids.discard(saved_view_id)
|
|
||||||
if show_in_sidebar is not None:
|
|
||||||
if show_in_sidebar:
|
|
||||||
sidebar_ids.add(saved_view_id)
|
|
||||||
else:
|
|
||||||
sidebar_ids.discard(saved_view_id)
|
|
||||||
|
|
||||||
saved_views_settings["dashboard_views_visible_ids"] = sorted(dashboard_ids)
|
|
||||||
saved_views_settings["sidebar_views_visible_ids"] = sorted(sidebar_ids)
|
|
||||||
current_settings["saved_views"] = saved_views_settings
|
|
||||||
ui_settings.settings = current_settings
|
|
||||||
ui_settings.save(update_fields=["settings"])
|
|
||||||
return ui_settings
|
|
||||||
|
|
||||||
def to_representation(self, instance):
|
|
||||||
# TODO: remove this and related backwards compatibility code when API v9 is dropped
|
|
||||||
ret = super().to_representation(instance)
|
|
||||||
request = self.context.get("request")
|
|
||||||
api_version = self._get_api_version()
|
|
||||||
|
|
||||||
if api_version < 10:
|
|
||||||
dashboard_ids = set()
|
|
||||||
sidebar_ids = set()
|
|
||||||
user = request.user if request else None
|
|
||||||
if user is not None and hasattr(user, "ui_settings"):
|
|
||||||
ui_settings = user.ui_settings.settings or None
|
|
||||||
saved_views = None
|
|
||||||
if isinstance(ui_settings, dict):
|
|
||||||
saved_views = ui_settings.get("saved_views", {})
|
|
||||||
if isinstance(saved_views, dict):
|
|
||||||
dashboard_ids = set(
|
|
||||||
saved_views.get("dashboard_views_visible_ids", []),
|
|
||||||
)
|
|
||||||
sidebar_ids = set(
|
|
||||||
saved_views.get("sidebar_views_visible_ids", []),
|
|
||||||
)
|
|
||||||
ret["show_on_dashboard"] = instance.id in dashboard_ids
|
|
||||||
ret["show_in_sidebar"] = instance.id in sidebar_ids
|
|
||||||
|
|
||||||
return ret
|
|
||||||
|
|
||||||
def to_internal_value(self, data):
|
|
||||||
# TODO: remove this and related backwards compatibility code when API v9 is dropped
|
|
||||||
api_version = self._get_api_version()
|
|
||||||
if api_version >= 10:
|
|
||||||
return super().to_internal_value(data)
|
|
||||||
|
|
||||||
normalized_data = data.copy()
|
|
||||||
legacy_visibility_fields = {}
|
|
||||||
boolean_field = serializers.BooleanField()
|
|
||||||
|
|
||||||
for field_name in ("show_on_dashboard", "show_in_sidebar"):
|
|
||||||
if field_name in normalized_data:
|
|
||||||
try:
|
|
||||||
legacy_visibility_fields[field_name] = (
|
|
||||||
boolean_field.to_internal_value(
|
|
||||||
normalized_data.get(field_name),
|
|
||||||
)
|
|
||||||
)
|
|
||||||
except serializers.ValidationError as exc:
|
|
||||||
raise serializers.ValidationError({field_name: exc.detail})
|
|
||||||
del normalized_data[field_name]
|
|
||||||
|
|
||||||
ret = super().to_internal_value(normalized_data)
|
|
||||||
ret.update(legacy_visibility_fields)
|
|
||||||
return ret
|
|
||||||
|
|
||||||
def validate(self, attrs):
|
def validate(self, attrs):
|
||||||
attrs = super().validate(attrs)
|
attrs = super().validate(attrs)
|
||||||
if "display_fields" in attrs and attrs["display_fields"] is not None:
|
if "display_fields" in attrs and attrs["display_fields"] is not None:
|
||||||
@@ -1462,9 +1459,6 @@ class SavedViewSerializer(OwnedObjectSerializer):
|
|||||||
return attrs
|
return attrs
|
||||||
|
|
||||||
def update(self, instance, validated_data):
|
def update(self, instance, validated_data):
|
||||||
request = self.context.get("request")
|
|
||||||
show_on_dashboard = validated_data.pop("show_on_dashboard", None)
|
|
||||||
show_in_sidebar = validated_data.pop("show_in_sidebar", None)
|
|
||||||
if "filter_rules" in validated_data:
|
if "filter_rules" in validated_data:
|
||||||
rules_data = validated_data.pop("filter_rules")
|
rules_data = validated_data.pop("filter_rules")
|
||||||
else:
|
else:
|
||||||
@@ -1486,19 +1480,9 @@ class SavedViewSerializer(OwnedObjectSerializer):
|
|||||||
SavedViewFilterRule.objects.filter(saved_view=instance).delete()
|
SavedViewFilterRule.objects.filter(saved_view=instance).delete()
|
||||||
for rule_data in rules_data:
|
for rule_data in rules_data:
|
||||||
SavedViewFilterRule.objects.create(saved_view=instance, **rule_data)
|
SavedViewFilterRule.objects.create(saved_view=instance, **rule_data)
|
||||||
ui_settings = self._update_legacy_visibility_preferences(
|
|
||||||
instance.id,
|
|
||||||
show_on_dashboard=show_on_dashboard,
|
|
||||||
show_in_sidebar=show_in_sidebar,
|
|
||||||
)
|
|
||||||
if request is not None and ui_settings is not None:
|
|
||||||
request.user.ui_settings = ui_settings
|
|
||||||
return instance
|
return instance
|
||||||
|
|
||||||
def create(self, validated_data):
|
def create(self, validated_data):
|
||||||
request = self.context.get("request")
|
|
||||||
show_on_dashboard = validated_data.pop("show_on_dashboard", None)
|
|
||||||
show_in_sidebar = validated_data.pop("show_in_sidebar", None)
|
|
||||||
rules_data = validated_data.pop("filter_rules")
|
rules_data = validated_data.pop("filter_rules")
|
||||||
if "user" in validated_data:
|
if "user" in validated_data:
|
||||||
# backwards compatibility
|
# backwards compatibility
|
||||||
@@ -1506,13 +1490,6 @@ class SavedViewSerializer(OwnedObjectSerializer):
|
|||||||
saved_view = super().create(validated_data)
|
saved_view = super().create(validated_data)
|
||||||
for rule_data in rules_data:
|
for rule_data in rules_data:
|
||||||
SavedViewFilterRule.objects.create(saved_view=saved_view, **rule_data)
|
SavedViewFilterRule.objects.create(saved_view=saved_view, **rule_data)
|
||||||
ui_settings = self._update_legacy_visibility_preferences(
|
|
||||||
saved_view.id,
|
|
||||||
show_on_dashboard=show_on_dashboard,
|
|
||||||
show_in_sidebar=show_in_sidebar,
|
|
||||||
)
|
|
||||||
if request is not None and ui_settings is not None:
|
|
||||||
request.user.ui_settings = ui_settings
|
|
||||||
return saved_view
|
return saved_view
|
||||||
|
|
||||||
|
|
||||||
@@ -1540,124 +1517,11 @@ class DocumentListSerializer(serializers.Serializer):
|
|||||||
return documents
|
return documents
|
||||||
|
|
||||||
|
|
||||||
class SourceModeValidationMixin:
|
|
||||||
def validate_source_mode(self, source_mode: str) -> str:
|
|
||||||
if source_mode not in bulk_edit.SourceModeChoices.__dict__.values():
|
|
||||||
raise serializers.ValidationError("Invalid source_mode")
|
|
||||||
return source_mode
|
|
||||||
|
|
||||||
|
|
||||||
class RotateDocumentsSerializer(DocumentListSerializer, SourceModeValidationMixin):
|
|
||||||
degrees = serializers.IntegerField(required=True)
|
|
||||||
source_mode = serializers.CharField(
|
|
||||||
required=False,
|
|
||||||
default=bulk_edit.SourceModeChoices.LATEST_VERSION,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class MergeDocumentsSerializer(DocumentListSerializer, SourceModeValidationMixin):
|
|
||||||
metadata_document_id = serializers.IntegerField(
|
|
||||||
required=False,
|
|
||||||
allow_null=True,
|
|
||||||
)
|
|
||||||
delete_originals = serializers.BooleanField(required=False, default=False)
|
|
||||||
archive_fallback = serializers.BooleanField(required=False, default=False)
|
|
||||||
source_mode = serializers.CharField(
|
|
||||||
required=False,
|
|
||||||
default=bulk_edit.SourceModeChoices.LATEST_VERSION,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class EditPdfDocumentsSerializer(DocumentListSerializer, SourceModeValidationMixin):
|
|
||||||
operations = serializers.ListField(required=True)
|
|
||||||
delete_original = serializers.BooleanField(required=False, default=False)
|
|
||||||
update_document = serializers.BooleanField(required=False, default=False)
|
|
||||||
include_metadata = serializers.BooleanField(required=False, default=True)
|
|
||||||
source_mode = serializers.CharField(
|
|
||||||
required=False,
|
|
||||||
default=bulk_edit.SourceModeChoices.LATEST_VERSION,
|
|
||||||
)
|
|
||||||
|
|
||||||
def validate(self, attrs):
|
|
||||||
documents = attrs["documents"]
|
|
||||||
if len(documents) > 1:
|
|
||||||
raise serializers.ValidationError(
|
|
||||||
"Edit PDF method only supports one document",
|
|
||||||
)
|
|
||||||
|
|
||||||
operations = attrs["operations"]
|
|
||||||
if not isinstance(operations, list):
|
|
||||||
raise serializers.ValidationError("operations must be a list")
|
|
||||||
|
|
||||||
for op in operations:
|
|
||||||
if not isinstance(op, dict):
|
|
||||||
raise serializers.ValidationError("invalid operation entry")
|
|
||||||
if "page" not in op or not isinstance(op["page"], int):
|
|
||||||
raise serializers.ValidationError("page must be an integer")
|
|
||||||
if "rotate" in op and not isinstance(op["rotate"], int):
|
|
||||||
raise serializers.ValidationError("rotate must be an integer")
|
|
||||||
if "doc" in op and not isinstance(op["doc"], int):
|
|
||||||
raise serializers.ValidationError("doc must be an integer")
|
|
||||||
|
|
||||||
if attrs["update_document"]:
|
|
||||||
max_idx = max(op.get("doc", 0) for op in operations)
|
|
||||||
if max_idx > 0:
|
|
||||||
raise serializers.ValidationError(
|
|
||||||
"update_document only allowed with a single output document",
|
|
||||||
)
|
|
||||||
|
|
||||||
doc = Document.objects.get(id=documents[0])
|
|
||||||
if doc.page_count:
|
|
||||||
for op in operations:
|
|
||||||
if op["page"] < 1 or op["page"] > doc.page_count:
|
|
||||||
raise serializers.ValidationError(
|
|
||||||
f"Page {op['page']} is out of bounds for document with {doc.page_count} pages.",
|
|
||||||
)
|
|
||||||
return attrs
|
|
||||||
|
|
||||||
|
|
||||||
class RemovePasswordDocumentsSerializer(
|
|
||||||
DocumentListSerializer,
|
|
||||||
SourceModeValidationMixin,
|
|
||||||
):
|
|
||||||
password = serializers.CharField(required=True)
|
|
||||||
update_document = serializers.BooleanField(required=False, default=False)
|
|
||||||
delete_original = serializers.BooleanField(required=False, default=False)
|
|
||||||
include_metadata = serializers.BooleanField(required=False, default=True)
|
|
||||||
source_mode = serializers.CharField(
|
|
||||||
required=False,
|
|
||||||
default=bulk_edit.SourceModeChoices.LATEST_VERSION,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class DeleteDocumentsSerializer(DocumentListSerializer):
|
|
||||||
pass
|
|
||||||
|
|
||||||
|
|
||||||
class ReprocessDocumentsSerializer(DocumentListSerializer):
|
|
||||||
pass
|
|
||||||
|
|
||||||
|
|
||||||
class BulkEditSerializer(
|
class BulkEditSerializer(
|
||||||
SerializerWithPerms,
|
SerializerWithPerms,
|
||||||
DocumentListSerializer,
|
DocumentListSerializer,
|
||||||
SetPermissionsMixin,
|
SetPermissionsMixin,
|
||||||
SourceModeValidationMixin,
|
|
||||||
):
|
):
|
||||||
# TODO: remove this and related backwards compatibility code when API v9 is dropped
|
|
||||||
# split, delete_pages can be removed entirely
|
|
||||||
MOVED_DOCUMENT_ACTION_ENDPOINTS = {
|
|
||||||
"delete": "/api/documents/delete/",
|
|
||||||
"reprocess": "/api/documents/reprocess/",
|
|
||||||
"rotate": "/api/documents/rotate/",
|
|
||||||
"merge": "/api/documents/merge/",
|
|
||||||
"edit_pdf": "/api/documents/edit_pdf/",
|
|
||||||
"remove_password": "/api/documents/remove_password/",
|
|
||||||
"split": "/api/documents/edit_pdf/",
|
|
||||||
"delete_pages": "/api/documents/edit_pdf/",
|
|
||||||
}
|
|
||||||
LEGACY_DOCUMENT_ACTION_METHODS = tuple(MOVED_DOCUMENT_ACTION_ENDPOINTS.keys())
|
|
||||||
|
|
||||||
method = serializers.ChoiceField(
|
method = serializers.ChoiceField(
|
||||||
choices=[
|
choices=[
|
||||||
"set_correspondent",
|
"set_correspondent",
|
||||||
@@ -1667,8 +1531,15 @@ class BulkEditSerializer(
|
|||||||
"remove_tag",
|
"remove_tag",
|
||||||
"modify_tags",
|
"modify_tags",
|
||||||
"modify_custom_fields",
|
"modify_custom_fields",
|
||||||
|
"delete",
|
||||||
|
"reprocess",
|
||||||
"set_permissions",
|
"set_permissions",
|
||||||
*LEGACY_DOCUMENT_ACTION_METHODS,
|
"rotate",
|
||||||
|
"merge",
|
||||||
|
"split",
|
||||||
|
"delete_pages",
|
||||||
|
"edit_pdf",
|
||||||
|
"remove_password",
|
||||||
],
|
],
|
||||||
label="Method",
|
label="Method",
|
||||||
write_only=True,
|
write_only=True,
|
||||||
@@ -1746,7 +1617,8 @@ class BulkEditSerializer(
|
|||||||
return bulk_edit.edit_pdf
|
return bulk_edit.edit_pdf
|
||||||
elif method == "remove_password":
|
elif method == "remove_password":
|
||||||
return bulk_edit.remove_password
|
return bulk_edit.remove_password
|
||||||
else:
|
else: # pragma: no cover
|
||||||
|
# This will never happen as it is handled by the ChoiceField
|
||||||
raise serializers.ValidationError("Unsupported method.")
|
raise serializers.ValidationError("Unsupported method.")
|
||||||
|
|
||||||
def _validate_parameters_tags(self, parameters) -> None:
|
def _validate_parameters_tags(self, parameters) -> None:
|
||||||
@@ -1856,7 +1728,9 @@ class BulkEditSerializer(
|
|||||||
"source_mode",
|
"source_mode",
|
||||||
bulk_edit.SourceModeChoices.LATEST_VERSION,
|
bulk_edit.SourceModeChoices.LATEST_VERSION,
|
||||||
)
|
)
|
||||||
parameters["source_mode"] = self.validate_source_mode(source_mode)
|
if source_mode not in bulk_edit.SourceModeChoices.__dict__.values():
|
||||||
|
raise serializers.ValidationError("Invalid source_mode")
|
||||||
|
parameters["source_mode"] = source_mode
|
||||||
|
|
||||||
def _validate_parameters_split(self, parameters) -> None:
|
def _validate_parameters_split(self, parameters) -> None:
|
||||||
if "pages" not in parameters:
|
if "pages" not in parameters:
|
||||||
|
|||||||
@@ -422,34 +422,6 @@ class TestBulkEditAPI(DirectoriesMixin, APITestCase):
|
|||||||
self.assertEqual(args[0], [self.doc1.id])
|
self.assertEqual(args[0], [self.doc1.id])
|
||||||
self.assertEqual(len(kwargs), 0)
|
self.assertEqual(len(kwargs), 0)
|
||||||
|
|
||||||
@mock.patch("documents.views.bulk_edit.delete")
|
|
||||||
def test_delete_documents_endpoint(self, m) -> None:
|
|
||||||
self.setup_mock(m, "delete")
|
|
||||||
response = self.client.post(
|
|
||||||
"/api/documents/delete/",
|
|
||||||
json.dumps({"documents": [self.doc1.id]}),
|
|
||||||
content_type="application/json",
|
|
||||||
)
|
|
||||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
|
||||||
m.assert_called_once()
|
|
||||||
args, kwargs = m.call_args
|
|
||||||
self.assertEqual(args[0], [self.doc1.id])
|
|
||||||
self.assertEqual(len(kwargs), 0)
|
|
||||||
|
|
||||||
@mock.patch("documents.views.bulk_edit.reprocess")
|
|
||||||
def test_reprocess_documents_endpoint(self, m) -> None:
|
|
||||||
self.setup_mock(m, "reprocess")
|
|
||||||
response = self.client.post(
|
|
||||||
"/api/documents/reprocess/",
|
|
||||||
json.dumps({"documents": [self.doc1.id]}),
|
|
||||||
content_type="application/json",
|
|
||||||
)
|
|
||||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
|
||||||
m.assert_called_once()
|
|
||||||
args, kwargs = m.call_args
|
|
||||||
self.assertEqual(args[0], [self.doc1.id])
|
|
||||||
self.assertEqual(len(kwargs), 0)
|
|
||||||
|
|
||||||
@mock.patch("documents.serialisers.bulk_edit.set_storage_path")
|
@mock.patch("documents.serialisers.bulk_edit.set_storage_path")
|
||||||
def test_api_set_storage_path(self, m) -> None:
|
def test_api_set_storage_path(self, m) -> None:
|
||||||
"""
|
"""
|
||||||
@@ -905,7 +877,7 @@ class TestBulkEditAPI(DirectoriesMixin, APITestCase):
|
|||||||
self.assertEqual(kwargs["merge"], True)
|
self.assertEqual(kwargs["merge"], True)
|
||||||
|
|
||||||
@mock.patch("documents.serialisers.bulk_edit.set_storage_path")
|
@mock.patch("documents.serialisers.bulk_edit.set_storage_path")
|
||||||
@mock.patch("documents.views.bulk_edit.merge")
|
@mock.patch("documents.serialisers.bulk_edit.merge")
|
||||||
def test_insufficient_global_perms(self, mock_merge, mock_set_storage) -> None:
|
def test_insufficient_global_perms(self, mock_merge, mock_set_storage) -> None:
|
||||||
"""
|
"""
|
||||||
GIVEN:
|
GIVEN:
|
||||||
@@ -940,11 +912,12 @@ class TestBulkEditAPI(DirectoriesMixin, APITestCase):
|
|||||||
mock_set_storage.assert_not_called()
|
mock_set_storage.assert_not_called()
|
||||||
|
|
||||||
response = self.client.post(
|
response = self.client.post(
|
||||||
"/api/documents/merge/",
|
"/api/documents/bulk_edit/",
|
||||||
json.dumps(
|
json.dumps(
|
||||||
{
|
{
|
||||||
"documents": [self.doc1.id],
|
"documents": [self.doc1.id],
|
||||||
"metadata_document_id": self.doc1.id,
|
"method": "merge",
|
||||||
|
"parameters": {"metadata_document_id": self.doc1.id},
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
content_type="application/json",
|
content_type="application/json",
|
||||||
@@ -954,12 +927,15 @@ class TestBulkEditAPI(DirectoriesMixin, APITestCase):
|
|||||||
mock_merge.assert_not_called()
|
mock_merge.assert_not_called()
|
||||||
|
|
||||||
response = self.client.post(
|
response = self.client.post(
|
||||||
"/api/documents/merge/",
|
"/api/documents/bulk_edit/",
|
||||||
json.dumps(
|
json.dumps(
|
||||||
{
|
{
|
||||||
"documents": [self.doc1.id],
|
"documents": [self.doc1.id],
|
||||||
"metadata_document_id": self.doc1.id,
|
"method": "merge",
|
||||||
"delete_originals": True,
|
"parameters": {
|
||||||
|
"metadata_document_id": self.doc1.id,
|
||||||
|
"delete_originals": True,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
content_type="application/json",
|
content_type="application/json",
|
||||||
@@ -1076,117 +1052,84 @@ class TestBulkEditAPI(DirectoriesMixin, APITestCase):
|
|||||||
|
|
||||||
m.assert_called_once()
|
m.assert_called_once()
|
||||||
|
|
||||||
@mock.patch("documents.views.bulk_edit.rotate")
|
@mock.patch("documents.serialisers.bulk_edit.rotate")
|
||||||
def test_rotate(self, m) -> None:
|
def test_rotate(self, m) -> None:
|
||||||
self.setup_mock(m, "rotate")
|
self.setup_mock(m, "rotate")
|
||||||
response = self.client.post(
|
response = self.client.post(
|
||||||
"/api/documents/rotate/",
|
"/api/documents/bulk_edit/",
|
||||||
json.dumps(
|
json.dumps(
|
||||||
{
|
{
|
||||||
"documents": [self.doc2.id, self.doc3.id],
|
"documents": [self.doc2.id, self.doc3.id],
|
||||||
"degrees": 90,
|
"method": "rotate",
|
||||||
|
"parameters": {"degrees": 90},
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
content_type="application/json",
|
content_type="application/json",
|
||||||
)
|
)
|
||||||
|
|
||||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||||
|
|
||||||
m.assert_called_once()
|
m.assert_called_once()
|
||||||
args, kwargs = m.call_args
|
args, kwargs = m.call_args
|
||||||
self.assertCountEqual(args[0], [self.doc2.id, self.doc3.id])
|
self.assertCountEqual(args[0], [self.doc2.id, self.doc3.id])
|
||||||
self.assertEqual(kwargs["degrees"], 90)
|
self.assertEqual(kwargs["degrees"], 90)
|
||||||
self.assertEqual(kwargs["source_mode"], "latest_version")
|
|
||||||
self.assertEqual(kwargs["user"], self.user)
|
|
||||||
|
|
||||||
@mock.patch("documents.views.bulk_edit.rotate")
|
@mock.patch("documents.serialisers.bulk_edit.rotate")
|
||||||
def test_rotate_invalid_params(self, m) -> None:
|
def test_rotate_invalid_params(self, m) -> None:
|
||||||
response = self.client.post(
|
response = self.client.post(
|
||||||
"/api/documents/rotate/",
|
"/api/documents/bulk_edit/",
|
||||||
json.dumps(
|
json.dumps(
|
||||||
{
|
{
|
||||||
"documents": [self.doc2.id, self.doc3.id],
|
"documents": [self.doc2.id, self.doc3.id],
|
||||||
"degrees": "foo",
|
"method": "rotate",
|
||||||
|
"parameters": {"degrees": "foo"},
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
content_type="application/json",
|
content_type="application/json",
|
||||||
)
|
)
|
||||||
|
|
||||||
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
|
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
|
||||||
|
|
||||||
response = self.client.post(
|
response = self.client.post(
|
||||||
"/api/documents/rotate/",
|
"/api/documents/bulk_edit/",
|
||||||
json.dumps(
|
json.dumps(
|
||||||
{
|
{
|
||||||
"documents": [self.doc2.id, self.doc3.id],
|
"documents": [self.doc2.id, self.doc3.id],
|
||||||
"degrees": 90.5,
|
"method": "rotate",
|
||||||
|
"parameters": {"degrees": 90.5},
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
content_type="application/json",
|
content_type="application/json",
|
||||||
)
|
)
|
||||||
|
|
||||||
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
|
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
|
||||||
|
|
||||||
m.assert_not_called()
|
m.assert_not_called()
|
||||||
|
|
||||||
@mock.patch("documents.views.bulk_edit.rotate")
|
@mock.patch("documents.serialisers.bulk_edit.merge")
|
||||||
def test_rotate_insufficient_permissions(self, m) -> None:
|
|
||||||
self.doc1.owner = User.objects.get(username="temp_admin")
|
|
||||||
self.doc1.save()
|
|
||||||
user1 = User.objects.create(username="user1")
|
|
||||||
user1.user_permissions.add(*Permission.objects.all())
|
|
||||||
user1.save()
|
|
||||||
self.client.force_authenticate(user=user1)
|
|
||||||
|
|
||||||
self.setup_mock(m, "rotate")
|
|
||||||
response = self.client.post(
|
|
||||||
"/api/documents/rotate/",
|
|
||||||
json.dumps(
|
|
||||||
{
|
|
||||||
"documents": [self.doc1.id, self.doc2.id],
|
|
||||||
"degrees": 90,
|
|
||||||
},
|
|
||||||
),
|
|
||||||
content_type="application/json",
|
|
||||||
)
|
|
||||||
|
|
||||||
self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN)
|
|
||||||
m.assert_not_called()
|
|
||||||
self.assertEqual(response.content, b"Insufficient permissions")
|
|
||||||
|
|
||||||
response = self.client.post(
|
|
||||||
"/api/documents/rotate/",
|
|
||||||
json.dumps(
|
|
||||||
{
|
|
||||||
"documents": [self.doc2.id, self.doc3.id],
|
|
||||||
"degrees": 90,
|
|
||||||
},
|
|
||||||
),
|
|
||||||
content_type="application/json",
|
|
||||||
)
|
|
||||||
|
|
||||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
|
||||||
m.assert_called_once()
|
|
||||||
|
|
||||||
@mock.patch("documents.views.bulk_edit.merge")
|
|
||||||
def test_merge(self, m) -> None:
|
def test_merge(self, m) -> None:
|
||||||
self.setup_mock(m, "merge")
|
self.setup_mock(m, "merge")
|
||||||
response = self.client.post(
|
response = self.client.post(
|
||||||
"/api/documents/merge/",
|
"/api/documents/bulk_edit/",
|
||||||
json.dumps(
|
json.dumps(
|
||||||
{
|
{
|
||||||
"documents": [self.doc2.id, self.doc3.id],
|
"documents": [self.doc2.id, self.doc3.id],
|
||||||
"metadata_document_id": self.doc3.id,
|
"method": "merge",
|
||||||
|
"parameters": {"metadata_document_id": self.doc3.id},
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
content_type="application/json",
|
content_type="application/json",
|
||||||
)
|
)
|
||||||
|
|
||||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||||
|
|
||||||
m.assert_called_once()
|
m.assert_called_once()
|
||||||
args, kwargs = m.call_args
|
args, kwargs = m.call_args
|
||||||
self.assertCountEqual(args[0], [self.doc2.id, self.doc3.id])
|
self.assertCountEqual(args[0], [self.doc2.id, self.doc3.id])
|
||||||
self.assertEqual(kwargs["metadata_document_id"], self.doc3.id)
|
self.assertEqual(kwargs["metadata_document_id"], self.doc3.id)
|
||||||
self.assertEqual(kwargs["source_mode"], "latest_version")
|
|
||||||
self.assertEqual(kwargs["user"], self.user)
|
self.assertEqual(kwargs["user"], self.user)
|
||||||
|
|
||||||
@mock.patch("documents.views.bulk_edit.merge")
|
@mock.patch("documents.serialisers.bulk_edit.merge")
|
||||||
def test_merge_and_delete_insufficient_permissions(self, m) -> None:
|
def test_merge_and_delete_insufficient_permissions(self, m) -> None:
|
||||||
self.doc1.owner = User.objects.get(username="temp_admin")
|
self.doc1.owner = User.objects.get(username="temp_admin")
|
||||||
self.doc1.save()
|
self.doc1.save()
|
||||||
@@ -1197,12 +1140,15 @@ class TestBulkEditAPI(DirectoriesMixin, APITestCase):
|
|||||||
|
|
||||||
self.setup_mock(m, "merge")
|
self.setup_mock(m, "merge")
|
||||||
response = self.client.post(
|
response = self.client.post(
|
||||||
"/api/documents/merge/",
|
"/api/documents/bulk_edit/",
|
||||||
json.dumps(
|
json.dumps(
|
||||||
{
|
{
|
||||||
"documents": [self.doc1.id, self.doc2.id],
|
"documents": [self.doc1.id, self.doc2.id],
|
||||||
"metadata_document_id": self.doc2.id,
|
"method": "merge",
|
||||||
"delete_originals": True,
|
"parameters": {
|
||||||
|
"metadata_document_id": self.doc2.id,
|
||||||
|
"delete_originals": True,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
content_type="application/json",
|
content_type="application/json",
|
||||||
@@ -1213,12 +1159,15 @@ class TestBulkEditAPI(DirectoriesMixin, APITestCase):
|
|||||||
self.assertEqual(response.content, b"Insufficient permissions")
|
self.assertEqual(response.content, b"Insufficient permissions")
|
||||||
|
|
||||||
response = self.client.post(
|
response = self.client.post(
|
||||||
"/api/documents/merge/",
|
"/api/documents/bulk_edit/",
|
||||||
json.dumps(
|
json.dumps(
|
||||||
{
|
{
|
||||||
"documents": [self.doc2.id, self.doc3.id],
|
"documents": [self.doc2.id, self.doc3.id],
|
||||||
"metadata_document_id": self.doc2.id,
|
"method": "merge",
|
||||||
"delete_originals": True,
|
"parameters": {
|
||||||
|
"metadata_document_id": self.doc2.id,
|
||||||
|
"delete_originals": True,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
content_type="application/json",
|
content_type="application/json",
|
||||||
@@ -1227,15 +1176,27 @@ class TestBulkEditAPI(DirectoriesMixin, APITestCase):
|
|||||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||||
m.assert_called_once()
|
m.assert_called_once()
|
||||||
|
|
||||||
@mock.patch("documents.views.bulk_edit.merge")
|
@mock.patch("documents.serialisers.bulk_edit.merge")
|
||||||
def test_merge_invalid_parameters(self, m) -> None:
|
def test_merge_invalid_parameters(self, m) -> None:
|
||||||
|
"""
|
||||||
|
GIVEN:
|
||||||
|
- API data for merging documents is called
|
||||||
|
- The parameters are invalid
|
||||||
|
WHEN:
|
||||||
|
- API is called
|
||||||
|
THEN:
|
||||||
|
- The API fails with a correct error code
|
||||||
|
"""
|
||||||
self.setup_mock(m, "merge")
|
self.setup_mock(m, "merge")
|
||||||
response = self.client.post(
|
response = self.client.post(
|
||||||
"/api/documents/merge/",
|
"/api/documents/bulk_edit/",
|
||||||
json.dumps(
|
json.dumps(
|
||||||
{
|
{
|
||||||
"documents": [self.doc1.id, self.doc2.id],
|
"documents": [self.doc1.id, self.doc2.id],
|
||||||
"delete_originals": "not_boolean",
|
"method": "merge",
|
||||||
|
"parameters": {
|
||||||
|
"delete_originals": "not_boolean",
|
||||||
|
},
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
content_type="application/json",
|
content_type="application/json",
|
||||||
@@ -1244,67 +1205,207 @@ class TestBulkEditAPI(DirectoriesMixin, APITestCase):
|
|||||||
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
|
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
|
||||||
m.assert_not_called()
|
m.assert_not_called()
|
||||||
|
|
||||||
def test_bulk_edit_allows_legacy_file_methods_with_warning(self) -> None:
|
@mock.patch("documents.serialisers.bulk_edit.split")
|
||||||
method_payloads = {
|
def test_split(self, m) -> None:
|
||||||
"delete": {},
|
self.setup_mock(m, "split")
|
||||||
"reprocess": {},
|
|
||||||
"rotate": {"degrees": 90},
|
|
||||||
"merge": {"metadata_document_id": self.doc2.id},
|
|
||||||
"edit_pdf": {"operations": [{"page": 1}]},
|
|
||||||
"remove_password": {"password": "secret"},
|
|
||||||
"split": {"pages": "1,2-4"},
|
|
||||||
"delete_pages": {"pages": [1, 2]},
|
|
||||||
}
|
|
||||||
|
|
||||||
for version in (9, 10):
|
|
||||||
for method, parameters in method_payloads.items():
|
|
||||||
with self.subTest(method=method, version=version):
|
|
||||||
with mock.patch(
|
|
||||||
f"documents.views.bulk_edit.{method}",
|
|
||||||
) as mocked_method:
|
|
||||||
self.setup_mock(mocked_method, method)
|
|
||||||
with self.assertLogs("paperless.api", level="WARNING") as logs:
|
|
||||||
response = self.client.post(
|
|
||||||
"/api/documents/bulk_edit/",
|
|
||||||
json.dumps(
|
|
||||||
{
|
|
||||||
"documents": [self.doc2.id],
|
|
||||||
"method": method,
|
|
||||||
"parameters": parameters,
|
|
||||||
},
|
|
||||||
),
|
|
||||||
content_type="application/json",
|
|
||||||
headers={
|
|
||||||
"Accept": f"application/json; version={version}",
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
|
||||||
mocked_method.assert_called_once()
|
|
||||||
self.assertTrue(
|
|
||||||
any(
|
|
||||||
"Deprecated bulk_edit method" in entry
|
|
||||||
and f"'{method}'" in entry
|
|
||||||
for entry in logs.output
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
@mock.patch("documents.views.bulk_edit.edit_pdf")
|
|
||||||
def test_edit_pdf(self, m) -> None:
|
|
||||||
self.setup_mock(m, "edit_pdf")
|
|
||||||
response = self.client.post(
|
response = self.client.post(
|
||||||
"/api/documents/edit_pdf/",
|
"/api/documents/bulk_edit/",
|
||||||
json.dumps(
|
json.dumps(
|
||||||
{
|
{
|
||||||
"documents": [self.doc2.id],
|
"documents": [self.doc2.id],
|
||||||
"operations": [{"page": 1}],
|
"method": "split",
|
||||||
"source_mode": "explicit_selection",
|
"parameters": {"pages": "1,2-4,5-6,7"},
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
content_type="application/json",
|
content_type="application/json",
|
||||||
)
|
)
|
||||||
|
|
||||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||||
|
|
||||||
|
m.assert_called_once()
|
||||||
|
args, kwargs = m.call_args
|
||||||
|
self.assertCountEqual(args[0], [self.doc2.id])
|
||||||
|
self.assertEqual(kwargs["pages"], [[1], [2, 3, 4], [5, 6], [7]])
|
||||||
|
self.assertEqual(kwargs["user"], self.user)
|
||||||
|
|
||||||
|
def test_split_invalid_params(self) -> None:
|
||||||
|
response = self.client.post(
|
||||||
|
"/api/documents/bulk_edit/",
|
||||||
|
json.dumps(
|
||||||
|
{
|
||||||
|
"documents": [self.doc2.id],
|
||||||
|
"method": "split",
|
||||||
|
"parameters": {}, # pages not specified
|
||||||
|
},
|
||||||
|
),
|
||||||
|
content_type="application/json",
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
|
||||||
|
self.assertIn(b"pages not specified", response.content)
|
||||||
|
|
||||||
|
response = self.client.post(
|
||||||
|
"/api/documents/bulk_edit/",
|
||||||
|
json.dumps(
|
||||||
|
{
|
||||||
|
"documents": [self.doc2.id],
|
||||||
|
"method": "split",
|
||||||
|
"parameters": {"pages": "1:7"}, # wrong format
|
||||||
|
},
|
||||||
|
),
|
||||||
|
content_type="application/json",
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
|
||||||
|
self.assertIn(b"invalid pages specified", response.content)
|
||||||
|
|
||||||
|
response = self.client.post(
|
||||||
|
"/api/documents/bulk_edit/",
|
||||||
|
json.dumps(
|
||||||
|
{
|
||||||
|
"documents": [
|
||||||
|
self.doc1.id,
|
||||||
|
self.doc2.id,
|
||||||
|
], # only one document supported
|
||||||
|
"method": "split",
|
||||||
|
"parameters": {"pages": "1-2,3-7"}, # wrong format
|
||||||
|
},
|
||||||
|
),
|
||||||
|
content_type="application/json",
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
|
||||||
|
self.assertIn(b"Split method only supports one document", response.content)
|
||||||
|
|
||||||
|
response = self.client.post(
|
||||||
|
"/api/documents/bulk_edit/",
|
||||||
|
json.dumps(
|
||||||
|
{
|
||||||
|
"documents": [self.doc2.id],
|
||||||
|
"method": "split",
|
||||||
|
"parameters": {
|
||||||
|
"pages": "1",
|
||||||
|
"delete_originals": "notabool",
|
||||||
|
}, # not a bool
|
||||||
|
},
|
||||||
|
),
|
||||||
|
content_type="application/json",
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
|
||||||
|
self.assertIn(b"delete_originals must be a boolean", response.content)
|
||||||
|
|
||||||
|
@mock.patch("documents.serialisers.bulk_edit.delete_pages")
|
||||||
|
def test_delete_pages(self, m) -> None:
|
||||||
|
self.setup_mock(m, "delete_pages")
|
||||||
|
response = self.client.post(
|
||||||
|
"/api/documents/bulk_edit/",
|
||||||
|
json.dumps(
|
||||||
|
{
|
||||||
|
"documents": [self.doc2.id],
|
||||||
|
"method": "delete_pages",
|
||||||
|
"parameters": {"pages": [1, 2, 3, 4]},
|
||||||
|
},
|
||||||
|
),
|
||||||
|
content_type="application/json",
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||||
|
|
||||||
|
m.assert_called_once()
|
||||||
|
args, kwargs = m.call_args
|
||||||
|
self.assertCountEqual(args[0], [self.doc2.id])
|
||||||
|
self.assertEqual(kwargs["pages"], [1, 2, 3, 4])
|
||||||
|
|
||||||
|
def test_delete_pages_invalid_params(self) -> None:
|
||||||
|
response = self.client.post(
|
||||||
|
"/api/documents/bulk_edit/",
|
||||||
|
json.dumps(
|
||||||
|
{
|
||||||
|
"documents": [
|
||||||
|
self.doc1.id,
|
||||||
|
self.doc2.id,
|
||||||
|
], # only one document supported
|
||||||
|
"method": "delete_pages",
|
||||||
|
"parameters": {
|
||||||
|
"pages": [1, 2, 3, 4],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
),
|
||||||
|
content_type="application/json",
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
|
||||||
|
self.assertIn(
|
||||||
|
b"Delete pages method only supports one document",
|
||||||
|
response.content,
|
||||||
|
)
|
||||||
|
|
||||||
|
response = self.client.post(
|
||||||
|
"/api/documents/bulk_edit/",
|
||||||
|
json.dumps(
|
||||||
|
{
|
||||||
|
"documents": [self.doc2.id],
|
||||||
|
"method": "delete_pages",
|
||||||
|
"parameters": {}, # pages not specified
|
||||||
|
},
|
||||||
|
),
|
||||||
|
content_type="application/json",
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
|
||||||
|
self.assertIn(b"pages not specified", response.content)
|
||||||
|
|
||||||
|
response = self.client.post(
|
||||||
|
"/api/documents/bulk_edit/",
|
||||||
|
json.dumps(
|
||||||
|
{
|
||||||
|
"documents": [self.doc2.id],
|
||||||
|
"method": "delete_pages",
|
||||||
|
"parameters": {"pages": "1-3"}, # not a list
|
||||||
|
},
|
||||||
|
),
|
||||||
|
content_type="application/json",
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
|
||||||
|
self.assertIn(b"pages must be a list", response.content)
|
||||||
|
|
||||||
|
response = self.client.post(
|
||||||
|
"/api/documents/bulk_edit/",
|
||||||
|
json.dumps(
|
||||||
|
{
|
||||||
|
"documents": [self.doc2.id],
|
||||||
|
"method": "delete_pages",
|
||||||
|
"parameters": {"pages": ["1-3"]}, # not ints
|
||||||
|
},
|
||||||
|
),
|
||||||
|
content_type="application/json",
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
|
||||||
|
self.assertIn(b"pages must be a list of integers", response.content)
|
||||||
|
|
||||||
|
@mock.patch("documents.serialisers.bulk_edit.edit_pdf")
|
||||||
|
def test_edit_pdf(self, m) -> None:
|
||||||
|
self.setup_mock(m, "edit_pdf")
|
||||||
|
response = self.client.post(
|
||||||
|
"/api/documents/bulk_edit/",
|
||||||
|
json.dumps(
|
||||||
|
{
|
||||||
|
"documents": [self.doc2.id],
|
||||||
|
"method": "edit_pdf",
|
||||||
|
"parameters": {
|
||||||
|
"operations": [{"page": 1}],
|
||||||
|
"source_mode": "explicit_selection",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
),
|
||||||
|
content_type="application/json",
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||||
|
|
||||||
m.assert_called_once()
|
m.assert_called_once()
|
||||||
args, kwargs = m.call_args
|
args, kwargs = m.call_args
|
||||||
self.assertCountEqual(args[0], [self.doc2.id])
|
self.assertCountEqual(args[0], [self.doc2.id])
|
||||||
@@ -1313,12 +1414,14 @@ class TestBulkEditAPI(DirectoriesMixin, APITestCase):
|
|||||||
self.assertEqual(kwargs["user"], self.user)
|
self.assertEqual(kwargs["user"], self.user)
|
||||||
|
|
||||||
def test_edit_pdf_invalid_params(self) -> None:
|
def test_edit_pdf_invalid_params(self) -> None:
|
||||||
|
# multiple documents
|
||||||
response = self.client.post(
|
response = self.client.post(
|
||||||
"/api/documents/edit_pdf/",
|
"/api/documents/bulk_edit/",
|
||||||
json.dumps(
|
json.dumps(
|
||||||
{
|
{
|
||||||
"documents": [self.doc2.id, self.doc3.id],
|
"documents": [self.doc2.id, self.doc3.id],
|
||||||
"operations": [{"page": 1}],
|
"method": "edit_pdf",
|
||||||
|
"parameters": {"operations": [{"page": 1}]},
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
content_type="application/json",
|
content_type="application/json",
|
||||||
@@ -1326,25 +1429,44 @@ class TestBulkEditAPI(DirectoriesMixin, APITestCase):
|
|||||||
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
|
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
|
||||||
self.assertIn(b"Edit PDF method only supports one document", response.content)
|
self.assertIn(b"Edit PDF method only supports one document", response.content)
|
||||||
|
|
||||||
|
# no operations specified
|
||||||
response = self.client.post(
|
response = self.client.post(
|
||||||
"/api/documents/edit_pdf/",
|
"/api/documents/bulk_edit/",
|
||||||
json.dumps(
|
json.dumps(
|
||||||
{
|
{
|
||||||
"documents": [self.doc2.id],
|
"documents": [self.doc2.id],
|
||||||
"operations": "not_a_list",
|
"method": "edit_pdf",
|
||||||
|
"parameters": {},
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
content_type="application/json",
|
content_type="application/json",
|
||||||
)
|
)
|
||||||
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
|
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
|
||||||
self.assertIn(b"Expected a list of items", response.content)
|
self.assertIn(b"operations not specified", response.content)
|
||||||
|
|
||||||
|
# operations not a list
|
||||||
response = self.client.post(
|
response = self.client.post(
|
||||||
"/api/documents/edit_pdf/",
|
"/api/documents/bulk_edit/",
|
||||||
json.dumps(
|
json.dumps(
|
||||||
{
|
{
|
||||||
"documents": [self.doc2.id],
|
"documents": [self.doc2.id],
|
||||||
"operations": ["invalid_operation"],
|
"method": "edit_pdf",
|
||||||
|
"parameters": {"operations": "not_a_list"},
|
||||||
|
},
|
||||||
|
),
|
||||||
|
content_type="application/json",
|
||||||
|
)
|
||||||
|
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
|
||||||
|
self.assertIn(b"operations must be a list", response.content)
|
||||||
|
|
||||||
|
# invalid operation
|
||||||
|
response = self.client.post(
|
||||||
|
"/api/documents/bulk_edit/",
|
||||||
|
json.dumps(
|
||||||
|
{
|
||||||
|
"documents": [self.doc2.id],
|
||||||
|
"method": "edit_pdf",
|
||||||
|
"parameters": {"operations": ["invalid_operation"]},
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
content_type="application/json",
|
content_type="application/json",
|
||||||
@@ -1352,12 +1474,14 @@ class TestBulkEditAPI(DirectoriesMixin, APITestCase):
|
|||||||
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
|
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
|
||||||
self.assertIn(b"invalid operation entry", response.content)
|
self.assertIn(b"invalid operation entry", response.content)
|
||||||
|
|
||||||
|
# page not an int
|
||||||
response = self.client.post(
|
response = self.client.post(
|
||||||
"/api/documents/edit_pdf/",
|
"/api/documents/bulk_edit/",
|
||||||
json.dumps(
|
json.dumps(
|
||||||
{
|
{
|
||||||
"documents": [self.doc2.id],
|
"documents": [self.doc2.id],
|
||||||
"operations": [{"page": "not_an_int"}],
|
"method": "edit_pdf",
|
||||||
|
"parameters": {"operations": [{"page": "not_an_int"}]},
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
content_type="application/json",
|
content_type="application/json",
|
||||||
@@ -1365,12 +1489,14 @@ class TestBulkEditAPI(DirectoriesMixin, APITestCase):
|
|||||||
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
|
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
|
||||||
self.assertIn(b"page must be an integer", response.content)
|
self.assertIn(b"page must be an integer", response.content)
|
||||||
|
|
||||||
|
# rotate not an int
|
||||||
response = self.client.post(
|
response = self.client.post(
|
||||||
"/api/documents/edit_pdf/",
|
"/api/documents/bulk_edit/",
|
||||||
json.dumps(
|
json.dumps(
|
||||||
{
|
{
|
||||||
"documents": [self.doc2.id],
|
"documents": [self.doc2.id],
|
||||||
"operations": [{"page": 1, "rotate": "not_an_int"}],
|
"method": "edit_pdf",
|
||||||
|
"parameters": {"operations": [{"page": 1, "rotate": "not_an_int"}]},
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
content_type="application/json",
|
content_type="application/json",
|
||||||
@@ -1378,12 +1504,14 @@ class TestBulkEditAPI(DirectoriesMixin, APITestCase):
|
|||||||
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
|
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
|
||||||
self.assertIn(b"rotate must be an integer", response.content)
|
self.assertIn(b"rotate must be an integer", response.content)
|
||||||
|
|
||||||
|
# doc not an int
|
||||||
response = self.client.post(
|
response = self.client.post(
|
||||||
"/api/documents/edit_pdf/",
|
"/api/documents/bulk_edit/",
|
||||||
json.dumps(
|
json.dumps(
|
||||||
{
|
{
|
||||||
"documents": [self.doc2.id],
|
"documents": [self.doc2.id],
|
||||||
"operations": [{"page": 1, "doc": "not_an_int"}],
|
"method": "edit_pdf",
|
||||||
|
"parameters": {"operations": [{"page": 1, "doc": "not_an_int"}]},
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
content_type="application/json",
|
content_type="application/json",
|
||||||
@@ -1391,13 +1519,53 @@ class TestBulkEditAPI(DirectoriesMixin, APITestCase):
|
|||||||
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
|
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
|
||||||
self.assertIn(b"doc must be an integer", response.content)
|
self.assertIn(b"doc must be an integer", response.content)
|
||||||
|
|
||||||
|
# update_document not a boolean
|
||||||
response = self.client.post(
|
response = self.client.post(
|
||||||
"/api/documents/edit_pdf/",
|
"/api/documents/bulk_edit/",
|
||||||
json.dumps(
|
json.dumps(
|
||||||
{
|
{
|
||||||
"documents": [self.doc2.id],
|
"documents": [self.doc2.id],
|
||||||
"update_document": True,
|
"method": "edit_pdf",
|
||||||
"operations": [{"page": 1, "doc": 1}, {"page": 2, "doc": 2}],
|
"parameters": {
|
||||||
|
"update_document": "not_a_bool",
|
||||||
|
"operations": [{"page": 1}],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
),
|
||||||
|
content_type="application/json",
|
||||||
|
)
|
||||||
|
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
|
||||||
|
self.assertIn(b"update_document must be a boolean", response.content)
|
||||||
|
|
||||||
|
# include_metadata not a boolean
|
||||||
|
response = self.client.post(
|
||||||
|
"/api/documents/bulk_edit/",
|
||||||
|
json.dumps(
|
||||||
|
{
|
||||||
|
"documents": [self.doc2.id],
|
||||||
|
"method": "edit_pdf",
|
||||||
|
"parameters": {
|
||||||
|
"include_metadata": "not_a_bool",
|
||||||
|
"operations": [{"page": 1}],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
),
|
||||||
|
content_type="application/json",
|
||||||
|
)
|
||||||
|
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
|
||||||
|
self.assertIn(b"include_metadata must be a boolean", response.content)
|
||||||
|
|
||||||
|
# update_document True but output would be multiple documents
|
||||||
|
response = self.client.post(
|
||||||
|
"/api/documents/bulk_edit/",
|
||||||
|
json.dumps(
|
||||||
|
{
|
||||||
|
"documents": [self.doc2.id],
|
||||||
|
"method": "edit_pdf",
|
||||||
|
"parameters": {
|
||||||
|
"update_document": True,
|
||||||
|
"operations": [{"page": 1, "doc": 1}, {"page": 2, "doc": 2}],
|
||||||
|
},
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
content_type="application/json",
|
content_type="application/json",
|
||||||
@@ -1408,13 +1576,17 @@ class TestBulkEditAPI(DirectoriesMixin, APITestCase):
|
|||||||
response.content,
|
response.content,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# invalid source mode
|
||||||
response = self.client.post(
|
response = self.client.post(
|
||||||
"/api/documents/edit_pdf/",
|
"/api/documents/bulk_edit/",
|
||||||
json.dumps(
|
json.dumps(
|
||||||
{
|
{
|
||||||
"documents": [self.doc2.id],
|
"documents": [self.doc2.id],
|
||||||
"operations": [{"page": 1}],
|
"method": "edit_pdf",
|
||||||
"source_mode": "not_a_mode",
|
"parameters": {
|
||||||
|
"operations": [{"page": 1}],
|
||||||
|
"source_mode": "not_a_mode",
|
||||||
|
},
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
content_type="application/json",
|
content_type="application/json",
|
||||||
@@ -1422,70 +1594,42 @@ class TestBulkEditAPI(DirectoriesMixin, APITestCase):
|
|||||||
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
|
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
|
||||||
self.assertIn(b"Invalid source_mode", response.content)
|
self.assertIn(b"Invalid source_mode", response.content)
|
||||||
|
|
||||||
@mock.patch("documents.views.bulk_edit.edit_pdf")
|
@mock.patch("documents.serialisers.bulk_edit.edit_pdf")
|
||||||
def test_edit_pdf_page_out_of_bounds(self, m) -> None:
|
def test_edit_pdf_page_out_of_bounds(self, m) -> None:
|
||||||
|
"""
|
||||||
|
GIVEN:
|
||||||
|
- API data for editing PDF is called
|
||||||
|
- The page number is out of bounds
|
||||||
|
WHEN:
|
||||||
|
- API is called
|
||||||
|
THEN:
|
||||||
|
- The API fails with a correct error code
|
||||||
|
"""
|
||||||
self.setup_mock(m, "edit_pdf")
|
self.setup_mock(m, "edit_pdf")
|
||||||
response = self.client.post(
|
response = self.client.post(
|
||||||
"/api/documents/edit_pdf/",
|
"/api/documents/bulk_edit/",
|
||||||
json.dumps(
|
json.dumps(
|
||||||
{
|
{
|
||||||
"documents": [self.doc2.id],
|
"documents": [self.doc2.id],
|
||||||
"operations": [{"page": 99}],
|
"method": "edit_pdf",
|
||||||
|
"parameters": {"operations": [{"page": 99}]},
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
content_type="application/json",
|
content_type="application/json",
|
||||||
)
|
)
|
||||||
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
|
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
|
||||||
self.assertIn(b"out of bounds", response.content)
|
self.assertIn(b"out of bounds", response.content)
|
||||||
m.assert_not_called()
|
|
||||||
|
|
||||||
@mock.patch("documents.views.bulk_edit.edit_pdf")
|
@mock.patch("documents.serialisers.bulk_edit.remove_password")
|
||||||
def test_edit_pdf_insufficient_permissions(self, m) -> None:
|
|
||||||
self.doc1.owner = User.objects.get(username="temp_admin")
|
|
||||||
self.doc1.save()
|
|
||||||
user1 = User.objects.create(username="user1")
|
|
||||||
user1.user_permissions.add(*Permission.objects.all())
|
|
||||||
user1.save()
|
|
||||||
self.client.force_authenticate(user=user1)
|
|
||||||
|
|
||||||
self.setup_mock(m, "edit_pdf")
|
|
||||||
response = self.client.post(
|
|
||||||
"/api/documents/edit_pdf/",
|
|
||||||
json.dumps(
|
|
||||||
{
|
|
||||||
"documents": [self.doc1.id],
|
|
||||||
"operations": [{"page": 1}],
|
|
||||||
},
|
|
||||||
),
|
|
||||||
content_type="application/json",
|
|
||||||
)
|
|
||||||
self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN)
|
|
||||||
m.assert_not_called()
|
|
||||||
self.assertEqual(response.content, b"Insufficient permissions")
|
|
||||||
|
|
||||||
response = self.client.post(
|
|
||||||
"/api/documents/edit_pdf/",
|
|
||||||
json.dumps(
|
|
||||||
{
|
|
||||||
"documents": [self.doc2.id],
|
|
||||||
"operations": [{"page": 1}],
|
|
||||||
},
|
|
||||||
),
|
|
||||||
content_type="application/json",
|
|
||||||
)
|
|
||||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
|
||||||
m.assert_called_once()
|
|
||||||
|
|
||||||
@mock.patch("documents.views.bulk_edit.remove_password")
|
|
||||||
def test_remove_password(self, m) -> None:
|
def test_remove_password(self, m) -> None:
|
||||||
self.setup_mock(m, "remove_password")
|
self.setup_mock(m, "remove_password")
|
||||||
response = self.client.post(
|
response = self.client.post(
|
||||||
"/api/documents/remove_password/",
|
"/api/documents/bulk_edit/",
|
||||||
json.dumps(
|
json.dumps(
|
||||||
{
|
{
|
||||||
"documents": [self.doc2.id],
|
"documents": [self.doc2.id],
|
||||||
"password": "secret",
|
"method": "remove_password",
|
||||||
"update_document": True,
|
"parameters": {"password": "secret", "update_document": True},
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
content_type="application/json",
|
content_type="application/json",
|
||||||
@@ -1497,69 +1641,36 @@ class TestBulkEditAPI(DirectoriesMixin, APITestCase):
|
|||||||
self.assertCountEqual(args[0], [self.doc2.id])
|
self.assertCountEqual(args[0], [self.doc2.id])
|
||||||
self.assertEqual(kwargs["password"], "secret")
|
self.assertEqual(kwargs["password"], "secret")
|
||||||
self.assertTrue(kwargs["update_document"])
|
self.assertTrue(kwargs["update_document"])
|
||||||
self.assertEqual(kwargs["source_mode"], "latest_version")
|
|
||||||
self.assertEqual(kwargs["user"], self.user)
|
self.assertEqual(kwargs["user"], self.user)
|
||||||
|
|
||||||
def test_remove_password_invalid_params(self) -> None:
|
def test_remove_password_invalid_params(self) -> None:
|
||||||
response = self.client.post(
|
response = self.client.post(
|
||||||
"/api/documents/remove_password/",
|
"/api/documents/bulk_edit/",
|
||||||
json.dumps(
|
json.dumps(
|
||||||
{
|
{
|
||||||
"documents": [self.doc2.id],
|
"documents": [self.doc2.id],
|
||||||
|
"method": "remove_password",
|
||||||
|
"parameters": {},
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
content_type="application/json",
|
content_type="application/json",
|
||||||
)
|
)
|
||||||
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
|
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
|
||||||
|
self.assertIn(b"password not specified", response.content)
|
||||||
|
|
||||||
response = self.client.post(
|
response = self.client.post(
|
||||||
"/api/documents/remove_password/",
|
"/api/documents/bulk_edit/",
|
||||||
json.dumps(
|
json.dumps(
|
||||||
{
|
{
|
||||||
"documents": [self.doc2.id],
|
"documents": [self.doc2.id],
|
||||||
"password": 123,
|
"method": "remove_password",
|
||||||
|
"parameters": {"password": 123},
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
content_type="application/json",
|
content_type="application/json",
|
||||||
)
|
)
|
||||||
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
|
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
|
||||||
|
self.assertIn(b"password must be a string", response.content)
|
||||||
@mock.patch("documents.views.bulk_edit.remove_password")
|
|
||||||
def test_remove_password_insufficient_permissions(self, m) -> None:
|
|
||||||
self.doc1.owner = User.objects.get(username="temp_admin")
|
|
||||||
self.doc1.save()
|
|
||||||
user1 = User.objects.create(username="user1")
|
|
||||||
user1.user_permissions.add(*Permission.objects.all())
|
|
||||||
user1.save()
|
|
||||||
self.client.force_authenticate(user=user1)
|
|
||||||
|
|
||||||
self.setup_mock(m, "remove_password")
|
|
||||||
response = self.client.post(
|
|
||||||
"/api/documents/remove_password/",
|
|
||||||
json.dumps(
|
|
||||||
{
|
|
||||||
"documents": [self.doc1.id],
|
|
||||||
"password": "secret",
|
|
||||||
},
|
|
||||||
),
|
|
||||||
content_type="application/json",
|
|
||||||
)
|
|
||||||
self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN)
|
|
||||||
m.assert_not_called()
|
|
||||||
self.assertEqual(response.content, b"Insufficient permissions")
|
|
||||||
|
|
||||||
response = self.client.post(
|
|
||||||
"/api/documents/remove_password/",
|
|
||||||
json.dumps(
|
|
||||||
{
|
|
||||||
"documents": [self.doc2.id],
|
|
||||||
"password": "secret",
|
|
||||||
},
|
|
||||||
),
|
|
||||||
content_type="application/json",
|
|
||||||
)
|
|
||||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
|
||||||
m.assert_called_once()
|
|
||||||
|
|
||||||
@override_settings(AUDIT_LOG_ENABLED=True)
|
@override_settings(AUDIT_LOG_ENABLED=True)
|
||||||
def test_bulk_edit_audit_log_enabled_simple_field(self) -> None:
|
def test_bulk_edit_audit_log_enabled_simple_field(self) -> None:
|
||||||
|
|||||||
@@ -323,6 +323,113 @@ class TestCustomFieldsAPI(DirectoriesMixin, APITestCase):
|
|||||||
|
|
||||||
mock_delay.assert_called_once_with(cf_select)
|
mock_delay.assert_called_once_with(cf_select)
|
||||||
|
|
||||||
|
def test_custom_field_select_old_version(self) -> None:
|
||||||
|
"""
|
||||||
|
GIVEN:
|
||||||
|
- Nothing
|
||||||
|
WHEN:
|
||||||
|
- API post request is made for custom fields with api version header < 7
|
||||||
|
- API get request is made for custom fields with api version header < 7
|
||||||
|
THEN:
|
||||||
|
- The select options are created with unique ids
|
||||||
|
- The select options are returned in the old format
|
||||||
|
"""
|
||||||
|
resp = self.client.post(
|
||||||
|
self.ENDPOINT,
|
||||||
|
headers={"Accept": "application/json; version=6"},
|
||||||
|
data=json.dumps(
|
||||||
|
{
|
||||||
|
"data_type": "select",
|
||||||
|
"name": "Select Field",
|
||||||
|
"extra_data": {
|
||||||
|
"select_options": [
|
||||||
|
"Option 1",
|
||||||
|
"Option 2",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
),
|
||||||
|
content_type="application/json",
|
||||||
|
)
|
||||||
|
self.assertEqual(resp.status_code, status.HTTP_201_CREATED)
|
||||||
|
|
||||||
|
field = CustomField.objects.get(name="Select Field")
|
||||||
|
self.assertEqual(
|
||||||
|
field.extra_data["select_options"],
|
||||||
|
[
|
||||||
|
{"label": "Option 1", "id": ANY},
|
||||||
|
{"label": "Option 2", "id": ANY},
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
resp = self.client.get(
|
||||||
|
f"{self.ENDPOINT}{field.id}/",
|
||||||
|
headers={"Accept": "application/json; version=6"},
|
||||||
|
)
|
||||||
|
self.assertEqual(resp.status_code, status.HTTP_200_OK)
|
||||||
|
|
||||||
|
data = resp.json()
|
||||||
|
self.assertEqual(
|
||||||
|
data["extra_data"]["select_options"],
|
||||||
|
[
|
||||||
|
"Option 1",
|
||||||
|
"Option 2",
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_custom_field_select_value_old_version(self) -> None:
|
||||||
|
"""
|
||||||
|
GIVEN:
|
||||||
|
- Existing document with custom field select
|
||||||
|
WHEN:
|
||||||
|
- API post request is made to add the field for document with api version header < 7
|
||||||
|
- API get request is made for document with api version header < 7
|
||||||
|
THEN:
|
||||||
|
- The select value is returned in the old format, the index of the option
|
||||||
|
"""
|
||||||
|
custom_field_select = CustomField.objects.create(
|
||||||
|
name="Select Field",
|
||||||
|
data_type=CustomField.FieldDataType.SELECT,
|
||||||
|
extra_data={
|
||||||
|
"select_options": [
|
||||||
|
{"label": "Option 1", "id": "abc-123"},
|
||||||
|
{"label": "Option 2", "id": "def-456"},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
doc = Document.objects.create(
|
||||||
|
title="WOW",
|
||||||
|
content="the content",
|
||||||
|
checksum="123",
|
||||||
|
mime_type="application/pdf",
|
||||||
|
)
|
||||||
|
|
||||||
|
resp = self.client.patch(
|
||||||
|
f"/api/documents/{doc.id}/",
|
||||||
|
headers={"Accept": "application/json; version=6"},
|
||||||
|
data=json.dumps(
|
||||||
|
{
|
||||||
|
"custom_fields": [
|
||||||
|
{"field": custom_field_select.id, "value": 1},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
content_type="application/json",
|
||||||
|
)
|
||||||
|
self.assertEqual(resp.status_code, status.HTTP_200_OK)
|
||||||
|
doc.refresh_from_db()
|
||||||
|
self.assertEqual(doc.custom_fields.first().value, "def-456")
|
||||||
|
|
||||||
|
resp = self.client.get(
|
||||||
|
f"/api/documents/{doc.id}/",
|
||||||
|
headers={"Accept": "application/json; version=6"},
|
||||||
|
)
|
||||||
|
self.assertEqual(resp.status_code, status.HTTP_200_OK)
|
||||||
|
|
||||||
|
data = resp.json()
|
||||||
|
self.assertEqual(data["custom_fields"][0]["value"], 1)
|
||||||
|
|
||||||
def test_create_custom_field_monetary_validation(self) -> None:
|
def test_create_custom_field_monetary_validation(self) -> None:
|
||||||
"""
|
"""
|
||||||
GIVEN:
|
GIVEN:
|
||||||
|
|||||||
@@ -41,7 +41,6 @@ from documents.models import SavedView
|
|||||||
from documents.models import ShareLink
|
from documents.models import ShareLink
|
||||||
from documents.models import StoragePath
|
from documents.models import StoragePath
|
||||||
from documents.models import Tag
|
from documents.models import Tag
|
||||||
from documents.models import UiSettings
|
|
||||||
from documents.models import Workflow
|
from documents.models import Workflow
|
||||||
from documents.models import WorkflowAction
|
from documents.models import WorkflowAction
|
||||||
from documents.models import WorkflowTrigger
|
from documents.models import WorkflowTrigger
|
||||||
@@ -177,7 +176,7 @@ class TestDocumentApi(DirectoriesMixin, DocumentConsumeDelayMixin, APITestCase):
|
|||||||
results = response.data["results"]
|
results = response.data["results"]
|
||||||
self.assertEqual(len(results[0]), 0)
|
self.assertEqual(len(results[0]), 0)
|
||||||
|
|
||||||
def test_document_fields_respects_created(self) -> None:
|
def test_document_fields_api_version_8_respects_created(self) -> None:
|
||||||
Document.objects.create(
|
Document.objects.create(
|
||||||
title="legacy",
|
title="legacy",
|
||||||
checksum="123",
|
checksum="123",
|
||||||
@@ -187,6 +186,7 @@ class TestDocumentApi(DirectoriesMixin, DocumentConsumeDelayMixin, APITestCase):
|
|||||||
|
|
||||||
response = self.client.get(
|
response = self.client.get(
|
||||||
"/api/documents/?fields=id",
|
"/api/documents/?fields=id",
|
||||||
|
headers={"Accept": "application/json; version=8"},
|
||||||
format="json",
|
format="json",
|
||||||
)
|
)
|
||||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||||
@@ -196,22 +196,25 @@ class TestDocumentApi(DirectoriesMixin, DocumentConsumeDelayMixin, APITestCase):
|
|||||||
|
|
||||||
response = self.client.get(
|
response = self.client.get(
|
||||||
"/api/documents/?fields=id,created",
|
"/api/documents/?fields=id,created",
|
||||||
|
headers={"Accept": "application/json; version=8"},
|
||||||
format="json",
|
format="json",
|
||||||
)
|
)
|
||||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||||
results = response.data["results"]
|
results = response.data["results"]
|
||||||
self.assertIn("id", results[0])
|
self.assertIn("id", results[0])
|
||||||
self.assertIn("created", results[0])
|
self.assertIn("created", results[0])
|
||||||
self.assertEqual(results[0]["created"], "2024-01-15")
|
self.assertRegex(results[0]["created"], r"^2024-01-15T00:00:00.*$")
|
||||||
|
|
||||||
def test_document_created_format(self) -> None:
|
def test_document_legacy_created_format(self) -> None:
|
||||||
"""
|
"""
|
||||||
GIVEN:
|
GIVEN:
|
||||||
- Existing document
|
- Existing document
|
||||||
WHEN:
|
WHEN:
|
||||||
- Document is requested
|
- Document is requested with api version ≥ 9
|
||||||
|
- Document is requested with api version < 9
|
||||||
THEN:
|
THEN:
|
||||||
- Document created field is returned as date
|
- Document created field is returned as date
|
||||||
|
- Document created field is returned as datetime
|
||||||
"""
|
"""
|
||||||
doc = Document.objects.create(
|
doc = Document.objects.create(
|
||||||
title="none",
|
title="none",
|
||||||
@@ -222,6 +225,14 @@ class TestDocumentApi(DirectoriesMixin, DocumentConsumeDelayMixin, APITestCase):
|
|||||||
|
|
||||||
response = self.client.get(
|
response = self.client.get(
|
||||||
f"/api/documents/{doc.pk}/",
|
f"/api/documents/{doc.pk}/",
|
||||||
|
headers={"Accept": "application/json; version=8"},
|
||||||
|
)
|
||||||
|
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||||
|
self.assertRegex(response.data["created"], r"^2023-01-01T00:00:00.*$")
|
||||||
|
|
||||||
|
response = self.client.get(
|
||||||
|
f"/api/documents/{doc.pk}/",
|
||||||
|
headers={"Accept": "application/json; version=9"},
|
||||||
)
|
)
|
||||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||||
self.assertEqual(response.data["created"], "2023-01-01")
|
self.assertEqual(response.data["created"], "2023-01-01")
|
||||||
@@ -2189,205 +2200,6 @@ class TestDocumentApi(DirectoriesMixin, DocumentConsumeDelayMixin, APITestCase):
|
|||||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||||
self.assertEqual(response.data["count"], 0)
|
self.assertEqual(response.data["count"], 0)
|
||||||
|
|
||||||
def test_saved_view_api_version_backward_compatibility(self) -> None:
|
|
||||||
"""
|
|
||||||
GIVEN:
|
|
||||||
- Saved views and UiSettings with visibility preferences
|
|
||||||
WHEN:
|
|
||||||
- API request with version=9 (legacy)
|
|
||||||
- API request with version=10 (current)
|
|
||||||
THEN:
|
|
||||||
- Version 9 returns show_on_dashboard and show_in_sidebar from UiSettings
|
|
||||||
- Version 10 omits these fields (moved to UiSettings)
|
|
||||||
"""
|
|
||||||
v1 = SavedView.objects.create(
|
|
||||||
owner=self.user,
|
|
||||||
name="dashboard_view",
|
|
||||||
sort_field="created",
|
|
||||||
)
|
|
||||||
v2 = SavedView.objects.create(
|
|
||||||
owner=self.user,
|
|
||||||
name="sidebar_view",
|
|
||||||
sort_field="created",
|
|
||||||
)
|
|
||||||
v3 = SavedView.objects.create(
|
|
||||||
owner=self.user,
|
|
||||||
name="hidden_view",
|
|
||||||
sort_field="created",
|
|
||||||
)
|
|
||||||
|
|
||||||
UiSettings.objects.update_or_create(
|
|
||||||
user=self.user,
|
|
||||||
defaults={
|
|
||||||
"settings": {
|
|
||||||
"saved_views": {
|
|
||||||
"dashboard_views_visible_ids": [v1.id],
|
|
||||||
"sidebar_views_visible_ids": [v2.id],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
response_v9 = self.client.get(
|
|
||||||
"/api/saved_views/",
|
|
||||||
headers={"Accept": "application/json; version=9"},
|
|
||||||
format="json",
|
|
||||||
)
|
|
||||||
self.assertEqual(response_v9.status_code, status.HTTP_200_OK)
|
|
||||||
results_v9 = {r["id"]: r for r in response_v9.data["results"]}
|
|
||||||
self.assertIn("show_on_dashboard", results_v9[v1.id])
|
|
||||||
self.assertIn("show_in_sidebar", results_v9[v1.id])
|
|
||||||
self.assertTrue(results_v9[v1.id]["show_on_dashboard"])
|
|
||||||
self.assertFalse(results_v9[v1.id]["show_in_sidebar"])
|
|
||||||
self.assertTrue(results_v9[v2.id]["show_in_sidebar"])
|
|
||||||
self.assertFalse(results_v9[v2.id]["show_on_dashboard"])
|
|
||||||
self.assertFalse(results_v9[v3.id]["show_on_dashboard"])
|
|
||||||
self.assertFalse(results_v9[v3.id]["show_in_sidebar"])
|
|
||||||
|
|
||||||
response_v10 = self.client.get(
|
|
||||||
"/api/saved_views/",
|
|
||||||
headers={"Accept": "application/json; version=10"},
|
|
||||||
format="json",
|
|
||||||
)
|
|
||||||
self.assertEqual(response_v10.status_code, status.HTTP_200_OK)
|
|
||||||
results_v10 = {r["id"]: r for r in response_v10.data["results"]}
|
|
||||||
self.assertNotIn("show_on_dashboard", results_v10[v1.id])
|
|
||||||
self.assertNotIn("show_in_sidebar", results_v10[v1.id])
|
|
||||||
|
|
||||||
def test_saved_view_api_version_9_user_without_ui_settings(self) -> None:
|
|
||||||
"""
|
|
||||||
GIVEN:
|
|
||||||
- User with no UiSettings and a saved view
|
|
||||||
WHEN:
|
|
||||||
- API request with version=9
|
|
||||||
THEN:
|
|
||||||
- show_on_dashboard and show_in_sidebar are False (default)
|
|
||||||
"""
|
|
||||||
SavedView.objects.create(
|
|
||||||
owner=self.user,
|
|
||||||
name="test_view",
|
|
||||||
sort_field="created",
|
|
||||||
)
|
|
||||||
UiSettings.objects.filter(user=self.user).delete()
|
|
||||||
|
|
||||||
response = self.client.get(
|
|
||||||
"/api/saved_views/",
|
|
||||||
headers={"Accept": "application/json; version=9"},
|
|
||||||
format="json",
|
|
||||||
)
|
|
||||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
|
||||||
result = response.data["results"][0]
|
|
||||||
self.assertFalse(result["show_on_dashboard"])
|
|
||||||
self.assertFalse(result["show_in_sidebar"])
|
|
||||||
|
|
||||||
def test_saved_view_api_version_9_create_writes_visibility_to_ui_settings(
|
|
||||||
self,
|
|
||||||
) -> None:
|
|
||||||
"""
|
|
||||||
GIVEN:
|
|
||||||
- No UiSettings for the current user
|
|
||||||
WHEN:
|
|
||||||
- A saved view is created through API version 9 with visibility flags
|
|
||||||
THEN:
|
|
||||||
- Visibility is persisted in UiSettings.saved_views
|
|
||||||
"""
|
|
||||||
UiSettings.objects.filter(user=self.user).delete()
|
|
||||||
|
|
||||||
response = self.client.post(
|
|
||||||
"/api/saved_views/",
|
|
||||||
{
|
|
||||||
"name": "legacy-v9-create",
|
|
||||||
"sort_field": "created",
|
|
||||||
"filter_rules": [],
|
|
||||||
"show_on_dashboard": True,
|
|
||||||
"show_in_sidebar": False,
|
|
||||||
},
|
|
||||||
headers={"Accept": "application/json; version=9"},
|
|
||||||
format="json",
|
|
||||||
)
|
|
||||||
self.assertEqual(response.status_code, status.HTTP_201_CREATED)
|
|
||||||
self.assertTrue(response.data["show_on_dashboard"])
|
|
||||||
self.assertFalse(response.data["show_in_sidebar"])
|
|
||||||
|
|
||||||
self.user.refresh_from_db()
|
|
||||||
self.assertTrue(hasattr(self.user, "ui_settings"))
|
|
||||||
saved_view_settings = self.user.ui_settings.settings["saved_views"]
|
|
||||||
self.assertListEqual(
|
|
||||||
saved_view_settings["dashboard_views_visible_ids"],
|
|
||||||
[response.data["id"]],
|
|
||||||
)
|
|
||||||
self.assertListEqual(saved_view_settings["sidebar_views_visible_ids"], [])
|
|
||||||
|
|
||||||
def test_saved_view_api_version_9_patch_writes_visibility_to_ui_settings(
|
|
||||||
self,
|
|
||||||
) -> None:
|
|
||||||
"""
|
|
||||||
GIVEN:
|
|
||||||
- Existing saved views and UiSettings visibility ids
|
|
||||||
WHEN:
|
|
||||||
- A saved view is updated through API version 9 visibility flags
|
|
||||||
THEN:
|
|
||||||
- The per-user UiSettings visibility ids are updated
|
|
||||||
"""
|
|
||||||
v1 = SavedView.objects.create(
|
|
||||||
owner=self.user,
|
|
||||||
name="legacy-v9-patch-1",
|
|
||||||
sort_field="created",
|
|
||||||
)
|
|
||||||
v2 = SavedView.objects.create(
|
|
||||||
owner=self.user,
|
|
||||||
name="legacy-v9-patch-2",
|
|
||||||
sort_field="created",
|
|
||||||
)
|
|
||||||
UiSettings.objects.update_or_create(
|
|
||||||
user=self.user,
|
|
||||||
defaults={
|
|
||||||
"settings": {
|
|
||||||
"saved_views": {
|
|
||||||
"dashboard_views_visible_ids": [v1.id],
|
|
||||||
"sidebar_views_visible_ids": [v1.id, v2.id],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
response = self.client.patch(
|
|
||||||
f"/api/saved_views/{v1.id}/",
|
|
||||||
{
|
|
||||||
"show_on_dashboard": False,
|
|
||||||
},
|
|
||||||
headers={"Accept": "application/json; version=9"},
|
|
||||||
format="json",
|
|
||||||
)
|
|
||||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
|
||||||
self.assertFalse(response.data["show_on_dashboard"])
|
|
||||||
self.assertTrue(response.data["show_in_sidebar"])
|
|
||||||
|
|
||||||
self.user.refresh_from_db()
|
|
||||||
saved_view_settings = self.user.ui_settings.settings["saved_views"]
|
|
||||||
self.assertListEqual(saved_view_settings["dashboard_views_visible_ids"], [])
|
|
||||||
self.assertListEqual(
|
|
||||||
saved_view_settings["sidebar_views_visible_ids"],
|
|
||||||
[v1.id, v2.id],
|
|
||||||
)
|
|
||||||
|
|
||||||
response = self.client.patch(
|
|
||||||
f"/api/saved_views/{v1.id}/",
|
|
||||||
{
|
|
||||||
"show_in_sidebar": False,
|
|
||||||
},
|
|
||||||
headers={"Accept": "application/json; version=9"},
|
|
||||||
format="json",
|
|
||||||
)
|
|
||||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
|
||||||
self.assertFalse(response.data["show_on_dashboard"])
|
|
||||||
self.assertFalse(response.data["show_in_sidebar"])
|
|
||||||
|
|
||||||
self.user.refresh_from_db()
|
|
||||||
saved_view_settings = self.user.ui_settings.settings["saved_views"]
|
|
||||||
self.assertListEqual(saved_view_settings["dashboard_views_visible_ids"], [])
|
|
||||||
self.assertListEqual(saved_view_settings["sidebar_views_visible_ids"], [v2.id])
|
|
||||||
|
|
||||||
def test_saved_view_create_update_patch(self) -> None:
|
def test_saved_view_create_update_patch(self) -> None:
|
||||||
User.objects.create_user("user1")
|
User.objects.create_user("user1")
|
||||||
|
|
||||||
@@ -2791,6 +2603,26 @@ class TestDocumentApi(DirectoriesMixin, DocumentConsumeDelayMixin, APITestCase):
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def test_docnote_serializer_v7(self) -> None:
|
||||||
|
doc = Document.objects.create(
|
||||||
|
title="test",
|
||||||
|
mime_type="application/pdf",
|
||||||
|
content="this is a document which will have notes!",
|
||||||
|
)
|
||||||
|
Note.objects.create(
|
||||||
|
note="This is a note.",
|
||||||
|
document=doc,
|
||||||
|
user=self.user,
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
self.client.get(
|
||||||
|
f"/api/documents/{doc.pk}/",
|
||||||
|
headers={"Accept": "application/json; version=7"},
|
||||||
|
format="json",
|
||||||
|
).data["notes"][0]["user"],
|
||||||
|
self.user.id,
|
||||||
|
)
|
||||||
|
|
||||||
def test_create_note(self) -> None:
|
def test_create_note(self) -> None:
|
||||||
"""
|
"""
|
||||||
GIVEN:
|
GIVEN:
|
||||||
@@ -3559,13 +3391,14 @@ class TestDocumentApi(DirectoriesMixin, DocumentConsumeDelayMixin, APITestCase):
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
class TestDocumentApiTagColors(DirectoriesMixin, APITestCase):
|
class TestDocumentApiV2(DirectoriesMixin, APITestCase):
|
||||||
def setUp(self) -> None:
|
def setUp(self) -> None:
|
||||||
super().setUp()
|
super().setUp()
|
||||||
|
|
||||||
self.user = User.objects.create_superuser(username="temp_admin")
|
self.user = User.objects.create_superuser(username="temp_admin")
|
||||||
|
|
||||||
self.client.force_authenticate(user=self.user)
|
self.client.force_authenticate(user=self.user)
|
||||||
|
self.client.defaults["HTTP_ACCEPT"] = "application/json; version=2"
|
||||||
|
|
||||||
def test_tag_validate_color(self) -> None:
|
def test_tag_validate_color(self) -> None:
|
||||||
self.assertEqual(
|
self.assertEqual(
|
||||||
|
|||||||
@@ -152,7 +152,7 @@ class TestCustomFieldsSearch(DirectoriesMixin, APITestCase):
|
|||||||
context={
|
context={
|
||||||
"request": types.SimpleNamespace(
|
"request": types.SimpleNamespace(
|
||||||
method="GET",
|
method="GET",
|
||||||
version="9",
|
version="7",
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -25,39 +25,3 @@ class TestApiSchema(APITestCase):
|
|||||||
|
|
||||||
ui_response = self.client.get(self.ENDPOINT + "view/")
|
ui_response = self.client.get(self.ENDPOINT + "view/")
|
||||||
self.assertEqual(ui_response.status_code, status.HTTP_200_OK)
|
self.assertEqual(ui_response.status_code, status.HTTP_200_OK)
|
||||||
|
|
||||||
def test_schema_includes_dedicated_document_edit_endpoints(self) -> None:
|
|
||||||
schema_response = self.client.get(self.ENDPOINT)
|
|
||||||
self.assertEqual(schema_response.status_code, status.HTTP_200_OK)
|
|
||||||
|
|
||||||
paths = schema_response.data["paths"]
|
|
||||||
self.assertIn("/api/documents/delete/", paths)
|
|
||||||
self.assertIn("/api/documents/reprocess/", paths)
|
|
||||||
self.assertIn("/api/documents/rotate/", paths)
|
|
||||||
self.assertIn("/api/documents/merge/", paths)
|
|
||||||
self.assertIn("/api/documents/edit_pdf/", paths)
|
|
||||||
self.assertIn("/api/documents/remove_password/", paths)
|
|
||||||
|
|
||||||
def test_schema_bulk_edit_advertises_legacy_document_action_methods(self) -> None:
|
|
||||||
schema_response = self.client.get(self.ENDPOINT)
|
|
||||||
self.assertEqual(schema_response.status_code, status.HTTP_200_OK)
|
|
||||||
|
|
||||||
schema = schema_response.data["components"]["schemas"]
|
|
||||||
bulk_schema = schema["BulkEditRequest"]
|
|
||||||
method_schema = bulk_schema["properties"]["method"]
|
|
||||||
|
|
||||||
# drf-spectacular emits the enum as a referenced schema for this field
|
|
||||||
enum_ref = method_schema["allOf"][0]["$ref"].split("/")[-1]
|
|
||||||
advertised_methods = schema[enum_ref]["enum"]
|
|
||||||
|
|
||||||
for action_method in [
|
|
||||||
"delete",
|
|
||||||
"reprocess",
|
|
||||||
"rotate",
|
|
||||||
"merge",
|
|
||||||
"edit_pdf",
|
|
||||||
"remove_password",
|
|
||||||
"split",
|
|
||||||
"delete_pages",
|
|
||||||
]:
|
|
||||||
self.assertIn(action_method, advertised_methods)
|
|
||||||
|
|||||||
@@ -156,46 +156,6 @@ class TestDocument(TestCase):
|
|||||||
)
|
)
|
||||||
self.assertEqual(doc.get_public_filename(), "2020-12-25 test")
|
self.assertEqual(doc.get_public_filename(), "2020-12-25 test")
|
||||||
|
|
||||||
def test_suggestion_content_uses_latest_version_content_for_root_documents(
|
|
||||||
self,
|
|
||||||
) -> None:
|
|
||||||
root = Document.objects.create(
|
|
||||||
title="root",
|
|
||||||
checksum="root",
|
|
||||||
mime_type="application/pdf",
|
|
||||||
content="outdated root content",
|
|
||||||
)
|
|
||||||
version = Document.objects.create(
|
|
||||||
title="v1",
|
|
||||||
checksum="v1",
|
|
||||||
mime_type="application/pdf",
|
|
||||||
root_document=root,
|
|
||||||
content="latest version content",
|
|
||||||
)
|
|
||||||
|
|
||||||
self.assertEqual(root.suggestion_content, version.content)
|
|
||||||
|
|
||||||
def test_content_length_is_per_document_row_for_versions(self) -> None:
|
|
||||||
root = Document.objects.create(
|
|
||||||
title="root",
|
|
||||||
checksum="root",
|
|
||||||
mime_type="application/pdf",
|
|
||||||
content="abc",
|
|
||||||
)
|
|
||||||
version = Document.objects.create(
|
|
||||||
title="v1",
|
|
||||||
checksum="v1",
|
|
||||||
mime_type="application/pdf",
|
|
||||||
root_document=root,
|
|
||||||
content="abcdefgh",
|
|
||||||
)
|
|
||||||
|
|
||||||
root.refresh_from_db()
|
|
||||||
version.refresh_from_db()
|
|
||||||
|
|
||||||
self.assertEqual(root.content_length, 3)
|
|
||||||
self.assertEqual(version.content_length, 8)
|
|
||||||
|
|
||||||
|
|
||||||
def test_suggestion_content() -> None:
|
def test_suggestion_content() -> None:
|
||||||
"""
|
"""
|
||||||
|
|||||||
@@ -147,6 +147,7 @@ class TestExportImport(
|
|||||||
else:
|
else:
|
||||||
raise ValueError(f"document with id {id} does not exist in manifest")
|
raise ValueError(f"document with id {id} does not exist in manifest")
|
||||||
|
|
||||||
|
@override_settings(PASSPHRASE="test")
|
||||||
def _do_export(
|
def _do_export(
|
||||||
self,
|
self,
|
||||||
*,
|
*,
|
||||||
@@ -440,6 +441,7 @@ class TestExportImport(
|
|||||||
)
|
)
|
||||||
self.assertRaises(FileNotFoundError, call_command, "document_exporter", target)
|
self.assertRaises(FileNotFoundError, call_command, "document_exporter", target)
|
||||||
|
|
||||||
|
@override_settings(PASSPHRASE="test")
|
||||||
def test_export_zipped(self) -> None:
|
def test_export_zipped(self) -> None:
|
||||||
"""
|
"""
|
||||||
GIVEN:
|
GIVEN:
|
||||||
@@ -471,6 +473,7 @@ class TestExportImport(
|
|||||||
self.assertIn("manifest.json", zip.namelist())
|
self.assertIn("manifest.json", zip.namelist())
|
||||||
self.assertIn("metadata.json", zip.namelist())
|
self.assertIn("metadata.json", zip.namelist())
|
||||||
|
|
||||||
|
@override_settings(PASSPHRASE="test")
|
||||||
def test_export_zipped_format(self) -> None:
|
def test_export_zipped_format(self) -> None:
|
||||||
"""
|
"""
|
||||||
GIVEN:
|
GIVEN:
|
||||||
@@ -507,6 +510,7 @@ class TestExportImport(
|
|||||||
self.assertIn("manifest.json", zip.namelist())
|
self.assertIn("manifest.json", zip.namelist())
|
||||||
self.assertIn("metadata.json", zip.namelist())
|
self.assertIn("metadata.json", zip.namelist())
|
||||||
|
|
||||||
|
@override_settings(PASSPHRASE="test")
|
||||||
def test_export_zipped_with_delete(self) -> None:
|
def test_export_zipped_with_delete(self) -> None:
|
||||||
"""
|
"""
|
||||||
GIVEN:
|
GIVEN:
|
||||||
@@ -749,31 +753,6 @@ class TestExportImport(
|
|||||||
call_command("document_importer", "--no-progress-bar", self.target)
|
call_command("document_importer", "--no-progress-bar", self.target)
|
||||||
self.assertEqual(Document.objects.count(), 4)
|
self.assertEqual(Document.objects.count(), 4)
|
||||||
|
|
||||||
def test_folder_prefix_with_split(self) -> None:
|
|
||||||
"""
|
|
||||||
GIVEN:
|
|
||||||
- Request to export documents to directory
|
|
||||||
WHEN:
|
|
||||||
- Option use_folder_prefix is used
|
|
||||||
- Option split manifest is used
|
|
||||||
THEN:
|
|
||||||
- Documents can be imported again
|
|
||||||
"""
|
|
||||||
shutil.rmtree(Path(self.dirs.media_dir) / "documents")
|
|
||||||
shutil.copytree(
|
|
||||||
Path(__file__).parent / "samples" / "documents",
|
|
||||||
Path(self.dirs.media_dir) / "documents",
|
|
||||||
)
|
|
||||||
|
|
||||||
self._do_export(use_folder_prefix=True, split_manifest=True)
|
|
||||||
|
|
||||||
with paperless_environment():
|
|
||||||
self.assertEqual(Document.objects.count(), 4)
|
|
||||||
Document.objects.all().delete()
|
|
||||||
self.assertEqual(Document.objects.count(), 0)
|
|
||||||
call_command("document_importer", "--no-progress-bar", self.target)
|
|
||||||
self.assertEqual(Document.objects.count(), 4)
|
|
||||||
|
|
||||||
def test_import_db_transaction_failed(self) -> None:
|
def test_import_db_transaction_failed(self) -> None:
|
||||||
"""
|
"""
|
||||||
GIVEN:
|
GIVEN:
|
||||||
|
|||||||
@@ -119,22 +119,15 @@ class TestCommandImport(
|
|||||||
# No read permissions
|
# No read permissions
|
||||||
original_path.chmod(0o222)
|
original_path.chmod(0o222)
|
||||||
|
|
||||||
manifest_path = Path(temp_dir) / "manifest.json"
|
|
||||||
manifest_path.write_text(
|
|
||||||
json.dumps(
|
|
||||||
[
|
|
||||||
{
|
|
||||||
"model": "documents.document",
|
|
||||||
EXPORTER_FILE_NAME: "original.pdf",
|
|
||||||
EXPORTER_ARCHIVE_NAME: "archive.pdf",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
cmd = Command()
|
cmd = Command()
|
||||||
cmd.source = Path(temp_dir)
|
cmd.source = Path(temp_dir)
|
||||||
cmd.manifest_paths = [manifest_path]
|
cmd.manifest = [
|
||||||
|
{
|
||||||
|
"model": "documents.document",
|
||||||
|
EXPORTER_FILE_NAME: "original.pdf",
|
||||||
|
EXPORTER_ARCHIVE_NAME: "archive.pdf",
|
||||||
|
},
|
||||||
|
]
|
||||||
cmd.data_only = False
|
cmd.data_only = False
|
||||||
with self.assertRaises(CommandError) as cm:
|
with self.assertRaises(CommandError) as cm:
|
||||||
cmd.check_manifest_validity()
|
cmd.check_manifest_validity()
|
||||||
@@ -303,7 +296,7 @@ class TestCommandImport(
|
|||||||
(self.dirs.scratch_dir / "manifest.json").touch()
|
(self.dirs.scratch_dir / "manifest.json").touch()
|
||||||
|
|
||||||
# We're not building a manifest, so it fails, but this test doesn't care
|
# We're not building a manifest, so it fails, but this test doesn't care
|
||||||
with self.assertRaises(CommandError):
|
with self.assertRaises(json.decoder.JSONDecodeError):
|
||||||
call_command(
|
call_command(
|
||||||
"document_importer",
|
"document_importer",
|
||||||
"--no-progress-bar",
|
"--no-progress-bar",
|
||||||
@@ -332,7 +325,7 @@ class TestCommandImport(
|
|||||||
)
|
)
|
||||||
|
|
||||||
# We're not building a manifest, so it fails, but this test doesn't care
|
# We're not building a manifest, so it fails, but this test doesn't care
|
||||||
with self.assertRaises(CommandError):
|
with self.assertRaises(json.decoder.JSONDecodeError):
|
||||||
call_command(
|
call_command(
|
||||||
"document_importer",
|
"document_importer",
|
||||||
"--no-progress-bar",
|
"--no-progress-bar",
|
||||||
|
|||||||
@@ -48,52 +48,6 @@ class _TestMatchingBase(TestCase):
|
|||||||
|
|
||||||
|
|
||||||
class TestMatching(_TestMatchingBase):
|
class TestMatching(_TestMatchingBase):
|
||||||
def test_matches_uses_latest_version_content_for_root_documents(self) -> None:
|
|
||||||
root = Document.objects.create(
|
|
||||||
title="root",
|
|
||||||
checksum="root",
|
|
||||||
mime_type="application/pdf",
|
|
||||||
content="root content without token",
|
|
||||||
)
|
|
||||||
Document.objects.create(
|
|
||||||
title="v1",
|
|
||||||
checksum="v1",
|
|
||||||
mime_type="application/pdf",
|
|
||||||
root_document=root,
|
|
||||||
content="latest version contains keyword",
|
|
||||||
)
|
|
||||||
tag = Tag.objects.create(
|
|
||||||
name="tag",
|
|
||||||
match="keyword",
|
|
||||||
matching_algorithm=Tag.MATCH_ANY,
|
|
||||||
)
|
|
||||||
|
|
||||||
self.assertTrue(matching.matches(tag, root))
|
|
||||||
|
|
||||||
def test_matches_does_not_fall_back_to_root_content_when_version_exists(
|
|
||||||
self,
|
|
||||||
) -> None:
|
|
||||||
root = Document.objects.create(
|
|
||||||
title="root",
|
|
||||||
checksum="root",
|
|
||||||
mime_type="application/pdf",
|
|
||||||
content="root contains keyword",
|
|
||||||
)
|
|
||||||
Document.objects.create(
|
|
||||||
title="v1",
|
|
||||||
checksum="v1",
|
|
||||||
mime_type="application/pdf",
|
|
||||||
root_document=root,
|
|
||||||
content="latest version without token",
|
|
||||||
)
|
|
||||||
tag = Tag.objects.create(
|
|
||||||
name="tag",
|
|
||||||
match="keyword",
|
|
||||||
matching_algorithm=Tag.MATCH_ANY,
|
|
||||||
)
|
|
||||||
|
|
||||||
self.assertFalse(matching.matches(tag, root))
|
|
||||||
|
|
||||||
def test_match_none(self) -> None:
|
def test_match_none(self) -> None:
|
||||||
self._test_matching(
|
self._test_matching(
|
||||||
"",
|
"",
|
||||||
|
|||||||
@@ -6,8 +6,8 @@ SIDEBAR_VIEWS_VISIBLE_IDS_KEY = "sidebar_views_visible_ids"
|
|||||||
|
|
||||||
|
|
||||||
class TestMigrateSavedViewVisibilityToUiSettings(TestMigrations):
|
class TestMigrateSavedViewVisibilityToUiSettings(TestMigrations):
|
||||||
migrate_from = "0013_alter_paperlesstask_task_name"
|
migrate_from = "0013_document_root_document"
|
||||||
migrate_to = "0014_savedview_visibility_to_ui_settings"
|
migrate_to = "0015_savedview_visibility_to_ui_settings"
|
||||||
|
|
||||||
def setUpBeforeMigration(self, apps) -> None:
|
def setUpBeforeMigration(self, apps) -> None:
|
||||||
User = apps.get_model("auth", "User")
|
User = apps.get_model("auth", "User")
|
||||||
@@ -132,8 +132,8 @@ class TestMigrateSavedViewVisibilityToUiSettings(TestMigrations):
|
|||||||
|
|
||||||
|
|
||||||
class TestReverseMigrateSavedViewVisibilityFromUiSettings(TestMigrations):
|
class TestReverseMigrateSavedViewVisibilityFromUiSettings(TestMigrations):
|
||||||
migrate_from = "0014_savedview_visibility_to_ui_settings"
|
migrate_from = "0015_savedview_visibility_to_ui_settings"
|
||||||
migrate_to = "0013_alter_paperlesstask_task_name"
|
migrate_to = "0014_alter_paperlesstask_task_name"
|
||||||
|
|
||||||
def setUpBeforeMigration(self, apps) -> None:
|
def setUpBeforeMigration(self, apps) -> None:
|
||||||
User = apps.get_model("auth", "User")
|
User = apps.get_model("auth", "User")
|
||||||
|
|||||||
@@ -2,8 +2,8 @@ from documents.tests.utils import TestMigrations
|
|||||||
|
|
||||||
|
|
||||||
class TestMigrateShareLinkBundlePermissions(TestMigrations):
|
class TestMigrateShareLinkBundlePermissions(TestMigrations):
|
||||||
migrate_from = "0006_document_content_length"
|
migrate_from = "0007_document_content_length"
|
||||||
migrate_to = "0007_sharelinkbundle"
|
migrate_to = "0008_sharelinkbundle"
|
||||||
|
|
||||||
def setUpBeforeMigration(self, apps) -> None:
|
def setUpBeforeMigration(self, apps) -> None:
|
||||||
User = apps.get_model("auth", "User")
|
User = apps.get_model("auth", "User")
|
||||||
@@ -24,8 +24,8 @@ class TestMigrateShareLinkBundlePermissions(TestMigrations):
|
|||||||
|
|
||||||
|
|
||||||
class TestReverseMigrateShareLinkBundlePermissions(TestMigrations):
|
class TestReverseMigrateShareLinkBundlePermissions(TestMigrations):
|
||||||
migrate_from = "0007_sharelinkbundle"
|
migrate_from = "0008_sharelinkbundle"
|
||||||
migrate_to = "0006_document_content_length"
|
migrate_to = "0007_document_content_length"
|
||||||
|
|
||||||
def setUpBeforeMigration(self, apps) -> None:
|
def setUpBeforeMigration(self, apps) -> None:
|
||||||
User = apps.get_model("auth", "User")
|
User = apps.get_model("auth", "User")
|
||||||
|
|||||||
@@ -176,20 +176,14 @@ from documents.serialisers import BulkEditObjectsSerializer
|
|||||||
from documents.serialisers import BulkEditSerializer
|
from documents.serialisers import BulkEditSerializer
|
||||||
from documents.serialisers import CorrespondentSerializer
|
from documents.serialisers import CorrespondentSerializer
|
||||||
from documents.serialisers import CustomFieldSerializer
|
from documents.serialisers import CustomFieldSerializer
|
||||||
from documents.serialisers import DeleteDocumentsSerializer
|
|
||||||
from documents.serialisers import DocumentListSerializer
|
from documents.serialisers import DocumentListSerializer
|
||||||
from documents.serialisers import DocumentSerializer
|
from documents.serialisers import DocumentSerializer
|
||||||
from documents.serialisers import DocumentTypeSerializer
|
from documents.serialisers import DocumentTypeSerializer
|
||||||
from documents.serialisers import DocumentVersionLabelSerializer
|
from documents.serialisers import DocumentVersionLabelSerializer
|
||||||
from documents.serialisers import DocumentVersionSerializer
|
from documents.serialisers import DocumentVersionSerializer
|
||||||
from documents.serialisers import EditPdfDocumentsSerializer
|
|
||||||
from documents.serialisers import EmailSerializer
|
from documents.serialisers import EmailSerializer
|
||||||
from documents.serialisers import MergeDocumentsSerializer
|
|
||||||
from documents.serialisers import NotesSerializer
|
from documents.serialisers import NotesSerializer
|
||||||
from documents.serialisers import PostDocumentSerializer
|
from documents.serialisers import PostDocumentSerializer
|
||||||
from documents.serialisers import RemovePasswordDocumentsSerializer
|
|
||||||
from documents.serialisers import ReprocessDocumentsSerializer
|
|
||||||
from documents.serialisers import RotateDocumentsSerializer
|
|
||||||
from documents.serialisers import RunTaskViewSerializer
|
from documents.serialisers import RunTaskViewSerializer
|
||||||
from documents.serialisers import SavedViewSerializer
|
from documents.serialisers import SavedViewSerializer
|
||||||
from documents.serialisers import SearchResultSerializer
|
from documents.serialisers import SearchResultSerializer
|
||||||
@@ -1528,17 +1522,13 @@ class DocumentViewSet(
|
|||||||
|
|
||||||
return Response(sorted(entries, key=lambda x: x["timestamp"], reverse=True))
|
return Response(sorted(entries, key=lambda x: x["timestamp"], reverse=True))
|
||||||
|
|
||||||
@extend_schema(
|
|
||||||
operation_id="documents_email_document",
|
|
||||||
deprecated=True,
|
|
||||||
)
|
|
||||||
@action(
|
@action(
|
||||||
methods=["post"],
|
methods=["post"],
|
||||||
detail=True,
|
detail=True,
|
||||||
url_path="email",
|
url_path="email",
|
||||||
permission_classes=[IsAuthenticated, ViewDocumentsPermissions],
|
permission_classes=[IsAuthenticated, ViewDocumentsPermissions],
|
||||||
)
|
)
|
||||||
# TODO: deprecated, remove with drop of support for API v9
|
# TODO: deprecated as of 2.19, remove in future release
|
||||||
def email_document(self, request, pk=None):
|
def email_document(self, request, pk=None):
|
||||||
request_data = request.data.copy()
|
request_data = request.data.copy()
|
||||||
request_data.setlist("documents", [pk])
|
request_data.setlist("documents", [pk])
|
||||||
@@ -2124,125 +2114,6 @@ class SavedViewViewSet(BulkPermissionMixin, PassUserMixin, ModelViewSet):
|
|||||||
ordering_fields = ("name",)
|
ordering_fields = ("name",)
|
||||||
|
|
||||||
|
|
||||||
class DocumentOperationPermissionMixin(PassUserMixin):
|
|
||||||
permission_classes = (IsAuthenticated,)
|
|
||||||
parser_classes = (parsers.JSONParser,)
|
|
||||||
METHOD_NAMES_REQUIRING_USER = {
|
|
||||||
"split",
|
|
||||||
"merge",
|
|
||||||
"rotate",
|
|
||||||
"delete_pages",
|
|
||||||
"edit_pdf",
|
|
||||||
"remove_password",
|
|
||||||
}
|
|
||||||
|
|
||||||
def _has_document_permissions(
|
|
||||||
self,
|
|
||||||
*,
|
|
||||||
user: User,
|
|
||||||
documents: list[int],
|
|
||||||
method,
|
|
||||||
parameters: dict[str, Any],
|
|
||||||
) -> bool:
|
|
||||||
if user.is_superuser:
|
|
||||||
return True
|
|
||||||
|
|
||||||
document_objs = Document.objects.select_related("owner").filter(
|
|
||||||
pk__in=documents,
|
|
||||||
)
|
|
||||||
user_is_owner_of_all_documents = all(
|
|
||||||
(doc.owner == user or doc.owner is None) for doc in document_objs
|
|
||||||
)
|
|
||||||
|
|
||||||
# check global and object permissions for all documents
|
|
||||||
has_perms = user.has_perm("documents.change_document") and all(
|
|
||||||
has_perms_owner_aware(user, "change_document", doc) for doc in document_objs
|
|
||||||
)
|
|
||||||
|
|
||||||
# check ownership for methods that change original document
|
|
||||||
if (
|
|
||||||
(
|
|
||||||
has_perms
|
|
||||||
and method
|
|
||||||
in [
|
|
||||||
bulk_edit.set_permissions,
|
|
||||||
bulk_edit.delete,
|
|
||||||
bulk_edit.rotate,
|
|
||||||
bulk_edit.delete_pages,
|
|
||||||
bulk_edit.edit_pdf,
|
|
||||||
bulk_edit.remove_password,
|
|
||||||
]
|
|
||||||
)
|
|
||||||
or (
|
|
||||||
method in [bulk_edit.merge, bulk_edit.split]
|
|
||||||
and parameters.get("delete_originals")
|
|
||||||
)
|
|
||||||
or (method == bulk_edit.edit_pdf and parameters.get("update_document"))
|
|
||||||
):
|
|
||||||
has_perms = user_is_owner_of_all_documents
|
|
||||||
|
|
||||||
# check global add permissions for methods that create documents
|
|
||||||
if (
|
|
||||||
has_perms
|
|
||||||
and (
|
|
||||||
method in [bulk_edit.split, bulk_edit.merge]
|
|
||||||
or (
|
|
||||||
method in [bulk_edit.edit_pdf, bulk_edit.remove_password]
|
|
||||||
and not parameters.get("update_document")
|
|
||||||
)
|
|
||||||
)
|
|
||||||
and not user.has_perm("documents.add_document")
|
|
||||||
):
|
|
||||||
has_perms = False
|
|
||||||
|
|
||||||
# check global delete permissions for methods that delete documents
|
|
||||||
if (
|
|
||||||
has_perms
|
|
||||||
and (
|
|
||||||
method == bulk_edit.delete
|
|
||||||
or (
|
|
||||||
method in [bulk_edit.merge, bulk_edit.split]
|
|
||||||
and parameters.get("delete_originals")
|
|
||||||
)
|
|
||||||
)
|
|
||||||
and not user.has_perm("documents.delete_document")
|
|
||||||
):
|
|
||||||
has_perms = False
|
|
||||||
|
|
||||||
return has_perms
|
|
||||||
|
|
||||||
def _execute_document_action(
|
|
||||||
self,
|
|
||||||
*,
|
|
||||||
method,
|
|
||||||
validated_data: dict[str, Any],
|
|
||||||
operation_label: str,
|
|
||||||
):
|
|
||||||
documents = validated_data["documents"]
|
|
||||||
parameters = {k: v for k, v in validated_data.items() if k != "documents"}
|
|
||||||
user = self.request.user
|
|
||||||
|
|
||||||
if method.__name__ in self.METHOD_NAMES_REQUIRING_USER:
|
|
||||||
parameters["user"] = user
|
|
||||||
|
|
||||||
if not self._has_document_permissions(
|
|
||||||
user=user,
|
|
||||||
documents=documents,
|
|
||||||
method=method,
|
|
||||||
parameters=parameters,
|
|
||||||
):
|
|
||||||
return HttpResponseForbidden("Insufficient permissions")
|
|
||||||
|
|
||||||
try:
|
|
||||||
result = method(documents, **parameters)
|
|
||||||
return Response({"result": result})
|
|
||||||
except Exception as e:
|
|
||||||
logger.warning(f"An error occurred performing {operation_label}: {e!s}")
|
|
||||||
return HttpResponseBadRequest(
|
|
||||||
f"Error performing {operation_label}, check logs for more detail.",
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@extend_schema_view(
|
@extend_schema_view(
|
||||||
post=extend_schema(
|
post=extend_schema(
|
||||||
operation_id="bulk_edit",
|
operation_id="bulk_edit",
|
||||||
@@ -2261,7 +2132,7 @@ class DocumentOperationPermissionMixin(PassUserMixin):
|
|||||||
},
|
},
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
class BulkEditView(DocumentOperationPermissionMixin):
|
class BulkEditView(PassUserMixin):
|
||||||
MODIFIED_FIELD_BY_METHOD = {
|
MODIFIED_FIELD_BY_METHOD = {
|
||||||
"set_correspondent": "correspondent",
|
"set_correspondent": "correspondent",
|
||||||
"set_document_type": "document_type",
|
"set_document_type": "document_type",
|
||||||
@@ -2283,24 +2154,11 @@ class BulkEditView(DocumentOperationPermissionMixin):
|
|||||||
"remove_password": None,
|
"remove_password": None,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
permission_classes = (IsAuthenticated,)
|
||||||
serializer_class = BulkEditSerializer
|
serializer_class = BulkEditSerializer
|
||||||
|
parser_classes = (parsers.JSONParser,)
|
||||||
|
|
||||||
def post(self, request, *args, **kwargs):
|
def post(self, request, *args, **kwargs):
|
||||||
request_method = request.data.get("method")
|
|
||||||
api_version = int(request.version or settings.REST_FRAMEWORK["DEFAULT_VERSION"])
|
|
||||||
# TODO: remove this and related backwards compatibility code when API v9 is dropped
|
|
||||||
if request_method in BulkEditSerializer.LEGACY_DOCUMENT_ACTION_METHODS:
|
|
||||||
endpoint = BulkEditSerializer.MOVED_DOCUMENT_ACTION_ENDPOINTS[
|
|
||||||
request_method
|
|
||||||
]
|
|
||||||
logger.warning(
|
|
||||||
"Deprecated bulk_edit method '%s' requested on API version %s. "
|
|
||||||
"Use '%s' instead.",
|
|
||||||
request_method,
|
|
||||||
api_version,
|
|
||||||
endpoint,
|
|
||||||
)
|
|
||||||
|
|
||||||
serializer = self.get_serializer(data=request.data)
|
serializer = self.get_serializer(data=request.data)
|
||||||
serializer.is_valid(raise_exception=True)
|
serializer.is_valid(raise_exception=True)
|
||||||
|
|
||||||
@@ -2308,15 +2166,82 @@ class BulkEditView(DocumentOperationPermissionMixin):
|
|||||||
method = serializer.validated_data.get("method")
|
method = serializer.validated_data.get("method")
|
||||||
parameters = serializer.validated_data.get("parameters")
|
parameters = serializer.validated_data.get("parameters")
|
||||||
documents = serializer.validated_data.get("documents")
|
documents = serializer.validated_data.get("documents")
|
||||||
if method.__name__ in self.METHOD_NAMES_REQUIRING_USER:
|
if method in [
|
||||||
|
bulk_edit.split,
|
||||||
|
bulk_edit.merge,
|
||||||
|
bulk_edit.rotate,
|
||||||
|
bulk_edit.delete_pages,
|
||||||
|
bulk_edit.edit_pdf,
|
||||||
|
bulk_edit.remove_password,
|
||||||
|
]:
|
||||||
parameters["user"] = user
|
parameters["user"] = user
|
||||||
if not self._has_document_permissions(
|
|
||||||
user=user,
|
if not user.is_superuser:
|
||||||
documents=documents,
|
document_objs = Document.objects.select_related("owner").filter(
|
||||||
method=method,
|
pk__in=documents,
|
||||||
parameters=parameters,
|
)
|
||||||
):
|
user_is_owner_of_all_documents = all(
|
||||||
return HttpResponseForbidden("Insufficient permissions")
|
(doc.owner == user or doc.owner is None) for doc in document_objs
|
||||||
|
)
|
||||||
|
|
||||||
|
# check global and object permissions for all documents
|
||||||
|
has_perms = user.has_perm("documents.change_document") and all(
|
||||||
|
has_perms_owner_aware(user, "change_document", doc)
|
||||||
|
for doc in document_objs
|
||||||
|
)
|
||||||
|
|
||||||
|
# check ownership for methods that change original document
|
||||||
|
if (
|
||||||
|
(
|
||||||
|
has_perms
|
||||||
|
and method
|
||||||
|
in [
|
||||||
|
bulk_edit.set_permissions,
|
||||||
|
bulk_edit.delete,
|
||||||
|
bulk_edit.rotate,
|
||||||
|
bulk_edit.delete_pages,
|
||||||
|
bulk_edit.edit_pdf,
|
||||||
|
bulk_edit.remove_password,
|
||||||
|
]
|
||||||
|
)
|
||||||
|
or (
|
||||||
|
method in [bulk_edit.merge, bulk_edit.split]
|
||||||
|
and parameters["delete_originals"]
|
||||||
|
)
|
||||||
|
or (method == bulk_edit.edit_pdf and parameters["update_document"])
|
||||||
|
):
|
||||||
|
has_perms = user_is_owner_of_all_documents
|
||||||
|
|
||||||
|
# check global add permissions for methods that create documents
|
||||||
|
if (
|
||||||
|
has_perms
|
||||||
|
and (
|
||||||
|
method in [bulk_edit.split, bulk_edit.merge]
|
||||||
|
or (
|
||||||
|
method in [bulk_edit.edit_pdf, bulk_edit.remove_password]
|
||||||
|
and not parameters["update_document"]
|
||||||
|
)
|
||||||
|
)
|
||||||
|
and not user.has_perm("documents.add_document")
|
||||||
|
):
|
||||||
|
has_perms = False
|
||||||
|
|
||||||
|
# check global delete permissions for methods that delete documents
|
||||||
|
if (
|
||||||
|
has_perms
|
||||||
|
and (
|
||||||
|
method == bulk_edit.delete
|
||||||
|
or (
|
||||||
|
method in [bulk_edit.merge, bulk_edit.split]
|
||||||
|
and parameters["delete_originals"]
|
||||||
|
)
|
||||||
|
)
|
||||||
|
and not user.has_perm("documents.delete_document")
|
||||||
|
):
|
||||||
|
has_perms = False
|
||||||
|
|
||||||
|
if not has_perms:
|
||||||
|
return HttpResponseForbidden("Insufficient permissions")
|
||||||
|
|
||||||
try:
|
try:
|
||||||
modified_field = self.MODIFIED_FIELD_BY_METHOD.get(method.__name__, None)
|
modified_field = self.MODIFIED_FIELD_BY_METHOD.get(method.__name__, None)
|
||||||
@@ -2373,168 +2298,6 @@ class BulkEditView(DocumentOperationPermissionMixin):
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@extend_schema_view(
|
|
||||||
post=extend_schema(
|
|
||||||
operation_id="documents_rotate",
|
|
||||||
description="Rotate one or more documents",
|
|
||||||
responses={
|
|
||||||
200: inline_serializer(
|
|
||||||
name="RotateDocumentsResult",
|
|
||||||
fields={
|
|
||||||
"result": serializers.CharField(),
|
|
||||||
},
|
|
||||||
),
|
|
||||||
},
|
|
||||||
),
|
|
||||||
)
|
|
||||||
class RotateDocumentsView(DocumentOperationPermissionMixin):
|
|
||||||
serializer_class = RotateDocumentsSerializer
|
|
||||||
|
|
||||||
def post(self, request, *args, **kwargs):
|
|
||||||
serializer = self.get_serializer(data=request.data)
|
|
||||||
serializer.is_valid(raise_exception=True)
|
|
||||||
return self._execute_document_action(
|
|
||||||
method=bulk_edit.rotate,
|
|
||||||
validated_data=serializer.validated_data,
|
|
||||||
operation_label="document rotate",
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@extend_schema_view(
|
|
||||||
post=extend_schema(
|
|
||||||
operation_id="documents_merge",
|
|
||||||
description="Merge selected documents into a new document",
|
|
||||||
responses={
|
|
||||||
200: inline_serializer(
|
|
||||||
name="MergeDocumentsResult",
|
|
||||||
fields={
|
|
||||||
"result": serializers.CharField(),
|
|
||||||
},
|
|
||||||
),
|
|
||||||
},
|
|
||||||
),
|
|
||||||
)
|
|
||||||
class MergeDocumentsView(DocumentOperationPermissionMixin):
|
|
||||||
serializer_class = MergeDocumentsSerializer
|
|
||||||
|
|
||||||
def post(self, request, *args, **kwargs):
|
|
||||||
serializer = self.get_serializer(data=request.data)
|
|
||||||
serializer.is_valid(raise_exception=True)
|
|
||||||
return self._execute_document_action(
|
|
||||||
method=bulk_edit.merge,
|
|
||||||
validated_data=serializer.validated_data,
|
|
||||||
operation_label="document merge",
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@extend_schema_view(
|
|
||||||
post=extend_schema(
|
|
||||||
operation_id="documents_delete",
|
|
||||||
description="Move selected documents to trash",
|
|
||||||
responses={
|
|
||||||
200: inline_serializer(
|
|
||||||
name="DeleteDocumentsResult",
|
|
||||||
fields={
|
|
||||||
"result": serializers.CharField(),
|
|
||||||
},
|
|
||||||
),
|
|
||||||
},
|
|
||||||
),
|
|
||||||
)
|
|
||||||
class DeleteDocumentsView(DocumentOperationPermissionMixin):
|
|
||||||
serializer_class = DeleteDocumentsSerializer
|
|
||||||
|
|
||||||
def post(self, request, *args, **kwargs):
|
|
||||||
serializer = self.get_serializer(data=request.data)
|
|
||||||
serializer.is_valid(raise_exception=True)
|
|
||||||
return self._execute_document_action(
|
|
||||||
method=bulk_edit.delete,
|
|
||||||
validated_data=serializer.validated_data,
|
|
||||||
operation_label="document delete",
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@extend_schema_view(
|
|
||||||
post=extend_schema(
|
|
||||||
operation_id="documents_reprocess",
|
|
||||||
description="Reprocess selected documents",
|
|
||||||
responses={
|
|
||||||
200: inline_serializer(
|
|
||||||
name="ReprocessDocumentsResult",
|
|
||||||
fields={
|
|
||||||
"result": serializers.CharField(),
|
|
||||||
},
|
|
||||||
),
|
|
||||||
},
|
|
||||||
),
|
|
||||||
)
|
|
||||||
class ReprocessDocumentsView(DocumentOperationPermissionMixin):
|
|
||||||
serializer_class = ReprocessDocumentsSerializer
|
|
||||||
|
|
||||||
def post(self, request, *args, **kwargs):
|
|
||||||
serializer = self.get_serializer(data=request.data)
|
|
||||||
serializer.is_valid(raise_exception=True)
|
|
||||||
return self._execute_document_action(
|
|
||||||
method=bulk_edit.reprocess,
|
|
||||||
validated_data=serializer.validated_data,
|
|
||||||
operation_label="document reprocess",
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@extend_schema_view(
|
|
||||||
post=extend_schema(
|
|
||||||
operation_id="documents_edit_pdf",
|
|
||||||
description="Perform PDF edit operations on a selected document",
|
|
||||||
responses={
|
|
||||||
200: inline_serializer(
|
|
||||||
name="EditPdfDocumentsResult",
|
|
||||||
fields={
|
|
||||||
"result": serializers.CharField(),
|
|
||||||
},
|
|
||||||
),
|
|
||||||
},
|
|
||||||
),
|
|
||||||
)
|
|
||||||
class EditPdfDocumentsView(DocumentOperationPermissionMixin):
|
|
||||||
serializer_class = EditPdfDocumentsSerializer
|
|
||||||
|
|
||||||
def post(self, request, *args, **kwargs):
|
|
||||||
serializer = self.get_serializer(data=request.data)
|
|
||||||
serializer.is_valid(raise_exception=True)
|
|
||||||
return self._execute_document_action(
|
|
||||||
method=bulk_edit.edit_pdf,
|
|
||||||
validated_data=serializer.validated_data,
|
|
||||||
operation_label="PDF edit",
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@extend_schema_view(
|
|
||||||
post=extend_schema(
|
|
||||||
operation_id="documents_remove_password",
|
|
||||||
description="Remove password protection from selected PDFs",
|
|
||||||
responses={
|
|
||||||
200: inline_serializer(
|
|
||||||
name="RemovePasswordDocumentsResult",
|
|
||||||
fields={
|
|
||||||
"result": serializers.CharField(),
|
|
||||||
},
|
|
||||||
),
|
|
||||||
},
|
|
||||||
),
|
|
||||||
)
|
|
||||||
class RemovePasswordDocumentsView(DocumentOperationPermissionMixin):
|
|
||||||
serializer_class = RemovePasswordDocumentsSerializer
|
|
||||||
|
|
||||||
def post(self, request, *args, **kwargs):
|
|
||||||
serializer = self.get_serializer(data=request.data)
|
|
||||||
serializer.is_valid(raise_exception=True)
|
|
||||||
return self._execute_document_action(
|
|
||||||
method=bulk_edit.remove_password,
|
|
||||||
validated_data=serializer.validated_data,
|
|
||||||
operation_label="password removal",
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@extend_schema_view(
|
@extend_schema_view(
|
||||||
post=extend_schema(
|
post=extend_schema(
|
||||||
description="Upload a document via the API",
|
description="Upload a document via the API",
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -7,7 +7,6 @@ from pathlib import Path
|
|||||||
|
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.core.checks import Error
|
from django.core.checks import Error
|
||||||
from django.core.checks import Tags
|
|
||||||
from django.core.checks import Warning
|
from django.core.checks import Warning
|
||||||
from django.core.checks import register
|
from django.core.checks import register
|
||||||
from django.db import connections
|
from django.db import connections
|
||||||
@@ -205,16 +204,15 @@ def audit_log_check(app_configs, **kwargs):
|
|||||||
return result
|
return result
|
||||||
|
|
||||||
|
|
||||||
@register(Tags.compatibility)
|
@register()
|
||||||
def check_v3_minimum_upgrade_version(
|
def check_v3_minimum_upgrade_version(
|
||||||
app_configs: object,
|
app_configs: object,
|
||||||
**kwargs: object,
|
**kwargs: object,
|
||||||
) -> list[Error]:
|
) -> list[Error]:
|
||||||
"""
|
"""Enforce that upgrades to v3 must start from v2.20.9.
|
||||||
Enforce that upgrades to v3 must start from v2.20.10.
|
|
||||||
|
|
||||||
v3 squashes all prior migrations into 0001_squashed and 0002_squashed.
|
v3 squashes all prior migrations into 0001_squashed and 0002_squashed.
|
||||||
If a user skips v2.20.10, the data migration in 1075_workflowaction_order
|
If a user skips v2.20.9, the data migration in 1075_workflowaction_order
|
||||||
never runs and the squash may apply schema changes against an incomplete
|
never runs and the squash may apply schema changes against an incomplete
|
||||||
database state.
|
database state.
|
||||||
"""
|
"""
|
||||||
@@ -241,7 +239,7 @@ def check_v3_minimum_upgrade_version(
|
|||||||
if {"0001_squashed", "0002_squashed"} & applied:
|
if {"0001_squashed", "0002_squashed"} & applied:
|
||||||
return []
|
return []
|
||||||
|
|
||||||
# On v2.20.10 exactly — squash will pick up cleanly from here
|
# On v2.20.9 exactly — squash will pick up cleanly from here
|
||||||
if "1075_workflowaction_order" in applied:
|
if "1075_workflowaction_order" in applied:
|
||||||
return []
|
return []
|
||||||
|
|
||||||
@@ -252,8 +250,8 @@ def check_v3_minimum_upgrade_version(
|
|||||||
Error(
|
Error(
|
||||||
"Cannot upgrade to Paperless-ngx v3 from this version.",
|
"Cannot upgrade to Paperless-ngx v3 from this version.",
|
||||||
hint=(
|
hint=(
|
||||||
"Upgrading to v3 can only be performed from v2.20.10."
|
"Upgrading to v3 can only be performed from v2.20.9."
|
||||||
"Please upgrade to v2.20.10, run migrations, then upgrade to v3."
|
"Please upgrade to v2.20.9, run migrations, then upgrade to v3."
|
||||||
"See https://docs.paperless-ngx.com/setup/#upgrading for details."
|
"See https://docs.paperless-ngx.com/setup/#upgrading for details."
|
||||||
),
|
),
|
||||||
id="paperless.E002",
|
id="paperless.E002",
|
||||||
|
|||||||
@@ -6,25 +6,18 @@ import math
|
|||||||
import multiprocessing
|
import multiprocessing
|
||||||
import os
|
import os
|
||||||
import tempfile
|
import tempfile
|
||||||
|
from os import PathLike
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Final
|
from typing import Final
|
||||||
from urllib.parse import urlparse
|
from urllib.parse import urlparse
|
||||||
|
|
||||||
|
from celery.schedules import crontab
|
||||||
from compression_middleware.middleware import CompressionMiddleware
|
from compression_middleware.middleware import CompressionMiddleware
|
||||||
|
from dateparser.languages.loader import LocaleDataLoader
|
||||||
from django.utils.translation import gettext_lazy as _
|
from django.utils.translation import gettext_lazy as _
|
||||||
from dotenv import load_dotenv
|
from dotenv import load_dotenv
|
||||||
|
|
||||||
from paperless.settings.custom import parse_beat_schedule
|
|
||||||
from paperless.settings.custom import parse_dateparser_languages
|
|
||||||
from paperless.settings.custom import parse_db_settings
|
from paperless.settings.custom import parse_db_settings
|
||||||
from paperless.settings.custom import parse_hosting_settings
|
|
||||||
from paperless.settings.custom import parse_ignore_dates
|
|
||||||
from paperless.settings.custom import parse_redis_url
|
|
||||||
from paperless.settings.parsers import get_bool_from_env
|
|
||||||
from paperless.settings.parsers import get_float_from_env
|
|
||||||
from paperless.settings.parsers import get_int_from_env
|
|
||||||
from paperless.settings.parsers import get_list_from_env
|
|
||||||
from paperless.settings.parsers import get_path_from_env
|
|
||||||
|
|
||||||
logger = logging.getLogger("paperless.settings")
|
logger = logging.getLogger("paperless.settings")
|
||||||
|
|
||||||
@@ -52,8 +45,239 @@ for path in [
|
|||||||
os.environ["OMP_THREAD_LIMIT"] = "1"
|
os.environ["OMP_THREAD_LIMIT"] = "1"
|
||||||
|
|
||||||
|
|
||||||
|
def __get_boolean(key: str, default: str = "NO") -> bool:
|
||||||
|
"""
|
||||||
|
Return a boolean value based on whatever the user has supplied in the
|
||||||
|
environment based on whether the value "looks like" it's True or not.
|
||||||
|
"""
|
||||||
|
return bool(os.getenv(key, default).lower() in ("yes", "y", "1", "t", "true"))
|
||||||
|
|
||||||
|
|
||||||
|
def __get_int(key: str, default: int) -> int:
|
||||||
|
"""
|
||||||
|
Return an integer value based on the environment variable or a default
|
||||||
|
"""
|
||||||
|
return int(os.getenv(key, default))
|
||||||
|
|
||||||
|
|
||||||
|
def __get_optional_int(key: str) -> int | None:
|
||||||
|
"""
|
||||||
|
Returns None if the environment key is not present, otherwise an integer
|
||||||
|
"""
|
||||||
|
if key in os.environ:
|
||||||
|
return __get_int(key, -1) # pragma: no cover
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def __get_float(key: str, default: float) -> float:
|
||||||
|
"""
|
||||||
|
Return an integer value based on the environment variable or a default
|
||||||
|
"""
|
||||||
|
return float(os.getenv(key, default))
|
||||||
|
|
||||||
|
|
||||||
|
def __get_path(
|
||||||
|
key: str,
|
||||||
|
default: PathLike | str,
|
||||||
|
) -> Path:
|
||||||
|
"""
|
||||||
|
Return a normalized, absolute path based on the environment variable or a default,
|
||||||
|
if provided
|
||||||
|
"""
|
||||||
|
if key in os.environ:
|
||||||
|
return Path(os.environ[key]).resolve()
|
||||||
|
return Path(default).resolve()
|
||||||
|
|
||||||
|
|
||||||
|
def __get_optional_path(key: str) -> Path | None:
|
||||||
|
"""
|
||||||
|
Returns None if the environment key is not present, otherwise a fully resolved Path
|
||||||
|
"""
|
||||||
|
if key in os.environ:
|
||||||
|
return __get_path(key, "")
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def __get_list(
|
||||||
|
key: str,
|
||||||
|
default: list[str] | None = None,
|
||||||
|
sep: str = ",",
|
||||||
|
) -> list[str]:
|
||||||
|
"""
|
||||||
|
Return a list of elements from the environment, as separated by the given
|
||||||
|
string, or the default if the key does not exist
|
||||||
|
"""
|
||||||
|
if key in os.environ:
|
||||||
|
return list(filter(None, os.environ[key].split(sep)))
|
||||||
|
elif default is not None:
|
||||||
|
return default
|
||||||
|
else:
|
||||||
|
return []
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_redis_url(env_redis: str | None) -> tuple[str, str]:
|
||||||
|
"""
|
||||||
|
Gets the Redis information from the environment or a default and handles
|
||||||
|
converting from incompatible django_channels and celery formats.
|
||||||
|
|
||||||
|
Returns a tuple of (celery_url, channels_url)
|
||||||
|
"""
|
||||||
|
|
||||||
|
# Not set, return a compatible default
|
||||||
|
if env_redis is None:
|
||||||
|
return ("redis://localhost:6379", "redis://localhost:6379")
|
||||||
|
|
||||||
|
if "unix" in env_redis.lower():
|
||||||
|
# channels_redis socket format, looks like:
|
||||||
|
# "unix:///path/to/redis.sock"
|
||||||
|
_, path = env_redis.split(":", 1)
|
||||||
|
# Optionally setting a db number
|
||||||
|
if "?db=" in env_redis:
|
||||||
|
path, number = path.split("?db=")
|
||||||
|
return (f"redis+socket:{path}?virtual_host={number}", env_redis)
|
||||||
|
else:
|
||||||
|
return (f"redis+socket:{path}", env_redis)
|
||||||
|
|
||||||
|
elif "+socket" in env_redis.lower():
|
||||||
|
# celery socket style, looks like:
|
||||||
|
# "redis+socket:///path/to/redis.sock"
|
||||||
|
_, path = env_redis.split(":", 1)
|
||||||
|
if "?virtual_host=" in env_redis:
|
||||||
|
# Virtual host (aka db number)
|
||||||
|
path, number = path.split("?virtual_host=")
|
||||||
|
return (env_redis, f"unix:{path}?db={number}")
|
||||||
|
else:
|
||||||
|
return (env_redis, f"unix:{path}")
|
||||||
|
|
||||||
|
# Not a socket
|
||||||
|
return (env_redis, env_redis)
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_beat_schedule() -> dict:
|
||||||
|
"""
|
||||||
|
Configures the scheduled tasks, according to default or
|
||||||
|
environment variables. Task expiration is configured so the task will
|
||||||
|
expire (and not run), shortly before the default frequency will put another
|
||||||
|
of the same task into the queue
|
||||||
|
|
||||||
|
|
||||||
|
https://docs.celeryq.dev/en/stable/userguide/periodic-tasks.html#beat-entries
|
||||||
|
https://docs.celeryq.dev/en/latest/userguide/calling.html#expiration
|
||||||
|
"""
|
||||||
|
schedule = {}
|
||||||
|
tasks = [
|
||||||
|
{
|
||||||
|
"name": "Check all e-mail accounts",
|
||||||
|
"env_key": "PAPERLESS_EMAIL_TASK_CRON",
|
||||||
|
# Default every ten minutes
|
||||||
|
"env_default": "*/10 * * * *",
|
||||||
|
"task": "paperless_mail.tasks.process_mail_accounts",
|
||||||
|
"options": {
|
||||||
|
# 1 minute before default schedule sends again
|
||||||
|
"expires": 9.0 * 60.0,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Train the classifier",
|
||||||
|
"env_key": "PAPERLESS_TRAIN_TASK_CRON",
|
||||||
|
# Default hourly at 5 minutes past the hour
|
||||||
|
"env_default": "5 */1 * * *",
|
||||||
|
"task": "documents.tasks.train_classifier",
|
||||||
|
"options": {
|
||||||
|
# 1 minute before default schedule sends again
|
||||||
|
"expires": 59.0 * 60.0,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Optimize the index",
|
||||||
|
"env_key": "PAPERLESS_INDEX_TASK_CRON",
|
||||||
|
# Default daily at midnight
|
||||||
|
"env_default": "0 0 * * *",
|
||||||
|
"task": "documents.tasks.index_optimize",
|
||||||
|
"options": {
|
||||||
|
# 1 hour before default schedule sends again
|
||||||
|
"expires": 23.0 * 60.0 * 60.0,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Perform sanity check",
|
||||||
|
"env_key": "PAPERLESS_SANITY_TASK_CRON",
|
||||||
|
# Default Sunday at 00:30
|
||||||
|
"env_default": "30 0 * * sun",
|
||||||
|
"task": "documents.tasks.sanity_check",
|
||||||
|
"options": {
|
||||||
|
# 1 hour before default schedule sends again
|
||||||
|
"expires": ((7.0 * 24.0) - 1.0) * 60.0 * 60.0,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Empty trash",
|
||||||
|
"env_key": "PAPERLESS_EMPTY_TRASH_TASK_CRON",
|
||||||
|
# Default daily at 01:00
|
||||||
|
"env_default": "0 1 * * *",
|
||||||
|
"task": "documents.tasks.empty_trash",
|
||||||
|
"options": {
|
||||||
|
# 1 hour before default schedule sends again
|
||||||
|
"expires": 23.0 * 60.0 * 60.0,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Check and run scheduled workflows",
|
||||||
|
"env_key": "PAPERLESS_WORKFLOW_SCHEDULED_TASK_CRON",
|
||||||
|
# Default hourly at 5 minutes past the hour
|
||||||
|
"env_default": "5 */1 * * *",
|
||||||
|
"task": "documents.tasks.check_scheduled_workflows",
|
||||||
|
"options": {
|
||||||
|
# 1 minute before default schedule sends again
|
||||||
|
"expires": 59.0 * 60.0,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Rebuild LLM index",
|
||||||
|
"env_key": "PAPERLESS_LLM_INDEX_TASK_CRON",
|
||||||
|
# Default daily at 02:10
|
||||||
|
"env_default": "10 2 * * *",
|
||||||
|
"task": "documents.tasks.llmindex_index",
|
||||||
|
"options": {
|
||||||
|
# 1 hour before default schedule sends again
|
||||||
|
"expires": 23.0 * 60.0 * 60.0,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Cleanup expired share link bundles",
|
||||||
|
"env_key": "PAPERLESS_SHARE_LINK_BUNDLE_CLEANUP_CRON",
|
||||||
|
# Default daily at 02:00
|
||||||
|
"env_default": "0 2 * * *",
|
||||||
|
"task": "documents.tasks.cleanup_expired_share_link_bundles",
|
||||||
|
"options": {
|
||||||
|
# 1 hour before default schedule sends again
|
||||||
|
"expires": 23.0 * 60.0 * 60.0,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]
|
||||||
|
for task in tasks:
|
||||||
|
# Either get the environment setting or use the default
|
||||||
|
value = os.getenv(task["env_key"], task["env_default"])
|
||||||
|
# Don't add disabled tasks to the schedule
|
||||||
|
if value == "disable":
|
||||||
|
continue
|
||||||
|
# I find https://crontab.guru/ super helpful
|
||||||
|
# crontab(5) format
|
||||||
|
# - five time-and-date fields
|
||||||
|
# - separated by at least one blank
|
||||||
|
minute, hour, day_month, month, day_week = value.split(" ")
|
||||||
|
|
||||||
|
schedule[task["name"]] = {
|
||||||
|
"task": task["task"],
|
||||||
|
"schedule": crontab(minute, hour, day_week, day_month, month),
|
||||||
|
"options": task["options"],
|
||||||
|
}
|
||||||
|
|
||||||
|
return schedule
|
||||||
|
|
||||||
|
|
||||||
# NEVER RUN WITH DEBUG IN PRODUCTION.
|
# NEVER RUN WITH DEBUG IN PRODUCTION.
|
||||||
DEBUG = get_bool_from_env("PAPERLESS_DEBUG", "NO")
|
DEBUG = __get_boolean("PAPERLESS_DEBUG", "NO")
|
||||||
|
|
||||||
|
|
||||||
###############################################################################
|
###############################################################################
|
||||||
@@ -62,21 +286,21 @@ DEBUG = get_bool_from_env("PAPERLESS_DEBUG", "NO")
|
|||||||
|
|
||||||
BASE_DIR: Path = Path(__file__).resolve().parent.parent.parent
|
BASE_DIR: Path = Path(__file__).resolve().parent.parent.parent
|
||||||
|
|
||||||
STATIC_ROOT = get_path_from_env("PAPERLESS_STATICDIR", BASE_DIR.parent / "static")
|
STATIC_ROOT = __get_path("PAPERLESS_STATICDIR", BASE_DIR.parent / "static")
|
||||||
|
|
||||||
MEDIA_ROOT = get_path_from_env("PAPERLESS_MEDIA_ROOT", BASE_DIR.parent / "media")
|
MEDIA_ROOT = __get_path("PAPERLESS_MEDIA_ROOT", BASE_DIR.parent / "media")
|
||||||
ORIGINALS_DIR = MEDIA_ROOT / "documents" / "originals"
|
ORIGINALS_DIR = MEDIA_ROOT / "documents" / "originals"
|
||||||
ARCHIVE_DIR = MEDIA_ROOT / "documents" / "archive"
|
ARCHIVE_DIR = MEDIA_ROOT / "documents" / "archive"
|
||||||
THUMBNAIL_DIR = MEDIA_ROOT / "documents" / "thumbnails"
|
THUMBNAIL_DIR = MEDIA_ROOT / "documents" / "thumbnails"
|
||||||
SHARE_LINK_BUNDLE_DIR = MEDIA_ROOT / "documents" / "share_link_bundles"
|
SHARE_LINK_BUNDLE_DIR = MEDIA_ROOT / "documents" / "share_link_bundles"
|
||||||
|
|
||||||
DATA_DIR = get_path_from_env("PAPERLESS_DATA_DIR", BASE_DIR.parent / "data")
|
DATA_DIR = __get_path("PAPERLESS_DATA_DIR", BASE_DIR.parent / "data")
|
||||||
|
|
||||||
NLTK_DIR = get_path_from_env("PAPERLESS_NLTK_DIR", "/usr/share/nltk_data")
|
NLTK_DIR = __get_path("PAPERLESS_NLTK_DIR", "/usr/share/nltk_data")
|
||||||
|
|
||||||
# Check deprecated setting first
|
# Check deprecated setting first
|
||||||
EMPTY_TRASH_DIR = (
|
EMPTY_TRASH_DIR = (
|
||||||
get_path_from_env("PAPERLESS_TRASH_DIR", os.getenv("PAPERLESS_EMPTY_TRASH_DIR"))
|
__get_path("PAPERLESS_TRASH_DIR", os.getenv("PAPERLESS_EMPTY_TRASH_DIR"))
|
||||||
if os.getenv("PAPERLESS_TRASH_DIR") or os.getenv("PAPERLESS_EMPTY_TRASH_DIR")
|
if os.getenv("PAPERLESS_TRASH_DIR") or os.getenv("PAPERLESS_EMPTY_TRASH_DIR")
|
||||||
else None
|
else None
|
||||||
)
|
)
|
||||||
@@ -85,21 +309,21 @@ EMPTY_TRASH_DIR = (
|
|||||||
# threads.
|
# threads.
|
||||||
MEDIA_LOCK = MEDIA_ROOT / "media.lock"
|
MEDIA_LOCK = MEDIA_ROOT / "media.lock"
|
||||||
INDEX_DIR = DATA_DIR / "index"
|
INDEX_DIR = DATA_DIR / "index"
|
||||||
MODEL_FILE = get_path_from_env(
|
MODEL_FILE = __get_path(
|
||||||
"PAPERLESS_MODEL_FILE",
|
"PAPERLESS_MODEL_FILE",
|
||||||
DATA_DIR / "classification_model.pickle",
|
DATA_DIR / "classification_model.pickle",
|
||||||
)
|
)
|
||||||
LLM_INDEX_DIR = DATA_DIR / "llm_index"
|
LLM_INDEX_DIR = DATA_DIR / "llm_index"
|
||||||
|
|
||||||
LOGGING_DIR = get_path_from_env("PAPERLESS_LOGGING_DIR", DATA_DIR / "log")
|
LOGGING_DIR = __get_path("PAPERLESS_LOGGING_DIR", DATA_DIR / "log")
|
||||||
|
|
||||||
CONSUMPTION_DIR = get_path_from_env(
|
CONSUMPTION_DIR = __get_path(
|
||||||
"PAPERLESS_CONSUMPTION_DIR",
|
"PAPERLESS_CONSUMPTION_DIR",
|
||||||
BASE_DIR.parent / "consume",
|
BASE_DIR.parent / "consume",
|
||||||
)
|
)
|
||||||
|
|
||||||
# This will be created if it doesn't exist
|
# This will be created if it doesn't exist
|
||||||
SCRATCH_DIR = get_path_from_env(
|
SCRATCH_DIR = __get_path(
|
||||||
"PAPERLESS_SCRATCH_DIR",
|
"PAPERLESS_SCRATCH_DIR",
|
||||||
Path(tempfile.gettempdir()) / "paperless",
|
Path(tempfile.gettempdir()) / "paperless",
|
||||||
)
|
)
|
||||||
@@ -108,10 +332,10 @@ SCRATCH_DIR = get_path_from_env(
|
|||||||
# Application Definition #
|
# Application Definition #
|
||||||
###############################################################################
|
###############################################################################
|
||||||
|
|
||||||
env_apps = get_list_from_env("PAPERLESS_APPS")
|
env_apps = __get_list("PAPERLESS_APPS")
|
||||||
|
|
||||||
INSTALLED_APPS = [
|
INSTALLED_APPS = [
|
||||||
"whitenoise.runserver_nostatic",
|
"servestatic.runserver_nostatic",
|
||||||
"django.contrib.auth",
|
"django.contrib.auth",
|
||||||
"django.contrib.contenttypes",
|
"django.contrib.contenttypes",
|
||||||
"django.contrib.sessions",
|
"django.contrib.sessions",
|
||||||
@@ -155,7 +379,7 @@ REST_FRAMEWORK = {
|
|||||||
"DEFAULT_VERSION": "10", # match src-ui/src/environments/environment.prod.ts
|
"DEFAULT_VERSION": "10", # match src-ui/src/environments/environment.prod.ts
|
||||||
# Make sure these are ordered and that the most recent version appears
|
# Make sure these are ordered and that the most recent version appears
|
||||||
# last. See api.md#api-versioning when adding new versions.
|
# last. See api.md#api-versioning when adding new versions.
|
||||||
"ALLOWED_VERSIONS": ["9", "10"],
|
"ALLOWED_VERSIONS": ["2", "3", "4", "5", "6", "7", "8", "9", "10"],
|
||||||
# DRF Spectacular default schema
|
# DRF Spectacular default schema
|
||||||
"DEFAULT_SCHEMA_CLASS": "drf_spectacular.openapi.AutoSchema",
|
"DEFAULT_SCHEMA_CLASS": "drf_spectacular.openapi.AutoSchema",
|
||||||
}
|
}
|
||||||
@@ -167,7 +391,7 @@ if DEBUG:
|
|||||||
|
|
||||||
MIDDLEWARE = [
|
MIDDLEWARE = [
|
||||||
"django.middleware.security.SecurityMiddleware",
|
"django.middleware.security.SecurityMiddleware",
|
||||||
"whitenoise.middleware.WhiteNoiseMiddleware",
|
"servestatic.middleware.ServeStaticMiddleware",
|
||||||
"django.contrib.sessions.middleware.SessionMiddleware",
|
"django.contrib.sessions.middleware.SessionMiddleware",
|
||||||
"corsheaders.middleware.CorsMiddleware",
|
"corsheaders.middleware.CorsMiddleware",
|
||||||
"django.middleware.locale.LocaleMiddleware",
|
"django.middleware.locale.LocaleMiddleware",
|
||||||
@@ -181,7 +405,7 @@ MIDDLEWARE = [
|
|||||||
]
|
]
|
||||||
|
|
||||||
# Optional to enable compression
|
# Optional to enable compression
|
||||||
if get_bool_from_env("PAPERLESS_ENABLE_COMPRESSION", "yes"): # pragma: no cover
|
if __get_boolean("PAPERLESS_ENABLE_COMPRESSION", "yes"): # pragma: no cover
|
||||||
MIDDLEWARE.insert(0, "compression_middleware.middleware.CompressionMiddleware")
|
MIDDLEWARE.insert(0, "compression_middleware.middleware.CompressionMiddleware")
|
||||||
|
|
||||||
# Workaround to not compress streaming responses (e.g. chat).
|
# Workaround to not compress streaming responses (e.g. chat).
|
||||||
@@ -200,8 +424,20 @@ CompressionMiddleware.process_response = patched_process_response
|
|||||||
ROOT_URLCONF = "paperless.urls"
|
ROOT_URLCONF = "paperless.urls"
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_base_paths() -> tuple[str, str, str, str, str]:
|
||||||
|
script_name = os.getenv("PAPERLESS_FORCE_SCRIPT_NAME")
|
||||||
|
base_url = (script_name or "") + "/"
|
||||||
|
login_url = base_url + "accounts/login/"
|
||||||
|
login_redirect_url = base_url + "dashboard"
|
||||||
|
logout_redirect_url = os.getenv(
|
||||||
|
"PAPERLESS_LOGOUT_REDIRECT_URL",
|
||||||
|
login_url + "?loggedout=1",
|
||||||
|
)
|
||||||
|
return script_name, base_url, login_url, login_redirect_url, logout_redirect_url
|
||||||
|
|
||||||
|
|
||||||
FORCE_SCRIPT_NAME, BASE_URL, LOGIN_URL, LOGIN_REDIRECT_URL, LOGOUT_REDIRECT_URL = (
|
FORCE_SCRIPT_NAME, BASE_URL, LOGIN_URL, LOGIN_REDIRECT_URL, LOGOUT_REDIRECT_URL = (
|
||||||
parse_hosting_settings()
|
_parse_base_paths()
|
||||||
)
|
)
|
||||||
|
|
||||||
# DRF Spectacular settings
|
# DRF Spectacular settings
|
||||||
@@ -230,12 +466,12 @@ WHITENOISE_STATIC_PREFIX = "/static/"
|
|||||||
|
|
||||||
STORAGES = {
|
STORAGES = {
|
||||||
"staticfiles": {
|
"staticfiles": {
|
||||||
"BACKEND": "whitenoise.storage.CompressedStaticFilesStorage",
|
"BACKEND": "servestatic.storage.CompressedStaticFilesStorage",
|
||||||
},
|
},
|
||||||
"default": {"BACKEND": "django.core.files.storage.FileSystemStorage"},
|
"default": {"BACKEND": "django.core.files.storage.FileSystemStorage"},
|
||||||
}
|
}
|
||||||
|
|
||||||
_CELERY_REDIS_URL, _CHANNELS_REDIS_URL = parse_redis_url(
|
_CELERY_REDIS_URL, _CHANNELS_REDIS_URL = _parse_redis_url(
|
||||||
os.getenv("PAPERLESS_REDIS", None),
|
os.getenv("PAPERLESS_REDIS", None),
|
||||||
)
|
)
|
||||||
_REDIS_KEY_PREFIX = os.getenv("PAPERLESS_REDIS_PREFIX", "")
|
_REDIS_KEY_PREFIX = os.getenv("PAPERLESS_REDIS_PREFIX", "")
|
||||||
@@ -284,8 +520,8 @@ EMAIL_PORT: Final[int] = int(os.getenv("PAPERLESS_EMAIL_PORT", 25))
|
|||||||
EMAIL_HOST_USER: Final[str] = os.getenv("PAPERLESS_EMAIL_HOST_USER", "")
|
EMAIL_HOST_USER: Final[str] = os.getenv("PAPERLESS_EMAIL_HOST_USER", "")
|
||||||
EMAIL_HOST_PASSWORD: Final[str] = os.getenv("PAPERLESS_EMAIL_HOST_PASSWORD", "")
|
EMAIL_HOST_PASSWORD: Final[str] = os.getenv("PAPERLESS_EMAIL_HOST_PASSWORD", "")
|
||||||
DEFAULT_FROM_EMAIL: Final[str] = os.getenv("PAPERLESS_EMAIL_FROM", EMAIL_HOST_USER)
|
DEFAULT_FROM_EMAIL: Final[str] = os.getenv("PAPERLESS_EMAIL_FROM", EMAIL_HOST_USER)
|
||||||
EMAIL_USE_TLS: Final[bool] = get_bool_from_env("PAPERLESS_EMAIL_USE_TLS")
|
EMAIL_USE_TLS: Final[bool] = __get_boolean("PAPERLESS_EMAIL_USE_TLS")
|
||||||
EMAIL_USE_SSL: Final[bool] = get_bool_from_env("PAPERLESS_EMAIL_USE_SSL")
|
EMAIL_USE_SSL: Final[bool] = __get_boolean("PAPERLESS_EMAIL_USE_SSL")
|
||||||
EMAIL_SUBJECT_PREFIX: Final[str] = "[Paperless-ngx] "
|
EMAIL_SUBJECT_PREFIX: Final[str] = "[Paperless-ngx] "
|
||||||
EMAIL_TIMEOUT = 30.0
|
EMAIL_TIMEOUT = 30.0
|
||||||
EMAIL_ENABLED = EMAIL_HOST != "localhost" or EMAIL_HOST_USER != ""
|
EMAIL_ENABLED = EMAIL_HOST != "localhost" or EMAIL_HOST_USER != ""
|
||||||
@@ -310,22 +546,20 @@ ACCOUNT_DEFAULT_HTTP_PROTOCOL = os.getenv(
|
|||||||
)
|
)
|
||||||
|
|
||||||
ACCOUNT_ADAPTER = "paperless.adapter.CustomAccountAdapter"
|
ACCOUNT_ADAPTER = "paperless.adapter.CustomAccountAdapter"
|
||||||
ACCOUNT_ALLOW_SIGNUPS = get_bool_from_env("PAPERLESS_ACCOUNT_ALLOW_SIGNUPS")
|
ACCOUNT_ALLOW_SIGNUPS = __get_boolean("PAPERLESS_ACCOUNT_ALLOW_SIGNUPS")
|
||||||
ACCOUNT_DEFAULT_GROUPS = get_list_from_env("PAPERLESS_ACCOUNT_DEFAULT_GROUPS")
|
ACCOUNT_DEFAULT_GROUPS = __get_list("PAPERLESS_ACCOUNT_DEFAULT_GROUPS")
|
||||||
|
|
||||||
SOCIALACCOUNT_ADAPTER = "paperless.adapter.CustomSocialAccountAdapter"
|
SOCIALACCOUNT_ADAPTER = "paperless.adapter.CustomSocialAccountAdapter"
|
||||||
SOCIALACCOUNT_ALLOW_SIGNUPS = get_bool_from_env(
|
SOCIALACCOUNT_ALLOW_SIGNUPS = __get_boolean(
|
||||||
"PAPERLESS_SOCIALACCOUNT_ALLOW_SIGNUPS",
|
"PAPERLESS_SOCIALACCOUNT_ALLOW_SIGNUPS",
|
||||||
"yes",
|
"yes",
|
||||||
)
|
)
|
||||||
SOCIALACCOUNT_AUTO_SIGNUP = get_bool_from_env("PAPERLESS_SOCIAL_AUTO_SIGNUP")
|
SOCIALACCOUNT_AUTO_SIGNUP = __get_boolean("PAPERLESS_SOCIAL_AUTO_SIGNUP")
|
||||||
SOCIALACCOUNT_PROVIDERS = json.loads(
|
SOCIALACCOUNT_PROVIDERS = json.loads(
|
||||||
os.getenv("PAPERLESS_SOCIALACCOUNT_PROVIDERS", "{}"),
|
os.getenv("PAPERLESS_SOCIALACCOUNT_PROVIDERS", "{}"),
|
||||||
)
|
)
|
||||||
SOCIAL_ACCOUNT_DEFAULT_GROUPS = get_list_from_env(
|
SOCIAL_ACCOUNT_DEFAULT_GROUPS = __get_list("PAPERLESS_SOCIAL_ACCOUNT_DEFAULT_GROUPS")
|
||||||
"PAPERLESS_SOCIAL_ACCOUNT_DEFAULT_GROUPS",
|
SOCIAL_ACCOUNT_SYNC_GROUPS = __get_boolean("PAPERLESS_SOCIAL_ACCOUNT_SYNC_GROUPS")
|
||||||
)
|
|
||||||
SOCIAL_ACCOUNT_SYNC_GROUPS = get_bool_from_env("PAPERLESS_SOCIAL_ACCOUNT_SYNC_GROUPS")
|
|
||||||
SOCIAL_ACCOUNT_SYNC_GROUPS_CLAIM: Final[str] = os.getenv(
|
SOCIAL_ACCOUNT_SYNC_GROUPS_CLAIM: Final[str] = os.getenv(
|
||||||
"PAPERLESS_SOCIAL_ACCOUNT_SYNC_GROUPS_CLAIM",
|
"PAPERLESS_SOCIAL_ACCOUNT_SYNC_GROUPS_CLAIM",
|
||||||
"groups",
|
"groups",
|
||||||
@@ -337,8 +571,8 @@ MFA_TOTP_ISSUER = "Paperless-ngx"
|
|||||||
|
|
||||||
ACCOUNT_EMAIL_SUBJECT_PREFIX = "[Paperless-ngx] "
|
ACCOUNT_EMAIL_SUBJECT_PREFIX = "[Paperless-ngx] "
|
||||||
|
|
||||||
DISABLE_REGULAR_LOGIN = get_bool_from_env("PAPERLESS_DISABLE_REGULAR_LOGIN")
|
DISABLE_REGULAR_LOGIN = __get_boolean("PAPERLESS_DISABLE_REGULAR_LOGIN")
|
||||||
REDIRECT_LOGIN_TO_SSO = get_bool_from_env("PAPERLESS_REDIRECT_LOGIN_TO_SSO")
|
REDIRECT_LOGIN_TO_SSO = __get_boolean("PAPERLESS_REDIRECT_LOGIN_TO_SSO")
|
||||||
|
|
||||||
AUTO_LOGIN_USERNAME = os.getenv("PAPERLESS_AUTO_LOGIN_USERNAME")
|
AUTO_LOGIN_USERNAME = os.getenv("PAPERLESS_AUTO_LOGIN_USERNAME")
|
||||||
|
|
||||||
@@ -351,15 +585,12 @@ ACCOUNT_EMAIL_VERIFICATION = (
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
ACCOUNT_EMAIL_UNKNOWN_ACCOUNTS = get_bool_from_env(
|
ACCOUNT_EMAIL_UNKNOWN_ACCOUNTS = __get_boolean(
|
||||||
"PAPERLESS_ACCOUNT_EMAIL_UNKNOWN_ACCOUNTS",
|
"PAPERLESS_ACCOUNT_EMAIL_UNKNOWN_ACCOUNTS",
|
||||||
"True",
|
"True",
|
||||||
)
|
)
|
||||||
|
|
||||||
ACCOUNT_SESSION_REMEMBER = get_bool_from_env(
|
ACCOUNT_SESSION_REMEMBER = __get_boolean("PAPERLESS_ACCOUNT_SESSION_REMEMBER", "True")
|
||||||
"PAPERLESS_ACCOUNT_SESSION_REMEMBER",
|
|
||||||
"True",
|
|
||||||
)
|
|
||||||
SESSION_EXPIRE_AT_BROWSER_CLOSE = not ACCOUNT_SESSION_REMEMBER
|
SESSION_EXPIRE_AT_BROWSER_CLOSE = not ACCOUNT_SESSION_REMEMBER
|
||||||
SESSION_COOKIE_AGE = int(
|
SESSION_COOKIE_AGE = int(
|
||||||
os.getenv("PAPERLESS_SESSION_COOKIE_AGE", 60 * 60 * 24 * 7 * 3),
|
os.getenv("PAPERLESS_SESSION_COOKIE_AGE", 60 * 60 * 24 * 7 * 3),
|
||||||
@@ -376,8 +607,8 @@ if AUTO_LOGIN_USERNAME:
|
|||||||
|
|
||||||
def _parse_remote_user_settings() -> str:
|
def _parse_remote_user_settings() -> str:
|
||||||
global MIDDLEWARE, AUTHENTICATION_BACKENDS, REST_FRAMEWORK
|
global MIDDLEWARE, AUTHENTICATION_BACKENDS, REST_FRAMEWORK
|
||||||
enable = get_bool_from_env("PAPERLESS_ENABLE_HTTP_REMOTE_USER")
|
enable = __get_boolean("PAPERLESS_ENABLE_HTTP_REMOTE_USER")
|
||||||
enable_api = get_bool_from_env("PAPERLESS_ENABLE_HTTP_REMOTE_USER_API")
|
enable_api = __get_boolean("PAPERLESS_ENABLE_HTTP_REMOTE_USER_API")
|
||||||
if enable or enable_api:
|
if enable or enable_api:
|
||||||
MIDDLEWARE.append("paperless.auth.HttpRemoteUserMiddleware")
|
MIDDLEWARE.append("paperless.auth.HttpRemoteUserMiddleware")
|
||||||
AUTHENTICATION_BACKENDS.insert(
|
AUTHENTICATION_BACKENDS.insert(
|
||||||
@@ -405,16 +636,16 @@ HTTP_REMOTE_USER_HEADER_NAME = _parse_remote_user_settings()
|
|||||||
X_FRAME_OPTIONS = "SAMEORIGIN"
|
X_FRAME_OPTIONS = "SAMEORIGIN"
|
||||||
|
|
||||||
# The next 3 settings can also be set using just PAPERLESS_URL
|
# The next 3 settings can also be set using just PAPERLESS_URL
|
||||||
CSRF_TRUSTED_ORIGINS = get_list_from_env("PAPERLESS_CSRF_TRUSTED_ORIGINS")
|
CSRF_TRUSTED_ORIGINS = __get_list("PAPERLESS_CSRF_TRUSTED_ORIGINS")
|
||||||
|
|
||||||
if DEBUG:
|
if DEBUG:
|
||||||
# Allow access from the angular development server during debugging
|
# Allow access from the angular development server during debugging
|
||||||
CSRF_TRUSTED_ORIGINS.append("http://localhost:4200")
|
CSRF_TRUSTED_ORIGINS.append("http://localhost:4200")
|
||||||
|
|
||||||
# We allow CORS from localhost:8000
|
# We allow CORS from localhost:8000
|
||||||
CORS_ALLOWED_ORIGINS = get_list_from_env(
|
CORS_ALLOWED_ORIGINS = __get_list(
|
||||||
"PAPERLESS_CORS_ALLOWED_HOSTS",
|
"PAPERLESS_CORS_ALLOWED_HOSTS",
|
||||||
default=["http://localhost:8000"],
|
["http://localhost:8000"],
|
||||||
)
|
)
|
||||||
|
|
||||||
if DEBUG:
|
if DEBUG:
|
||||||
@@ -427,7 +658,7 @@ CORS_EXPOSE_HEADERS = [
|
|||||||
"Content-Disposition",
|
"Content-Disposition",
|
||||||
]
|
]
|
||||||
|
|
||||||
ALLOWED_HOSTS = get_list_from_env("PAPERLESS_ALLOWED_HOSTS", default=["*"])
|
ALLOWED_HOSTS = __get_list("PAPERLESS_ALLOWED_HOSTS", ["*"])
|
||||||
if ALLOWED_HOSTS != ["*"]:
|
if ALLOWED_HOSTS != ["*"]:
|
||||||
# always allow localhost. Necessary e.g. for healthcheck in docker.
|
# always allow localhost. Necessary e.g. for healthcheck in docker.
|
||||||
ALLOWED_HOSTS.append("localhost")
|
ALLOWED_HOSTS.append("localhost")
|
||||||
@@ -447,10 +678,10 @@ def _parse_paperless_url():
|
|||||||
PAPERLESS_URL = _parse_paperless_url()
|
PAPERLESS_URL = _parse_paperless_url()
|
||||||
|
|
||||||
# For use with trusted proxies
|
# For use with trusted proxies
|
||||||
TRUSTED_PROXIES = get_list_from_env("PAPERLESS_TRUSTED_PROXIES")
|
TRUSTED_PROXIES = __get_list("PAPERLESS_TRUSTED_PROXIES")
|
||||||
|
|
||||||
USE_X_FORWARDED_HOST = get_bool_from_env("PAPERLESS_USE_X_FORWARD_HOST", "false")
|
USE_X_FORWARDED_HOST = __get_boolean("PAPERLESS_USE_X_FORWARD_HOST", "false")
|
||||||
USE_X_FORWARDED_PORT = get_bool_from_env("PAPERLESS_USE_X_FORWARD_PORT", "false")
|
USE_X_FORWARDED_PORT = __get_boolean("PAPERLESS_USE_X_FORWARD_PORT", "false")
|
||||||
SECURE_PROXY_SSL_HEADER = (
|
SECURE_PROXY_SSL_HEADER = (
|
||||||
tuple(json.loads(os.environ["PAPERLESS_PROXY_SSL_HEADER"]))
|
tuple(json.loads(os.environ["PAPERLESS_PROXY_SSL_HEADER"]))
|
||||||
if "PAPERLESS_PROXY_SSL_HEADER" in os.environ
|
if "PAPERLESS_PROXY_SSL_HEADER" in os.environ
|
||||||
@@ -493,7 +724,7 @@ CSRF_COOKIE_NAME = f"{COOKIE_PREFIX}csrftoken"
|
|||||||
SESSION_COOKIE_NAME = f"{COOKIE_PREFIX}sessionid"
|
SESSION_COOKIE_NAME = f"{COOKIE_PREFIX}sessionid"
|
||||||
LANGUAGE_COOKIE_NAME = f"{COOKIE_PREFIX}django_language"
|
LANGUAGE_COOKIE_NAME = f"{COOKIE_PREFIX}django_language"
|
||||||
|
|
||||||
EMAIL_CERTIFICATE_FILE = get_path_from_env("PAPERLESS_EMAIL_CERTIFICATE_LOCATION")
|
EMAIL_CERTIFICATE_FILE = __get_optional_path("PAPERLESS_EMAIL_CERTIFICATE_LOCATION")
|
||||||
|
|
||||||
|
|
||||||
###############################################################################
|
###############################################################################
|
||||||
@@ -644,7 +875,7 @@ CELERY_BROKER_URL = _CELERY_REDIS_URL
|
|||||||
CELERY_TIMEZONE = TIME_ZONE
|
CELERY_TIMEZONE = TIME_ZONE
|
||||||
|
|
||||||
CELERY_WORKER_HIJACK_ROOT_LOGGER = False
|
CELERY_WORKER_HIJACK_ROOT_LOGGER = False
|
||||||
CELERY_WORKER_CONCURRENCY: Final[int] = get_int_from_env("PAPERLESS_TASK_WORKERS", 1)
|
CELERY_WORKER_CONCURRENCY: Final[int] = __get_int("PAPERLESS_TASK_WORKERS", 1)
|
||||||
TASK_WORKERS = CELERY_WORKER_CONCURRENCY
|
TASK_WORKERS = CELERY_WORKER_CONCURRENCY
|
||||||
CELERY_WORKER_MAX_TASKS_PER_CHILD = 1
|
CELERY_WORKER_MAX_TASKS_PER_CHILD = 1
|
||||||
CELERY_WORKER_SEND_TASK_EVENTS = True
|
CELERY_WORKER_SEND_TASK_EVENTS = True
|
||||||
@@ -657,7 +888,7 @@ CELERY_BROKER_TRANSPORT_OPTIONS = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
CELERY_TASK_TRACK_STARTED = True
|
CELERY_TASK_TRACK_STARTED = True
|
||||||
CELERY_TASK_TIME_LIMIT: Final[int] = get_int_from_env("PAPERLESS_WORKER_TIMEOUT", 1800)
|
CELERY_TASK_TIME_LIMIT: Final[int] = __get_int("PAPERLESS_WORKER_TIMEOUT", 1800)
|
||||||
|
|
||||||
CELERY_RESULT_EXTENDED = True
|
CELERY_RESULT_EXTENDED = True
|
||||||
CELERY_RESULT_BACKEND = "django-db"
|
CELERY_RESULT_BACKEND = "django-db"
|
||||||
@@ -669,7 +900,7 @@ CELERY_TASK_SERIALIZER = "pickle"
|
|||||||
CELERY_ACCEPT_CONTENT = ["application/json", "application/x-python-serialize"]
|
CELERY_ACCEPT_CONTENT = ["application/json", "application/x-python-serialize"]
|
||||||
|
|
||||||
# https://docs.celeryq.dev/en/stable/userguide/configuration.html#beat-schedule
|
# https://docs.celeryq.dev/en/stable/userguide/configuration.html#beat-schedule
|
||||||
CELERY_BEAT_SCHEDULE = parse_beat_schedule()
|
CELERY_BEAT_SCHEDULE = _parse_beat_schedule()
|
||||||
|
|
||||||
# https://docs.celeryq.dev/en/stable/userguide/configuration.html#beat-schedule-filename
|
# https://docs.celeryq.dev/en/stable/userguide/configuration.html#beat-schedule-filename
|
||||||
CELERY_BEAT_SCHEDULE_FILENAME = str(DATA_DIR / "celerybeat-schedule.db")
|
CELERY_BEAT_SCHEDULE_FILENAME = str(DATA_DIR / "celerybeat-schedule.db")
|
||||||
@@ -677,14 +908,14 @@ CELERY_BEAT_SCHEDULE_FILENAME = str(DATA_DIR / "celerybeat-schedule.db")
|
|||||||
|
|
||||||
# Cachalot: Database read cache.
|
# Cachalot: Database read cache.
|
||||||
def _parse_cachalot_settings():
|
def _parse_cachalot_settings():
|
||||||
ttl = get_int_from_env("PAPERLESS_READ_CACHE_TTL", 3600)
|
ttl = __get_int("PAPERLESS_READ_CACHE_TTL", 3600)
|
||||||
ttl = min(ttl, 31536000) if ttl > 0 else 3600
|
ttl = min(ttl, 31536000) if ttl > 0 else 3600
|
||||||
_, redis_url = parse_redis_url(
|
_, redis_url = _parse_redis_url(
|
||||||
os.getenv("PAPERLESS_READ_CACHE_REDIS_URL", _CHANNELS_REDIS_URL),
|
os.getenv("PAPERLESS_READ_CACHE_REDIS_URL", _CHANNELS_REDIS_URL),
|
||||||
)
|
)
|
||||||
result = {
|
result = {
|
||||||
"CACHALOT_CACHE": "read-cache",
|
"CACHALOT_CACHE": "read-cache",
|
||||||
"CACHALOT_ENABLED": get_bool_from_env(
|
"CACHALOT_ENABLED": __get_boolean(
|
||||||
"PAPERLESS_DB_READ_CACHE_ENABLED",
|
"PAPERLESS_DB_READ_CACHE_ENABLED",
|
||||||
default="no",
|
default="no",
|
||||||
),
|
),
|
||||||
@@ -769,9 +1000,9 @@ CONSUMER_POLLING_INTERVAL = float(os.getenv("PAPERLESS_CONSUMER_POLLING_INTERVAL
|
|||||||
|
|
||||||
CONSUMER_STABILITY_DELAY = float(os.getenv("PAPERLESS_CONSUMER_STABILITY_DELAY", 5))
|
CONSUMER_STABILITY_DELAY = float(os.getenv("PAPERLESS_CONSUMER_STABILITY_DELAY", 5))
|
||||||
|
|
||||||
CONSUMER_DELETE_DUPLICATES = get_bool_from_env("PAPERLESS_CONSUMER_DELETE_DUPLICATES")
|
CONSUMER_DELETE_DUPLICATES = __get_boolean("PAPERLESS_CONSUMER_DELETE_DUPLICATES")
|
||||||
|
|
||||||
CONSUMER_RECURSIVE = get_bool_from_env("PAPERLESS_CONSUMER_RECURSIVE")
|
CONSUMER_RECURSIVE = __get_boolean("PAPERLESS_CONSUMER_RECURSIVE")
|
||||||
|
|
||||||
# Ignore regex patterns, matched against filename only
|
# Ignore regex patterns, matched against filename only
|
||||||
CONSUMER_IGNORE_PATTERNS = list(
|
CONSUMER_IGNORE_PATTERNS = list(
|
||||||
@@ -793,13 +1024,13 @@ CONSUMER_IGNORE_DIRS = list(
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
CONSUMER_SUBDIRS_AS_TAGS = get_bool_from_env("PAPERLESS_CONSUMER_SUBDIRS_AS_TAGS")
|
CONSUMER_SUBDIRS_AS_TAGS = __get_boolean("PAPERLESS_CONSUMER_SUBDIRS_AS_TAGS")
|
||||||
|
|
||||||
CONSUMER_ENABLE_BARCODES: Final[bool] = get_bool_from_env(
|
CONSUMER_ENABLE_BARCODES: Final[bool] = __get_boolean(
|
||||||
"PAPERLESS_CONSUMER_ENABLE_BARCODES",
|
"PAPERLESS_CONSUMER_ENABLE_BARCODES",
|
||||||
)
|
)
|
||||||
|
|
||||||
CONSUMER_BARCODE_TIFF_SUPPORT: Final[bool] = get_bool_from_env(
|
CONSUMER_BARCODE_TIFF_SUPPORT: Final[bool] = __get_boolean(
|
||||||
"PAPERLESS_CONSUMER_BARCODE_TIFF_SUPPORT",
|
"PAPERLESS_CONSUMER_BARCODE_TIFF_SUPPORT",
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -808,7 +1039,7 @@ CONSUMER_BARCODE_STRING: Final[str] = os.getenv(
|
|||||||
"PATCHT",
|
"PATCHT",
|
||||||
)
|
)
|
||||||
|
|
||||||
CONSUMER_ENABLE_ASN_BARCODE: Final[bool] = get_bool_from_env(
|
CONSUMER_ENABLE_ASN_BARCODE: Final[bool] = __get_boolean(
|
||||||
"PAPERLESS_CONSUMER_ENABLE_ASN_BARCODE",
|
"PAPERLESS_CONSUMER_ENABLE_ASN_BARCODE",
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -817,26 +1048,23 @@ CONSUMER_ASN_BARCODE_PREFIX: Final[str] = os.getenv(
|
|||||||
"ASN",
|
"ASN",
|
||||||
)
|
)
|
||||||
|
|
||||||
CONSUMER_BARCODE_UPSCALE: Final[float] = get_float_from_env(
|
CONSUMER_BARCODE_UPSCALE: Final[float] = __get_float(
|
||||||
"PAPERLESS_CONSUMER_BARCODE_UPSCALE",
|
"PAPERLESS_CONSUMER_BARCODE_UPSCALE",
|
||||||
0.0,
|
0.0,
|
||||||
)
|
)
|
||||||
|
|
||||||
CONSUMER_BARCODE_DPI: Final[int] = get_int_from_env(
|
CONSUMER_BARCODE_DPI: Final[int] = __get_int("PAPERLESS_CONSUMER_BARCODE_DPI", 300)
|
||||||
"PAPERLESS_CONSUMER_BARCODE_DPI",
|
|
||||||
300,
|
|
||||||
)
|
|
||||||
|
|
||||||
CONSUMER_BARCODE_MAX_PAGES: Final[int] = get_int_from_env(
|
CONSUMER_BARCODE_MAX_PAGES: Final[int] = __get_int(
|
||||||
"PAPERLESS_CONSUMER_BARCODE_MAX_PAGES",
|
"PAPERLESS_CONSUMER_BARCODE_MAX_PAGES",
|
||||||
0,
|
0,
|
||||||
)
|
)
|
||||||
|
|
||||||
CONSUMER_BARCODE_RETAIN_SPLIT_PAGES = get_bool_from_env(
|
CONSUMER_BARCODE_RETAIN_SPLIT_PAGES = __get_boolean(
|
||||||
"PAPERLESS_CONSUMER_BARCODE_RETAIN_SPLIT_PAGES",
|
"PAPERLESS_CONSUMER_BARCODE_RETAIN_SPLIT_PAGES",
|
||||||
)
|
)
|
||||||
|
|
||||||
CONSUMER_ENABLE_TAG_BARCODE: Final[bool] = get_bool_from_env(
|
CONSUMER_ENABLE_TAG_BARCODE: Final[bool] = __get_boolean(
|
||||||
"PAPERLESS_CONSUMER_ENABLE_TAG_BARCODE",
|
"PAPERLESS_CONSUMER_ENABLE_TAG_BARCODE",
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -849,11 +1077,11 @@ CONSUMER_TAG_BARCODE_MAPPING = dict(
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
CONSUMER_TAG_BARCODE_SPLIT: Final[bool] = get_bool_from_env(
|
CONSUMER_TAG_BARCODE_SPLIT: Final[bool] = __get_boolean(
|
||||||
"PAPERLESS_CONSUMER_TAG_BARCODE_SPLIT",
|
"PAPERLESS_CONSUMER_TAG_BARCODE_SPLIT",
|
||||||
)
|
)
|
||||||
|
|
||||||
CONSUMER_ENABLE_COLLATE_DOUBLE_SIDED: Final[bool] = get_bool_from_env(
|
CONSUMER_ENABLE_COLLATE_DOUBLE_SIDED: Final[bool] = __get_boolean(
|
||||||
"PAPERLESS_CONSUMER_ENABLE_COLLATE_DOUBLE_SIDED",
|
"PAPERLESS_CONSUMER_ENABLE_COLLATE_DOUBLE_SIDED",
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -862,13 +1090,13 @@ CONSUMER_COLLATE_DOUBLE_SIDED_SUBDIR_NAME: Final[str] = os.getenv(
|
|||||||
"double-sided",
|
"double-sided",
|
||||||
)
|
)
|
||||||
|
|
||||||
CONSUMER_COLLATE_DOUBLE_SIDED_TIFF_SUPPORT: Final[bool] = get_bool_from_env(
|
CONSUMER_COLLATE_DOUBLE_SIDED_TIFF_SUPPORT: Final[bool] = __get_boolean(
|
||||||
"PAPERLESS_CONSUMER_COLLATE_DOUBLE_SIDED_TIFF_SUPPORT",
|
"PAPERLESS_CONSUMER_COLLATE_DOUBLE_SIDED_TIFF_SUPPORT",
|
||||||
)
|
)
|
||||||
|
|
||||||
CONSUMER_PDF_RECOVERABLE_MIME_TYPES = ("application/octet-stream",)
|
CONSUMER_PDF_RECOVERABLE_MIME_TYPES = ("application/octet-stream",)
|
||||||
|
|
||||||
OCR_PAGES = get_int_from_env("PAPERLESS_OCR_PAGES")
|
OCR_PAGES = __get_optional_int("PAPERLESS_OCR_PAGES")
|
||||||
|
|
||||||
# The default language that tesseract will attempt to use when parsing
|
# The default language that tesseract will attempt to use when parsing
|
||||||
# documents. It should be a 3-letter language code consistent with ISO 639.
|
# documents. It should be a 3-letter language code consistent with ISO 639.
|
||||||
@@ -882,20 +1110,20 @@ OCR_MODE = os.getenv("PAPERLESS_OCR_MODE", "skip")
|
|||||||
|
|
||||||
OCR_SKIP_ARCHIVE_FILE = os.getenv("PAPERLESS_OCR_SKIP_ARCHIVE_FILE", "never")
|
OCR_SKIP_ARCHIVE_FILE = os.getenv("PAPERLESS_OCR_SKIP_ARCHIVE_FILE", "never")
|
||||||
|
|
||||||
OCR_IMAGE_DPI = get_int_from_env("PAPERLESS_OCR_IMAGE_DPI")
|
OCR_IMAGE_DPI = __get_optional_int("PAPERLESS_OCR_IMAGE_DPI")
|
||||||
|
|
||||||
OCR_CLEAN = os.getenv("PAPERLESS_OCR_CLEAN", "clean")
|
OCR_CLEAN = os.getenv("PAPERLESS_OCR_CLEAN", "clean")
|
||||||
|
|
||||||
OCR_DESKEW: Final[bool] = get_bool_from_env("PAPERLESS_OCR_DESKEW", "true")
|
OCR_DESKEW: Final[bool] = __get_boolean("PAPERLESS_OCR_DESKEW", "true")
|
||||||
|
|
||||||
OCR_ROTATE_PAGES: Final[bool] = get_bool_from_env("PAPERLESS_OCR_ROTATE_PAGES", "true")
|
OCR_ROTATE_PAGES: Final[bool] = __get_boolean("PAPERLESS_OCR_ROTATE_PAGES", "true")
|
||||||
|
|
||||||
OCR_ROTATE_PAGES_THRESHOLD: Final[float] = get_float_from_env(
|
OCR_ROTATE_PAGES_THRESHOLD: Final[float] = __get_float(
|
||||||
"PAPERLESS_OCR_ROTATE_PAGES_THRESHOLD",
|
"PAPERLESS_OCR_ROTATE_PAGES_THRESHOLD",
|
||||||
12.0,
|
12.0,
|
||||||
)
|
)
|
||||||
|
|
||||||
OCR_MAX_IMAGE_PIXELS: Final[int | None] = get_int_from_env(
|
OCR_MAX_IMAGE_PIXELS: Final[int | None] = __get_optional_int(
|
||||||
"PAPERLESS_OCR_MAX_IMAGE_PIXELS",
|
"PAPERLESS_OCR_MAX_IMAGE_PIXELS",
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -906,7 +1134,7 @@ OCR_COLOR_CONVERSION_STRATEGY = os.getenv(
|
|||||||
|
|
||||||
OCR_USER_ARGS = os.getenv("PAPERLESS_OCR_USER_ARGS")
|
OCR_USER_ARGS = os.getenv("PAPERLESS_OCR_USER_ARGS")
|
||||||
|
|
||||||
MAX_IMAGE_PIXELS: Final[int | None] = get_int_from_env(
|
MAX_IMAGE_PIXELS: Final[int | None] = __get_optional_int(
|
||||||
"PAPERLESS_MAX_IMAGE_PIXELS",
|
"PAPERLESS_MAX_IMAGE_PIXELS",
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -921,7 +1149,7 @@ CONVERT_MEMORY_LIMIT = os.getenv("PAPERLESS_CONVERT_MEMORY_LIMIT")
|
|||||||
GS_BINARY = os.getenv("PAPERLESS_GS_BINARY", "gs")
|
GS_BINARY = os.getenv("PAPERLESS_GS_BINARY", "gs")
|
||||||
|
|
||||||
# Fallback layout for .eml consumption
|
# Fallback layout for .eml consumption
|
||||||
EMAIL_PARSE_DEFAULT_LAYOUT = get_int_from_env(
|
EMAIL_PARSE_DEFAULT_LAYOUT = __get_int(
|
||||||
"PAPERLESS_EMAIL_PARSE_DEFAULT_LAYOUT",
|
"PAPERLESS_EMAIL_PARSE_DEFAULT_LAYOUT",
|
||||||
1, # MailRule.PdfLayout.TEXT_HTML but that can't be imported here
|
1, # MailRule.PdfLayout.TEXT_HTML but that can't be imported here
|
||||||
)
|
)
|
||||||
@@ -935,9 +1163,23 @@ DATE_ORDER = os.getenv("PAPERLESS_DATE_ORDER", "DMY")
|
|||||||
FILENAME_DATE_ORDER = os.getenv("PAPERLESS_FILENAME_DATE_ORDER")
|
FILENAME_DATE_ORDER = os.getenv("PAPERLESS_FILENAME_DATE_ORDER")
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_dateparser_languages(languages: str | None):
|
||||||
|
language_list = languages.split("+") if languages else []
|
||||||
|
# There is an unfixed issue in zh-Hant and zh-Hans locales in the dateparser lib.
|
||||||
|
# See: https://github.com/scrapinghub/dateparser/issues/875
|
||||||
|
for index, language in enumerate(language_list):
|
||||||
|
if language.startswith("zh-") and "zh" not in language_list:
|
||||||
|
logger.warning(
|
||||||
|
f'Chinese locale detected: {language}. dateparser might fail to parse some dates with this locale, so Chinese ("zh") will be used as a fallback.',
|
||||||
|
)
|
||||||
|
language_list.append("zh")
|
||||||
|
|
||||||
|
return list(LocaleDataLoader().get_locale_map(locales=language_list))
|
||||||
|
|
||||||
|
|
||||||
# If not set, we will infer it at runtime
|
# If not set, we will infer it at runtime
|
||||||
DATE_PARSER_LANGUAGES = (
|
DATE_PARSER_LANGUAGES = (
|
||||||
parse_dateparser_languages(
|
_parse_dateparser_languages(
|
||||||
os.getenv("PAPERLESS_DATE_PARSER_LANGUAGES"),
|
os.getenv("PAPERLESS_DATE_PARSER_LANGUAGES"),
|
||||||
)
|
)
|
||||||
if os.getenv("PAPERLESS_DATE_PARSER_LANGUAGES")
|
if os.getenv("PAPERLESS_DATE_PARSER_LANGUAGES")
|
||||||
@@ -948,7 +1190,7 @@ DATE_PARSER_LANGUAGES = (
|
|||||||
# Maximum number of dates taken from document start to end to show as suggestions for
|
# Maximum number of dates taken from document start to end to show as suggestions for
|
||||||
# `created` date in the frontend. Duplicates are removed, which can result in
|
# `created` date in the frontend. Duplicates are removed, which can result in
|
||||||
# fewer dates shown.
|
# fewer dates shown.
|
||||||
NUMBER_OF_SUGGESTED_DATES = get_int_from_env("PAPERLESS_NUMBER_OF_SUGGESTED_DATES", 3)
|
NUMBER_OF_SUGGESTED_DATES = __get_int("PAPERLESS_NUMBER_OF_SUGGESTED_DATES", 3)
|
||||||
|
|
||||||
# Specify the filename format for out files
|
# Specify the filename format for out files
|
||||||
FILENAME_FORMAT = os.getenv("PAPERLESS_FILENAME_FORMAT")
|
FILENAME_FORMAT = os.getenv("PAPERLESS_FILENAME_FORMAT")
|
||||||
@@ -956,7 +1198,7 @@ FILENAME_FORMAT = os.getenv("PAPERLESS_FILENAME_FORMAT")
|
|||||||
# If this is enabled, variables in filename format will resolve to
|
# If this is enabled, variables in filename format will resolve to
|
||||||
# empty-string instead of 'none'.
|
# empty-string instead of 'none'.
|
||||||
# Directories with 'empty names' are omitted, too.
|
# Directories with 'empty names' are omitted, too.
|
||||||
FILENAME_FORMAT_REMOVE_NONE = get_bool_from_env(
|
FILENAME_FORMAT_REMOVE_NONE = __get_boolean(
|
||||||
"PAPERLESS_FILENAME_FORMAT_REMOVE_NONE",
|
"PAPERLESS_FILENAME_FORMAT_REMOVE_NONE",
|
||||||
"NO",
|
"NO",
|
||||||
)
|
)
|
||||||
@@ -967,7 +1209,7 @@ THUMBNAIL_FONT_NAME = os.getenv(
|
|||||||
)
|
)
|
||||||
|
|
||||||
# Tika settings
|
# Tika settings
|
||||||
TIKA_ENABLED = get_bool_from_env("PAPERLESS_TIKA_ENABLED", "NO")
|
TIKA_ENABLED = __get_boolean("PAPERLESS_TIKA_ENABLED", "NO")
|
||||||
TIKA_ENDPOINT = os.getenv("PAPERLESS_TIKA_ENDPOINT", "http://localhost:9998")
|
TIKA_ENDPOINT = os.getenv("PAPERLESS_TIKA_ENDPOINT", "http://localhost:9998")
|
||||||
TIKA_GOTENBERG_ENDPOINT = os.getenv(
|
TIKA_GOTENBERG_ENDPOINT = os.getenv(
|
||||||
"PAPERLESS_TIKA_GOTENBERG_ENDPOINT",
|
"PAPERLESS_TIKA_GOTENBERG_ENDPOINT",
|
||||||
@@ -977,21 +1219,52 @@ TIKA_GOTENBERG_ENDPOINT = os.getenv(
|
|||||||
if TIKA_ENABLED:
|
if TIKA_ENABLED:
|
||||||
INSTALLED_APPS.append("paperless_tika.apps.PaperlessTikaConfig")
|
INSTALLED_APPS.append("paperless_tika.apps.PaperlessTikaConfig")
|
||||||
|
|
||||||
AUDIT_LOG_ENABLED = get_bool_from_env("PAPERLESS_AUDIT_LOG_ENABLED", "true")
|
AUDIT_LOG_ENABLED = __get_boolean("PAPERLESS_AUDIT_LOG_ENABLED", "true")
|
||||||
if AUDIT_LOG_ENABLED:
|
if AUDIT_LOG_ENABLED:
|
||||||
INSTALLED_APPS.append("auditlog")
|
INSTALLED_APPS.append("auditlog")
|
||||||
MIDDLEWARE.append("auditlog.middleware.AuditlogMiddleware")
|
MIDDLEWARE.append("auditlog.middleware.AuditlogMiddleware")
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_ignore_dates(
|
||||||
|
env_ignore: str,
|
||||||
|
date_order: str = DATE_ORDER,
|
||||||
|
) -> set[datetime.datetime]:
|
||||||
|
"""
|
||||||
|
If the PAPERLESS_IGNORE_DATES environment variable is set, parse the
|
||||||
|
user provided string(s) into dates
|
||||||
|
|
||||||
|
Args:
|
||||||
|
env_ignore (str): The value of the environment variable, comma separated dates
|
||||||
|
date_order (str, optional): The format of the date strings.
|
||||||
|
Defaults to DATE_ORDER.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Set[datetime.datetime]: The set of parsed date objects
|
||||||
|
"""
|
||||||
|
import dateparser
|
||||||
|
|
||||||
|
ignored_dates = set()
|
||||||
|
for s in env_ignore.split(","):
|
||||||
|
d = dateparser.parse(
|
||||||
|
s,
|
||||||
|
settings={
|
||||||
|
"DATE_ORDER": date_order,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
if d:
|
||||||
|
ignored_dates.add(d.date())
|
||||||
|
return ignored_dates
|
||||||
|
|
||||||
|
|
||||||
# List dates that should be ignored when trying to parse date from document text
|
# List dates that should be ignored when trying to parse date from document text
|
||||||
IGNORE_DATES: set[datetime.date] = set()
|
IGNORE_DATES: set[datetime.date] = set()
|
||||||
|
|
||||||
if os.getenv("PAPERLESS_IGNORE_DATES") is not None:
|
if os.getenv("PAPERLESS_IGNORE_DATES") is not None:
|
||||||
IGNORE_DATES = parse_ignore_dates(os.getenv("PAPERLESS_IGNORE_DATES"), DATE_ORDER)
|
IGNORE_DATES = _parse_ignore_dates(os.getenv("PAPERLESS_IGNORE_DATES"))
|
||||||
|
|
||||||
ENABLE_UPDATE_CHECK = os.getenv("PAPERLESS_ENABLE_UPDATE_CHECK", "default")
|
ENABLE_UPDATE_CHECK = os.getenv("PAPERLESS_ENABLE_UPDATE_CHECK", "default")
|
||||||
if ENABLE_UPDATE_CHECK != "default":
|
if ENABLE_UPDATE_CHECK != "default":
|
||||||
ENABLE_UPDATE_CHECK = get_bool_from_env("PAPERLESS_ENABLE_UPDATE_CHECK")
|
ENABLE_UPDATE_CHECK = __get_boolean("PAPERLESS_ENABLE_UPDATE_CHECK")
|
||||||
|
|
||||||
APP_TITLE = os.getenv("PAPERLESS_APP_TITLE", None)
|
APP_TITLE = os.getenv("PAPERLESS_APP_TITLE", None)
|
||||||
APP_LOGO = os.getenv("PAPERLESS_APP_LOGO", None)
|
APP_LOGO = os.getenv("PAPERLESS_APP_LOGO", None)
|
||||||
@@ -1036,7 +1309,7 @@ def _get_nltk_language_setting(ocr_lang: str) -> str | None:
|
|||||||
return iso_code_to_nltk.get(ocr_lang)
|
return iso_code_to_nltk.get(ocr_lang)
|
||||||
|
|
||||||
|
|
||||||
NLTK_ENABLED: Final[bool] = get_bool_from_env("PAPERLESS_ENABLE_NLTK", "yes")
|
NLTK_ENABLED: Final[bool] = __get_boolean("PAPERLESS_ENABLE_NLTK", "yes")
|
||||||
|
|
||||||
NLTK_LANGUAGE: str | None = _get_nltk_language_setting(OCR_LANGUAGE)
|
NLTK_LANGUAGE: str | None = _get_nltk_language_setting(OCR_LANGUAGE)
|
||||||
|
|
||||||
@@ -1045,7 +1318,7 @@ NLTK_LANGUAGE: str | None = _get_nltk_language_setting(OCR_LANGUAGE)
|
|||||||
###############################################################################
|
###############################################################################
|
||||||
|
|
||||||
EMAIL_GNUPG_HOME: Final[str | None] = os.getenv("PAPERLESS_EMAIL_GNUPG_HOME")
|
EMAIL_GNUPG_HOME: Final[str | None] = os.getenv("PAPERLESS_EMAIL_GNUPG_HOME")
|
||||||
EMAIL_ENABLE_GPG_DECRYPTOR: Final[bool] = get_bool_from_env(
|
EMAIL_ENABLE_GPG_DECRYPTOR: Final[bool] = __get_boolean(
|
||||||
"PAPERLESS_ENABLE_GPG_DECRYPTOR",
|
"PAPERLESS_ENABLE_GPG_DECRYPTOR",
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -1053,7 +1326,7 @@ EMAIL_ENABLE_GPG_DECRYPTOR: Final[bool] = get_bool_from_env(
|
|||||||
###############################################################################
|
###############################################################################
|
||||||
# Soft Delete #
|
# Soft Delete #
|
||||||
###############################################################################
|
###############################################################################
|
||||||
EMPTY_TRASH_DELAY = max(get_int_from_env("PAPERLESS_EMPTY_TRASH_DELAY", 30), 1)
|
EMPTY_TRASH_DELAY = max(__get_int("PAPERLESS_EMPTY_TRASH_DELAY", 30), 1)
|
||||||
|
|
||||||
|
|
||||||
###############################################################################
|
###############################################################################
|
||||||
@@ -1078,17 +1351,21 @@ OUTLOOK_OAUTH_ENABLED = bool(
|
|||||||
###############################################################################
|
###############################################################################
|
||||||
# Webhooks
|
# Webhooks
|
||||||
###############################################################################
|
###############################################################################
|
||||||
WEBHOOKS_ALLOWED_SCHEMES = {
|
WEBHOOKS_ALLOWED_SCHEMES = set(
|
||||||
s.lower()
|
s.lower()
|
||||||
for s in get_list_from_env(
|
for s in __get_list(
|
||||||
"PAPERLESS_WEBHOOKS_ALLOWED_SCHEMES",
|
"PAPERLESS_WEBHOOKS_ALLOWED_SCHEMES",
|
||||||
default=["http", "https"],
|
["http", "https"],
|
||||||
)
|
)
|
||||||
}
|
)
|
||||||
WEBHOOKS_ALLOWED_PORTS = {
|
WEBHOOKS_ALLOWED_PORTS = set(
|
||||||
int(p) for p in get_list_from_env("PAPERLESS_WEBHOOKS_ALLOWED_PORTS", default=[])
|
int(p)
|
||||||
}
|
for p in __get_list(
|
||||||
WEBHOOKS_ALLOW_INTERNAL_REQUESTS = get_bool_from_env(
|
"PAPERLESS_WEBHOOKS_ALLOWED_PORTS",
|
||||||
|
[],
|
||||||
|
)
|
||||||
|
)
|
||||||
|
WEBHOOKS_ALLOW_INTERNAL_REQUESTS = __get_boolean(
|
||||||
"PAPERLESS_WEBHOOKS_ALLOW_INTERNAL_REQUESTS",
|
"PAPERLESS_WEBHOOKS_ALLOW_INTERNAL_REQUESTS",
|
||||||
"true",
|
"true",
|
||||||
)
|
)
|
||||||
@@ -1103,7 +1380,7 @@ REMOTE_OCR_ENDPOINT = os.getenv("PAPERLESS_REMOTE_OCR_ENDPOINT")
|
|||||||
################################################################################
|
################################################################################
|
||||||
# AI Settings #
|
# AI Settings #
|
||||||
################################################################################
|
################################################################################
|
||||||
AI_ENABLED = get_bool_from_env("PAPERLESS_AI_ENABLED", "NO")
|
AI_ENABLED = __get_boolean("PAPERLESS_AI_ENABLED", "NO")
|
||||||
LLM_EMBEDDING_BACKEND = os.getenv(
|
LLM_EMBEDDING_BACKEND = os.getenv(
|
||||||
"PAPERLESS_AI_LLM_EMBEDDING_BACKEND",
|
"PAPERLESS_AI_LLM_EMBEDDING_BACKEND",
|
||||||
) # "huggingface" or "openai"
|
) # "huggingface" or "openai"
|
||||||
|
|||||||
@@ -1,191 +1,11 @@
|
|||||||
import datetime
|
|
||||||
import logging
|
|
||||||
import os
|
import os
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from celery.schedules import crontab
|
|
||||||
from dateparser.languages.loader import LocaleDataLoader
|
|
||||||
|
|
||||||
from paperless.settings.parsers import get_choice_from_env
|
from paperless.settings.parsers import get_choice_from_env
|
||||||
from paperless.settings.parsers import get_int_from_env
|
from paperless.settings.parsers import get_int_from_env
|
||||||
from paperless.settings.parsers import parse_dict_from_str
|
from paperless.settings.parsers import parse_dict_from_str
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
|
|
||||||
def parse_hosting_settings() -> tuple[str | None, str, str, str, str]:
|
|
||||||
script_name = os.getenv("PAPERLESS_FORCE_SCRIPT_NAME")
|
|
||||||
base_url = (script_name or "") + "/"
|
|
||||||
login_url = base_url + "accounts/login/"
|
|
||||||
login_redirect_url = base_url + "dashboard"
|
|
||||||
logout_redirect_url = os.getenv(
|
|
||||||
"PAPERLESS_LOGOUT_REDIRECT_URL",
|
|
||||||
login_url + "?loggedout=1",
|
|
||||||
)
|
|
||||||
return script_name, base_url, login_url, login_redirect_url, logout_redirect_url
|
|
||||||
|
|
||||||
|
|
||||||
def parse_redis_url(env_redis: str | None) -> tuple[str, str]:
|
|
||||||
"""
|
|
||||||
Gets the Redis information from the environment or a default and handles
|
|
||||||
converting from incompatible django_channels and celery formats.
|
|
||||||
|
|
||||||
Returns a tuple of (celery_url, channels_url)
|
|
||||||
"""
|
|
||||||
|
|
||||||
# Not set, return a compatible default
|
|
||||||
if env_redis is None:
|
|
||||||
return ("redis://localhost:6379", "redis://localhost:6379")
|
|
||||||
|
|
||||||
if "unix" in env_redis.lower():
|
|
||||||
# channels_redis socket format, looks like:
|
|
||||||
# "unix:///path/to/redis.sock"
|
|
||||||
_, path = env_redis.split(":", maxsplit=1)
|
|
||||||
# Optionally setting a db number
|
|
||||||
if "?db=" in env_redis:
|
|
||||||
path, number = path.split("?db=")
|
|
||||||
return (f"redis+socket:{path}?virtual_host={number}", env_redis)
|
|
||||||
else:
|
|
||||||
return (f"redis+socket:{path}", env_redis)
|
|
||||||
|
|
||||||
elif "+socket" in env_redis.lower():
|
|
||||||
# celery socket style, looks like:
|
|
||||||
# "redis+socket:///path/to/redis.sock"
|
|
||||||
_, path = env_redis.split(":", maxsplit=1)
|
|
||||||
if "?virtual_host=" in env_redis:
|
|
||||||
# Virtual host (aka db number)
|
|
||||||
path, number = path.split("?virtual_host=")
|
|
||||||
return (env_redis, f"unix:{path}?db={number}")
|
|
||||||
else:
|
|
||||||
return (env_redis, f"unix:{path}")
|
|
||||||
|
|
||||||
# Not a socket
|
|
||||||
return (env_redis, env_redis)
|
|
||||||
|
|
||||||
|
|
||||||
def parse_beat_schedule() -> dict:
|
|
||||||
"""
|
|
||||||
Configures the scheduled tasks, according to default or
|
|
||||||
environment variables. Task expiration is configured so the task will
|
|
||||||
expire (and not run), shortly before the default frequency will put another
|
|
||||||
of the same task into the queue
|
|
||||||
|
|
||||||
|
|
||||||
https://docs.celeryq.dev/en/stable/userguide/periodic-tasks.html#beat-entries
|
|
||||||
https://docs.celeryq.dev/en/latest/userguide/calling.html#expiration
|
|
||||||
"""
|
|
||||||
schedule = {}
|
|
||||||
tasks = [
|
|
||||||
{
|
|
||||||
"name": "Check all e-mail accounts",
|
|
||||||
"env_key": "PAPERLESS_EMAIL_TASK_CRON",
|
|
||||||
# Default every ten minutes
|
|
||||||
"env_default": "*/10 * * * *",
|
|
||||||
"task": "paperless_mail.tasks.process_mail_accounts",
|
|
||||||
"options": {
|
|
||||||
# 1 minute before default schedule sends again
|
|
||||||
"expires": 9.0 * 60.0,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "Train the classifier",
|
|
||||||
"env_key": "PAPERLESS_TRAIN_TASK_CRON",
|
|
||||||
# Default hourly at 5 minutes past the hour
|
|
||||||
"env_default": "5 */1 * * *",
|
|
||||||
"task": "documents.tasks.train_classifier",
|
|
||||||
"options": {
|
|
||||||
# 1 minute before default schedule sends again
|
|
||||||
"expires": 59.0 * 60.0,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "Optimize the index",
|
|
||||||
"env_key": "PAPERLESS_INDEX_TASK_CRON",
|
|
||||||
# Default daily at midnight
|
|
||||||
"env_default": "0 0 * * *",
|
|
||||||
"task": "documents.tasks.index_optimize",
|
|
||||||
"options": {
|
|
||||||
# 1 hour before default schedule sends again
|
|
||||||
"expires": 23.0 * 60.0 * 60.0,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "Perform sanity check",
|
|
||||||
"env_key": "PAPERLESS_SANITY_TASK_CRON",
|
|
||||||
# Default Sunday at 00:30
|
|
||||||
"env_default": "30 0 * * sun",
|
|
||||||
"task": "documents.tasks.sanity_check",
|
|
||||||
"options": {
|
|
||||||
# 1 hour before default schedule sends again
|
|
||||||
"expires": ((7.0 * 24.0) - 1.0) * 60.0 * 60.0,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "Empty trash",
|
|
||||||
"env_key": "PAPERLESS_EMPTY_TRASH_TASK_CRON",
|
|
||||||
# Default daily at 01:00
|
|
||||||
"env_default": "0 1 * * *",
|
|
||||||
"task": "documents.tasks.empty_trash",
|
|
||||||
"options": {
|
|
||||||
# 1 hour before default schedule sends again
|
|
||||||
"expires": 23.0 * 60.0 * 60.0,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "Check and run scheduled workflows",
|
|
||||||
"env_key": "PAPERLESS_WORKFLOW_SCHEDULED_TASK_CRON",
|
|
||||||
# Default hourly at 5 minutes past the hour
|
|
||||||
"env_default": "5 */1 * * *",
|
|
||||||
"task": "documents.tasks.check_scheduled_workflows",
|
|
||||||
"options": {
|
|
||||||
# 1 minute before default schedule sends again
|
|
||||||
"expires": 59.0 * 60.0,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "Rebuild LLM index",
|
|
||||||
"env_key": "PAPERLESS_LLM_INDEX_TASK_CRON",
|
|
||||||
# Default daily at 02:10
|
|
||||||
"env_default": "10 2 * * *",
|
|
||||||
"task": "documents.tasks.llmindex_index",
|
|
||||||
"options": {
|
|
||||||
# 1 hour before default schedule sends again
|
|
||||||
"expires": 23.0 * 60.0 * 60.0,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "Cleanup expired share link bundles",
|
|
||||||
"env_key": "PAPERLESS_SHARE_LINK_BUNDLE_CLEANUP_CRON",
|
|
||||||
# Default daily at 02:00
|
|
||||||
"env_default": "0 2 * * *",
|
|
||||||
"task": "documents.tasks.cleanup_expired_share_link_bundles",
|
|
||||||
"options": {
|
|
||||||
# 1 hour before default schedule sends again
|
|
||||||
"expires": 23.0 * 60.0 * 60.0,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
]
|
|
||||||
for task in tasks:
|
|
||||||
# Either get the environment setting or use the default
|
|
||||||
value = os.getenv(task["env_key"], task["env_default"])
|
|
||||||
# Don't add disabled tasks to the schedule
|
|
||||||
if value == "disable":
|
|
||||||
continue
|
|
||||||
# I find https://crontab.guru/ super helpful
|
|
||||||
# crontab(5) format
|
|
||||||
# - five time-and-date fields
|
|
||||||
# - separated by at least one blank
|
|
||||||
minute, hour, day_month, month, day_week = value.split(" ")
|
|
||||||
|
|
||||||
schedule[task["name"]] = {
|
|
||||||
"task": task["task"],
|
|
||||||
"schedule": crontab(minute, hour, day_week, day_month, month),
|
|
||||||
"options": task["options"],
|
|
||||||
}
|
|
||||||
|
|
||||||
return schedule
|
|
||||||
|
|
||||||
|
|
||||||
def parse_db_settings(data_dir: Path) -> dict[str, dict[str, Any]]:
|
def parse_db_settings(data_dir: Path) -> dict[str, dict[str, Any]]:
|
||||||
"""Parse database settings from environment variables.
|
"""Parse database settings from environment variables.
|
||||||
@@ -209,6 +29,7 @@ def parse_db_settings(data_dir: Path) -> dict[str, dict[str, Any]]:
|
|||||||
engine = get_choice_from_env(
|
engine = get_choice_from_env(
|
||||||
"PAPERLESS_DBENGINE",
|
"PAPERLESS_DBENGINE",
|
||||||
{"sqlite", "postgresql", "mariadb"},
|
{"sqlite", "postgresql", "mariadb"},
|
||||||
|
default="sqlite",
|
||||||
)
|
)
|
||||||
except ValueError:
|
except ValueError:
|
||||||
# MariaDB users already had to set PAPERLESS_DBENGINE, so it was picked up above
|
# MariaDB users already had to set PAPERLESS_DBENGINE, so it was picked up above
|
||||||
@@ -299,48 +120,3 @@ def parse_db_settings(data_dir: Path) -> dict[str, dict[str, Any]]:
|
|||||||
)
|
)
|
||||||
|
|
||||||
return {"default": db_config}
|
return {"default": db_config}
|
||||||
|
|
||||||
|
|
||||||
def parse_dateparser_languages(languages: str | None) -> list[str]:
|
|
||||||
language_list = languages.split("+") if languages else []
|
|
||||||
# There is an unfixed issue in zh-Hant and zh-Hans locales in the dateparser lib.
|
|
||||||
# See: https://github.com/scrapinghub/dateparser/issues/875
|
|
||||||
for index, language in enumerate(language_list):
|
|
||||||
if language.startswith("zh-") and "zh" not in language_list:
|
|
||||||
logger.warning(
|
|
||||||
f"Chinese locale detected: {language}. dateparser might fail to parse"
|
|
||||||
f' some dates with this locale, so Chinese ("zh") will be used as a fallback.',
|
|
||||||
)
|
|
||||||
language_list.append("zh")
|
|
||||||
|
|
||||||
return list(LocaleDataLoader().get_locale_map(locales=language_list))
|
|
||||||
|
|
||||||
|
|
||||||
def parse_ignore_dates(
|
|
||||||
env_ignore: str,
|
|
||||||
date_order: str,
|
|
||||||
) -> set[datetime.date]:
|
|
||||||
"""
|
|
||||||
If the PAPERLESS_IGNORE_DATES environment variable is set, parse the
|
|
||||||
user provided string(s) into dates
|
|
||||||
|
|
||||||
Args:
|
|
||||||
env_ignore (str): The value of the environment variable, comma separated dates
|
|
||||||
date_order (str): The format of the date strings.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
set[datetime.date]: The set of parsed date objects
|
|
||||||
"""
|
|
||||||
import dateparser
|
|
||||||
|
|
||||||
ignored_dates = set()
|
|
||||||
for s in env_ignore.split(","):
|
|
||||||
d = dateparser.parse(
|
|
||||||
s,
|
|
||||||
settings={
|
|
||||||
"DATE_ORDER": date_order,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
if d:
|
|
||||||
ignored_dates.add(d.date())
|
|
||||||
return ignored_dates
|
|
||||||
|
|||||||
@@ -156,108 +156,6 @@ def parse_dict_from_str(
|
|||||||
return settings
|
return settings
|
||||||
|
|
||||||
|
|
||||||
def get_bool_from_env(key: str, default: str = "NO") -> bool:
|
|
||||||
"""
|
|
||||||
Return a boolean value based on whatever the user has supplied in the
|
|
||||||
environment based on whether the value "looks like" it's True or not.
|
|
||||||
"""
|
|
||||||
return str_to_bool(os.getenv(key, default))
|
|
||||||
|
|
||||||
|
|
||||||
@overload
|
|
||||||
def get_float_from_env(key: str) -> float | None: ...
|
|
||||||
|
|
||||||
|
|
||||||
@overload
|
|
||||||
def get_float_from_env(key: str, default: None) -> float | None: ...
|
|
||||||
|
|
||||||
|
|
||||||
@overload
|
|
||||||
def get_float_from_env(key: str, default: float) -> float: ...
|
|
||||||
|
|
||||||
|
|
||||||
def get_float_from_env(key: str, default: float | None = None) -> float | None:
|
|
||||||
"""
|
|
||||||
Return a float value based on the environment variable.
|
|
||||||
If default is provided, returns that value when key is missing.
|
|
||||||
If default is None, returns None when key is missing.
|
|
||||||
"""
|
|
||||||
if key not in os.environ:
|
|
||||||
return default
|
|
||||||
|
|
||||||
return float(os.environ[key])
|
|
||||||
|
|
||||||
|
|
||||||
@overload
|
|
||||||
def get_path_from_env(key: str) -> Path | None: ...
|
|
||||||
|
|
||||||
|
|
||||||
@overload
|
|
||||||
def get_path_from_env(key: str, default: None) -> Path | None: ...
|
|
||||||
|
|
||||||
|
|
||||||
@overload
|
|
||||||
def get_path_from_env(key: str, default: Path | str) -> Path: ...
|
|
||||||
|
|
||||||
|
|
||||||
def get_path_from_env(key: str, default: Path | str | None = None) -> Path | None:
|
|
||||||
"""
|
|
||||||
Return a Path object based on the environment variable.
|
|
||||||
If default is provided, returns that value when key is missing.
|
|
||||||
If default is None, returns None when key is missing.
|
|
||||||
"""
|
|
||||||
if key not in os.environ:
|
|
||||||
return default if default is None else Path(default).resolve()
|
|
||||||
|
|
||||||
return Path(os.environ[key]).resolve()
|
|
||||||
|
|
||||||
|
|
||||||
def get_list_from_env(
|
|
||||||
key: str,
|
|
||||||
separator: str = ",",
|
|
||||||
default: list[T] | None = None,
|
|
||||||
*,
|
|
||||||
strip_whitespace: bool = True,
|
|
||||||
remove_empty: bool = True,
|
|
||||||
required: bool = False,
|
|
||||||
) -> list[str] | list[T]:
|
|
||||||
"""
|
|
||||||
Get and parse a list from an environment variable or return a default.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
key: Environment variable name
|
|
||||||
separator: Character(s) to split on (default: ',')
|
|
||||||
default: Default value to return if env var is not set or empty
|
|
||||||
strip_whitespace: Whether to strip whitespace from each element
|
|
||||||
remove_empty: Whether to remove empty strings from the result
|
|
||||||
required: If True, raise an error when the env var is missing and no default provided
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
List of strings or list of type-cast values, or default if env var is empty/None
|
|
||||||
|
|
||||||
Raises:
|
|
||||||
ValueError: If required=True and env var is missing and there is no default
|
|
||||||
"""
|
|
||||||
# Get the environment variable value
|
|
||||||
env_value = os.environ.get(key)
|
|
||||||
|
|
||||||
# Handle required environment variables
|
|
||||||
if required and env_value is None and default is None:
|
|
||||||
raise ValueError(f"Required environment variable '{key}' is not set")
|
|
||||||
|
|
||||||
if env_value:
|
|
||||||
items = env_value.split(separator)
|
|
||||||
if strip_whitespace:
|
|
||||||
items = [item.strip() for item in items]
|
|
||||||
if remove_empty:
|
|
||||||
items = [item for item in items if item]
|
|
||||||
return items
|
|
||||||
elif default is not None:
|
|
||||||
return default
|
|
||||||
else:
|
|
||||||
return []
|
|
||||||
|
|
||||||
|
|
||||||
def get_choice_from_env(
|
def get_choice_from_env(
|
||||||
env_key: str,
|
env_key: str,
|
||||||
choices: set[str],
|
choices: set[str],
|
||||||
|
|||||||
@@ -1,279 +1,10 @@
|
|||||||
import datetime
|
|
||||||
import os
|
import os
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any
|
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
from celery.schedules import crontab
|
|
||||||
from pytest_mock import MockerFixture
|
from pytest_mock import MockerFixture
|
||||||
|
|
||||||
from paperless.settings.custom import parse_beat_schedule
|
|
||||||
from paperless.settings.custom import parse_dateparser_languages
|
|
||||||
from paperless.settings.custom import parse_db_settings
|
from paperless.settings.custom import parse_db_settings
|
||||||
from paperless.settings.custom import parse_hosting_settings
|
|
||||||
from paperless.settings.custom import parse_ignore_dates
|
|
||||||
from paperless.settings.custom import parse_redis_url
|
|
||||||
|
|
||||||
|
|
||||||
class TestRedisSocketConversion:
|
|
||||||
@pytest.mark.parametrize(
|
|
||||||
("input_url", "expected"),
|
|
||||||
[
|
|
||||||
pytest.param(
|
|
||||||
None,
|
|
||||||
("redis://localhost:6379", "redis://localhost:6379"),
|
|
||||||
id="none_uses_default",
|
|
||||||
),
|
|
||||||
pytest.param(
|
|
||||||
"redis+socket:///run/redis/redis.sock",
|
|
||||||
(
|
|
||||||
"redis+socket:///run/redis/redis.sock",
|
|
||||||
"unix:///run/redis/redis.sock",
|
|
||||||
),
|
|
||||||
id="celery_style_socket",
|
|
||||||
),
|
|
||||||
pytest.param(
|
|
||||||
"unix:///run/redis/redis.sock",
|
|
||||||
(
|
|
||||||
"redis+socket:///run/redis/redis.sock",
|
|
||||||
"unix:///run/redis/redis.sock",
|
|
||||||
),
|
|
||||||
id="redis_py_style_socket",
|
|
||||||
),
|
|
||||||
pytest.param(
|
|
||||||
"redis+socket:///run/redis/redis.sock?virtual_host=5",
|
|
||||||
(
|
|
||||||
"redis+socket:///run/redis/redis.sock?virtual_host=5",
|
|
||||||
"unix:///run/redis/redis.sock?db=5",
|
|
||||||
),
|
|
||||||
id="celery_style_socket_with_db",
|
|
||||||
),
|
|
||||||
pytest.param(
|
|
||||||
"unix:///run/redis/redis.sock?db=10",
|
|
||||||
(
|
|
||||||
"redis+socket:///run/redis/redis.sock?virtual_host=10",
|
|
||||||
"unix:///run/redis/redis.sock?db=10",
|
|
||||||
),
|
|
||||||
id="redis_py_style_socket_with_db",
|
|
||||||
),
|
|
||||||
pytest.param(
|
|
||||||
"redis://myredishost:6379",
|
|
||||||
("redis://myredishost:6379", "redis://myredishost:6379"),
|
|
||||||
id="host_with_port_unchanged",
|
|
||||||
),
|
|
||||||
# Credentials in unix:// URL contain multiple colons (user:password@)
|
|
||||||
# Regression test for https://github.com/paperless-ngx/paperless-ngx/pull/12239
|
|
||||||
pytest.param(
|
|
||||||
"unix://user:password@/run/redis/redis.sock",
|
|
||||||
(
|
|
||||||
"redis+socket://user:password@/run/redis/redis.sock",
|
|
||||||
"unix://user:password@/run/redis/redis.sock",
|
|
||||||
),
|
|
||||||
id="redis_py_style_socket_with_credentials",
|
|
||||||
),
|
|
||||||
pytest.param(
|
|
||||||
"redis+socket://user:password@/run/redis/redis.sock",
|
|
||||||
(
|
|
||||||
"redis+socket://user:password@/run/redis/redis.sock",
|
|
||||||
"unix://user:password@/run/redis/redis.sock",
|
|
||||||
),
|
|
||||||
id="celery_style_socket_with_credentials",
|
|
||||||
),
|
|
||||||
],
|
|
||||||
)
|
|
||||||
def test_redis_socket_parsing(
|
|
||||||
self,
|
|
||||||
input_url: str | None,
|
|
||||||
expected: tuple[str, str],
|
|
||||||
) -> None:
|
|
||||||
"""
|
|
||||||
GIVEN:
|
|
||||||
- Various Redis connection URI formats
|
|
||||||
WHEN:
|
|
||||||
- The URI is parsed
|
|
||||||
THEN:
|
|
||||||
- Socket based URIs are translated
|
|
||||||
- Non-socket URIs are unchanged
|
|
||||||
- None provided uses default
|
|
||||||
"""
|
|
||||||
result = parse_redis_url(input_url)
|
|
||||||
assert expected == result
|
|
||||||
|
|
||||||
|
|
||||||
class TestParseHostingSettings:
|
|
||||||
@pytest.mark.parametrize(
|
|
||||||
("env", "expected"),
|
|
||||||
[
|
|
||||||
pytest.param(
|
|
||||||
{},
|
|
||||||
(
|
|
||||||
None,
|
|
||||||
"/",
|
|
||||||
"/accounts/login/",
|
|
||||||
"/dashboard",
|
|
||||||
"/accounts/login/?loggedout=1",
|
|
||||||
),
|
|
||||||
id="no_env_vars",
|
|
||||||
),
|
|
||||||
pytest.param(
|
|
||||||
{"PAPERLESS_FORCE_SCRIPT_NAME": "/paperless"},
|
|
||||||
(
|
|
||||||
"/paperless",
|
|
||||||
"/paperless/",
|
|
||||||
"/paperless/accounts/login/",
|
|
||||||
"/paperless/dashboard",
|
|
||||||
"/paperless/accounts/login/?loggedout=1",
|
|
||||||
),
|
|
||||||
id="force_script_name_only",
|
|
||||||
),
|
|
||||||
pytest.param(
|
|
||||||
{
|
|
||||||
"PAPERLESS_FORCE_SCRIPT_NAME": "/docs",
|
|
||||||
"PAPERLESS_LOGOUT_REDIRECT_URL": "/custom/logout",
|
|
||||||
},
|
|
||||||
(
|
|
||||||
"/docs",
|
|
||||||
"/docs/",
|
|
||||||
"/docs/accounts/login/",
|
|
||||||
"/docs/dashboard",
|
|
||||||
"/custom/logout",
|
|
||||||
),
|
|
||||||
id="force_script_name_and_logout_redirect",
|
|
||||||
),
|
|
||||||
],
|
|
||||||
)
|
|
||||||
def test_parse_hosting_settings(
|
|
||||||
self,
|
|
||||||
mocker: MockerFixture,
|
|
||||||
env: dict[str, str],
|
|
||||||
expected: tuple[str | None, str, str, str, str],
|
|
||||||
) -> None:
|
|
||||||
"""Test parse_hosting_settings with various env configurations."""
|
|
||||||
mocker.patch.dict(os.environ, env, clear=True)
|
|
||||||
|
|
||||||
result = parse_hosting_settings()
|
|
||||||
|
|
||||||
assert result == expected
|
|
||||||
|
|
||||||
|
|
||||||
def make_expected_schedule(
|
|
||||||
overrides: dict[str, dict[str, Any]] | None = None,
|
|
||||||
disabled: set[str] | None = None,
|
|
||||||
) -> dict[str, Any]:
|
|
||||||
"""
|
|
||||||
Build the expected schedule with optional overrides and disabled tasks.
|
|
||||||
"""
|
|
||||||
|
|
||||||
mail_expire = 9.0 * 60.0
|
|
||||||
classifier_expire = 59.0 * 60.0
|
|
||||||
index_expire = 23.0 * 60.0 * 60.0
|
|
||||||
sanity_expire = ((7.0 * 24.0) - 1.0) * 60.0 * 60.0
|
|
||||||
empty_trash_expire = 23.0 * 60.0 * 60.0
|
|
||||||
workflow_expire = 59.0 * 60.0
|
|
||||||
llm_index_expire = 23.0 * 60.0 * 60.0
|
|
||||||
share_link_cleanup_expire = 23.0 * 60.0 * 60.0
|
|
||||||
|
|
||||||
schedule: dict[str, Any] = {
|
|
||||||
"Check all e-mail accounts": {
|
|
||||||
"task": "paperless_mail.tasks.process_mail_accounts",
|
|
||||||
"schedule": crontab(minute="*/10"),
|
|
||||||
"options": {"expires": mail_expire},
|
|
||||||
},
|
|
||||||
"Train the classifier": {
|
|
||||||
"task": "documents.tasks.train_classifier",
|
|
||||||
"schedule": crontab(minute="5", hour="*/1"),
|
|
||||||
"options": {"expires": classifier_expire},
|
|
||||||
},
|
|
||||||
"Optimize the index": {
|
|
||||||
"task": "documents.tasks.index_optimize",
|
|
||||||
"schedule": crontab(minute=0, hour=0),
|
|
||||||
"options": {"expires": index_expire},
|
|
||||||
},
|
|
||||||
"Perform sanity check": {
|
|
||||||
"task": "documents.tasks.sanity_check",
|
|
||||||
"schedule": crontab(minute=30, hour=0, day_of_week="sun"),
|
|
||||||
"options": {"expires": sanity_expire},
|
|
||||||
},
|
|
||||||
"Empty trash": {
|
|
||||||
"task": "documents.tasks.empty_trash",
|
|
||||||
"schedule": crontab(minute=0, hour="1"),
|
|
||||||
"options": {"expires": empty_trash_expire},
|
|
||||||
},
|
|
||||||
"Check and run scheduled workflows": {
|
|
||||||
"task": "documents.tasks.check_scheduled_workflows",
|
|
||||||
"schedule": crontab(minute="5", hour="*/1"),
|
|
||||||
"options": {"expires": workflow_expire},
|
|
||||||
},
|
|
||||||
"Rebuild LLM index": {
|
|
||||||
"task": "documents.tasks.llmindex_index",
|
|
||||||
"schedule": crontab(minute="10", hour="2"),
|
|
||||||
"options": {"expires": llm_index_expire},
|
|
||||||
},
|
|
||||||
"Cleanup expired share link bundles": {
|
|
||||||
"task": "documents.tasks.cleanup_expired_share_link_bundles",
|
|
||||||
"schedule": crontab(minute=0, hour="2"),
|
|
||||||
"options": {"expires": share_link_cleanup_expire},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
overrides = overrides or {}
|
|
||||||
disabled = disabled or set()
|
|
||||||
|
|
||||||
for key, val in overrides.items():
|
|
||||||
schedule[key] = {**schedule.get(key, {}), **val}
|
|
||||||
|
|
||||||
for key in disabled:
|
|
||||||
schedule.pop(key, None)
|
|
||||||
|
|
||||||
return schedule
|
|
||||||
|
|
||||||
|
|
||||||
class TestParseBeatSchedule:
|
|
||||||
@pytest.mark.parametrize(
|
|
||||||
("env", "expected"),
|
|
||||||
[
|
|
||||||
pytest.param({}, make_expected_schedule(), id="defaults"),
|
|
||||||
pytest.param(
|
|
||||||
{"PAPERLESS_EMAIL_TASK_CRON": "*/50 * * * mon"},
|
|
||||||
make_expected_schedule(
|
|
||||||
overrides={
|
|
||||||
"Check all e-mail accounts": {
|
|
||||||
"schedule": crontab(minute="*/50", day_of_week="mon"),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
),
|
|
||||||
id="email-changed",
|
|
||||||
),
|
|
||||||
pytest.param(
|
|
||||||
{"PAPERLESS_INDEX_TASK_CRON": "disable"},
|
|
||||||
make_expected_schedule(disabled={"Optimize the index"}),
|
|
||||||
id="index-disabled",
|
|
||||||
),
|
|
||||||
pytest.param(
|
|
||||||
{
|
|
||||||
"PAPERLESS_EMAIL_TASK_CRON": "disable",
|
|
||||||
"PAPERLESS_TRAIN_TASK_CRON": "disable",
|
|
||||||
"PAPERLESS_SANITY_TASK_CRON": "disable",
|
|
||||||
"PAPERLESS_INDEX_TASK_CRON": "disable",
|
|
||||||
"PAPERLESS_EMPTY_TRASH_TASK_CRON": "disable",
|
|
||||||
"PAPERLESS_WORKFLOW_SCHEDULED_TASK_CRON": "disable",
|
|
||||||
"PAPERLESS_LLM_INDEX_TASK_CRON": "disable",
|
|
||||||
"PAPERLESS_SHARE_LINK_BUNDLE_CLEANUP_CRON": "disable",
|
|
||||||
},
|
|
||||||
{},
|
|
||||||
id="all-disabled",
|
|
||||||
),
|
|
||||||
],
|
|
||||||
)
|
|
||||||
def test_parse_beat_schedule(
|
|
||||||
self,
|
|
||||||
env: dict[str, str],
|
|
||||||
expected: dict[str, Any],
|
|
||||||
mocker: MockerFixture,
|
|
||||||
) -> None:
|
|
||||||
mocker.patch.dict(os.environ, env, clear=False)
|
|
||||||
schedule = parse_beat_schedule()
|
|
||||||
assert schedule == expected
|
|
||||||
|
|
||||||
|
|
||||||
class TestParseDbSettings:
|
class TestParseDbSettings:
|
||||||
@@ -533,85 +264,3 @@ class TestParseDbSettings:
|
|||||||
settings = parse_db_settings(tmp_path)
|
settings = parse_db_settings(tmp_path)
|
||||||
|
|
||||||
assert settings == expected_database_settings
|
assert settings == expected_database_settings
|
||||||
|
|
||||||
|
|
||||||
class TestParseIgnoreDates:
|
|
||||||
"""Tests the parsing of the PAPERLESS_IGNORE_DATES setting value."""
|
|
||||||
|
|
||||||
def test_no_ignore_dates_set(self) -> None:
|
|
||||||
"""
|
|
||||||
GIVEN:
|
|
||||||
- No ignore dates are set
|
|
||||||
THEN:
|
|
||||||
- No ignore dates are parsed
|
|
||||||
"""
|
|
||||||
assert parse_ignore_dates("", "YMD") == set()
|
|
||||||
|
|
||||||
@pytest.mark.parametrize(
|
|
||||||
("env_str", "date_format", "expected"),
|
|
||||||
[
|
|
||||||
pytest.param(
|
|
||||||
"1985-05-01",
|
|
||||||
"YMD",
|
|
||||||
{datetime.date(1985, 5, 1)},
|
|
||||||
id="single-ymd",
|
|
||||||
),
|
|
||||||
pytest.param(
|
|
||||||
"1985-05-01,1991-12-05",
|
|
||||||
"YMD",
|
|
||||||
{datetime.date(1985, 5, 1), datetime.date(1991, 12, 5)},
|
|
||||||
id="multiple-ymd",
|
|
||||||
),
|
|
||||||
pytest.param(
|
|
||||||
"2010-12-13",
|
|
||||||
"YMD",
|
|
||||||
{datetime.date(2010, 12, 13)},
|
|
||||||
id="single-ymd-2",
|
|
||||||
),
|
|
||||||
pytest.param(
|
|
||||||
"11.01.10",
|
|
||||||
"DMY",
|
|
||||||
{datetime.date(2010, 1, 11)},
|
|
||||||
id="single-dmy",
|
|
||||||
),
|
|
||||||
pytest.param(
|
|
||||||
"11.01.2001,15-06-1996",
|
|
||||||
"DMY",
|
|
||||||
{datetime.date(2001, 1, 11), datetime.date(1996, 6, 15)},
|
|
||||||
id="multiple-dmy",
|
|
||||||
),
|
|
||||||
],
|
|
||||||
)
|
|
||||||
def test_ignore_dates_parsed(
|
|
||||||
self,
|
|
||||||
env_str: str,
|
|
||||||
date_format: str,
|
|
||||||
expected: set[datetime.date],
|
|
||||||
) -> None:
|
|
||||||
"""
|
|
||||||
GIVEN:
|
|
||||||
- Ignore dates are set per certain inputs
|
|
||||||
THEN:
|
|
||||||
- All ignore dates are parsed
|
|
||||||
"""
|
|
||||||
assert parse_ignore_dates(env_str, date_format) == expected
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize(
|
|
||||||
("languages", "expected"),
|
|
||||||
[
|
|
||||||
("de", ["de"]),
|
|
||||||
("zh", ["zh"]),
|
|
||||||
("fr+en", ["fr", "en"]),
|
|
||||||
# Locales must be supported
|
|
||||||
("en-001+fr-CA", ["en-001", "fr-CA"]),
|
|
||||||
("en-001+fr", ["en-001", "fr"]),
|
|
||||||
# Special case for Chinese: variants seem to miss some dates,
|
|
||||||
# so we always add "zh" as a fallback.
|
|
||||||
("en+zh-Hans-HK", ["en", "zh-Hans-HK", "zh"]),
|
|
||||||
("en+zh-Hans", ["en", "zh-Hans", "zh"]),
|
|
||||||
("en+zh-Hans+zh-Hant", ["en", "zh-Hans", "zh-Hant", "zh"]),
|
|
||||||
],
|
|
||||||
)
|
|
||||||
def test_parse_dateparser_languages(languages: str, expected: list[str]) -> None:
|
|
||||||
assert sorted(parse_dateparser_languages(languages)) == sorted(expected)
|
|
||||||
|
|||||||
@@ -4,12 +4,8 @@ from pathlib import Path
|
|||||||
import pytest
|
import pytest
|
||||||
from pytest_mock import MockerFixture
|
from pytest_mock import MockerFixture
|
||||||
|
|
||||||
from paperless.settings.parsers import get_bool_from_env
|
|
||||||
from paperless.settings.parsers import get_choice_from_env
|
from paperless.settings.parsers import get_choice_from_env
|
||||||
from paperless.settings.parsers import get_float_from_env
|
|
||||||
from paperless.settings.parsers import get_int_from_env
|
from paperless.settings.parsers import get_int_from_env
|
||||||
from paperless.settings.parsers import get_list_from_env
|
|
||||||
from paperless.settings.parsers import get_path_from_env
|
|
||||||
from paperless.settings.parsers import parse_dict_from_str
|
from paperless.settings.parsers import parse_dict_from_str
|
||||||
from paperless.settings.parsers import str_to_bool
|
from paperless.settings.parsers import str_to_bool
|
||||||
|
|
||||||
@@ -209,29 +205,6 @@ class TestParseDictFromString:
|
|||||||
assert isinstance(result["database"]["port"], int)
|
assert isinstance(result["database"]["port"], int)
|
||||||
|
|
||||||
|
|
||||||
class TestGetBoolFromEnv:
|
|
||||||
def test_existing_env_var(self, mocker):
|
|
||||||
"""Test that an existing environment variable is read and converted."""
|
|
||||||
mocker.patch.dict(os.environ, {"TEST_VAR": "true"})
|
|
||||||
assert get_bool_from_env("TEST_VAR") is True
|
|
||||||
|
|
||||||
def test_missing_env_var_uses_default_no(self, mocker):
|
|
||||||
"""Test that a missing environment variable uses default 'NO' and returns False."""
|
|
||||||
mocker.patch.dict(os.environ, {}, clear=True)
|
|
||||||
assert get_bool_from_env("MISSING_VAR") is False
|
|
||||||
|
|
||||||
def test_missing_env_var_with_explicit_default(self, mocker):
|
|
||||||
"""Test that a missing environment variable uses the provided default."""
|
|
||||||
mocker.patch.dict(os.environ, {}, clear=True)
|
|
||||||
assert get_bool_from_env("MISSING_VAR", default="yes") is True
|
|
||||||
|
|
||||||
def test_invalid_value_raises_error(self, mocker):
|
|
||||||
"""Test that an invalid value raises ValueError (delegates to str_to_bool)."""
|
|
||||||
mocker.patch.dict(os.environ, {"INVALID_VAR": "maybe"})
|
|
||||||
with pytest.raises(ValueError):
|
|
||||||
get_bool_from_env("INVALID_VAR")
|
|
||||||
|
|
||||||
|
|
||||||
class TestGetIntFromEnv:
|
class TestGetIntFromEnv:
|
||||||
@pytest.mark.parametrize(
|
@pytest.mark.parametrize(
|
||||||
("env_value", "expected"),
|
("env_value", "expected"),
|
||||||
@@ -286,199 +259,6 @@ class TestGetIntFromEnv:
|
|||||||
get_int_from_env("INVALID_INT")
|
get_int_from_env("INVALID_INT")
|
||||||
|
|
||||||
|
|
||||||
class TestGetFloatFromEnv:
|
|
||||||
@pytest.mark.parametrize(
|
|
||||||
("env_value", "expected"),
|
|
||||||
[
|
|
||||||
pytest.param("3.14", 3.14, id="pi"),
|
|
||||||
pytest.param("42", 42.0, id="int_as_float"),
|
|
||||||
pytest.param("-2.5", -2.5, id="negative"),
|
|
||||||
pytest.param("0.0", 0.0, id="zero_float"),
|
|
||||||
pytest.param("0", 0.0, id="zero_int"),
|
|
||||||
pytest.param("1.5e2", 150.0, id="sci_positive"),
|
|
||||||
pytest.param("1e-3", 0.001, id="sci_negative"),
|
|
||||||
pytest.param("-1.23e4", -12300.0, id="sci_large"),
|
|
||||||
],
|
|
||||||
)
|
|
||||||
def test_existing_env_var_valid_floats(self, mocker, env_value, expected):
|
|
||||||
"""Test that existing environment variables with valid floats return correct values."""
|
|
||||||
mocker.patch.dict(os.environ, {"FLOAT_VAR": env_value})
|
|
||||||
assert get_float_from_env("FLOAT_VAR") == expected
|
|
||||||
|
|
||||||
@pytest.mark.parametrize(
|
|
||||||
("default", "expected"),
|
|
||||||
[
|
|
||||||
pytest.param(3.14, 3.14, id="pi_default"),
|
|
||||||
pytest.param(0.0, 0.0, id="zero_default"),
|
|
||||||
pytest.param(-2.5, -2.5, id="negative_default"),
|
|
||||||
pytest.param(None, None, id="none_default"),
|
|
||||||
],
|
|
||||||
)
|
|
||||||
def test_missing_env_var_with_defaults(self, mocker, default, expected):
|
|
||||||
"""Test that missing environment variables return provided defaults."""
|
|
||||||
mocker.patch.dict(os.environ, {}, clear=True)
|
|
||||||
assert get_float_from_env("MISSING_VAR", default=default) == expected
|
|
||||||
|
|
||||||
def test_missing_env_var_no_default(self, mocker):
|
|
||||||
"""Test that missing environment variable with no default returns None."""
|
|
||||||
mocker.patch.dict(os.environ, {}, clear=True)
|
|
||||||
assert get_float_from_env("MISSING_VAR") is None
|
|
||||||
|
|
||||||
@pytest.mark.parametrize(
|
|
||||||
"invalid_value",
|
|
||||||
[
|
|
||||||
pytest.param("not_a_number", id="text"),
|
|
||||||
pytest.param("42.5.0", id="double_decimal"),
|
|
||||||
pytest.param("42a", id="alpha_suffix"),
|
|
||||||
pytest.param("", id="empty"),
|
|
||||||
pytest.param(" ", id="whitespace"),
|
|
||||||
pytest.param("true", id="boolean"),
|
|
||||||
pytest.param("1.2.3", id="triple_decimal"),
|
|
||||||
],
|
|
||||||
)
|
|
||||||
def test_invalid_float_values_raise_error(self, mocker, invalid_value):
|
|
||||||
"""Test that invalid float values raise ValueError."""
|
|
||||||
mocker.patch.dict(os.environ, {"INVALID_FLOAT": invalid_value})
|
|
||||||
with pytest.raises(ValueError):
|
|
||||||
get_float_from_env("INVALID_FLOAT")
|
|
||||||
|
|
||||||
|
|
||||||
class TestGetPathFromEnv:
|
|
||||||
@pytest.mark.parametrize(
|
|
||||||
"env_value",
|
|
||||||
[
|
|
||||||
pytest.param("/tmp/test", id="absolute"),
|
|
||||||
pytest.param("relative/path", id="relative"),
|
|
||||||
pytest.param("/path/with spaces/file.txt", id="spaces"),
|
|
||||||
pytest.param(".", id="current_dir"),
|
|
||||||
pytest.param("..", id="parent_dir"),
|
|
||||||
pytest.param("/", id="root"),
|
|
||||||
],
|
|
||||||
)
|
|
||||||
def test_existing_env_var_paths(self, mocker, env_value):
|
|
||||||
"""Test that existing environment variables with paths return resolved Path objects."""
|
|
||||||
mocker.patch.dict(os.environ, {"PATH_VAR": env_value})
|
|
||||||
result = get_path_from_env("PATH_VAR")
|
|
||||||
assert isinstance(result, Path)
|
|
||||||
assert result == Path(env_value).resolve()
|
|
||||||
|
|
||||||
def test_missing_env_var_no_default(self, mocker):
|
|
||||||
"""Test that missing environment variable with no default returns None."""
|
|
||||||
mocker.patch.dict(os.environ, {}, clear=True)
|
|
||||||
assert get_path_from_env("MISSING_VAR") is None
|
|
||||||
|
|
||||||
def test_missing_env_var_with_none_default(self, mocker):
|
|
||||||
"""Test that missing environment variable with None default returns None."""
|
|
||||||
mocker.patch.dict(os.environ, {}, clear=True)
|
|
||||||
assert get_path_from_env("MISSING_VAR", default=None) is None
|
|
||||||
|
|
||||||
@pytest.mark.parametrize(
|
|
||||||
"default_path_str",
|
|
||||||
[
|
|
||||||
pytest.param("/default/path", id="absolute_default"),
|
|
||||||
pytest.param("relative/default", id="relative_default"),
|
|
||||||
pytest.param(".", id="current_default"),
|
|
||||||
],
|
|
||||||
)
|
|
||||||
def test_missing_env_var_with_path_defaults(self, mocker, default_path_str):
|
|
||||||
"""Test that missing environment variables return resolved default Path objects."""
|
|
||||||
mocker.patch.dict(os.environ, {}, clear=True)
|
|
||||||
default_path = Path(default_path_str)
|
|
||||||
result = get_path_from_env("MISSING_VAR", default=default_path)
|
|
||||||
assert isinstance(result, Path)
|
|
||||||
assert result == default_path.resolve()
|
|
||||||
|
|
||||||
def test_relative_paths_are_resolved(self, mocker):
|
|
||||||
"""Test that relative paths are properly resolved to absolute paths."""
|
|
||||||
mocker.patch.dict(os.environ, {"REL_PATH": "relative/path"})
|
|
||||||
result = get_path_from_env("REL_PATH")
|
|
||||||
assert result is not None
|
|
||||||
assert result.is_absolute()
|
|
||||||
|
|
||||||
|
|
||||||
class TestGetListFromEnv:
|
|
||||||
@pytest.mark.parametrize(
|
|
||||||
("env_value", "expected"),
|
|
||||||
[
|
|
||||||
pytest.param("a,b,c", ["a", "b", "c"], id="basic_comma_separated"),
|
|
||||||
pytest.param("single", ["single"], id="single_element"),
|
|
||||||
pytest.param("", [], id="empty_string"),
|
|
||||||
pytest.param("a, b , c", ["a", "b", "c"], id="whitespace_trimmed"),
|
|
||||||
pytest.param("a,,b,c", ["a", "b", "c"], id="empty_elements_removed"),
|
|
||||||
],
|
|
||||||
)
|
|
||||||
def test_existing_env_var_basic_parsing(self, mocker, env_value, expected):
|
|
||||||
"""Test that existing environment variables are parsed correctly."""
|
|
||||||
mocker.patch.dict(os.environ, {"LIST_VAR": env_value})
|
|
||||||
result = get_list_from_env("LIST_VAR")
|
|
||||||
assert result == expected
|
|
||||||
|
|
||||||
@pytest.mark.parametrize(
|
|
||||||
("separator", "env_value", "expected"),
|
|
||||||
[
|
|
||||||
pytest.param("|", "a|b|c", ["a", "b", "c"], id="pipe_separator"),
|
|
||||||
pytest.param(":", "a:b:c", ["a", "b", "c"], id="colon_separator"),
|
|
||||||
pytest.param(";", "a;b;c", ["a", "b", "c"], id="semicolon_separator"),
|
|
||||||
],
|
|
||||||
)
|
|
||||||
def test_custom_separators(self, mocker, separator, env_value, expected):
|
|
||||||
"""Test that custom separators work correctly."""
|
|
||||||
mocker.patch.dict(os.environ, {"LIST_VAR": env_value})
|
|
||||||
result = get_list_from_env("LIST_VAR", separator=separator)
|
|
||||||
assert result == expected
|
|
||||||
|
|
||||||
@pytest.mark.parametrize(
|
|
||||||
("default", "expected"),
|
|
||||||
[
|
|
||||||
pytest.param(
|
|
||||||
["default1", "default2"],
|
|
||||||
["default1", "default2"],
|
|
||||||
id="string_list_default",
|
|
||||||
),
|
|
||||||
pytest.param([1, 2, 3], [1, 2, 3], id="int_list_default"),
|
|
||||||
pytest.param(None, [], id="none_default_returns_empty_list"),
|
|
||||||
],
|
|
||||||
)
|
|
||||||
def test_missing_env_var_with_defaults(self, mocker, default, expected):
|
|
||||||
"""Test that missing environment variables return provided defaults."""
|
|
||||||
mocker.patch.dict(os.environ, {}, clear=True)
|
|
||||||
result = get_list_from_env("MISSING_VAR", default=default)
|
|
||||||
assert result == expected
|
|
||||||
|
|
||||||
def test_missing_env_var_no_default(self, mocker):
|
|
||||||
"""Test that missing environment variable with no default returns empty list."""
|
|
||||||
mocker.patch.dict(os.environ, {}, clear=True)
|
|
||||||
result = get_list_from_env("MISSING_VAR")
|
|
||||||
assert result == []
|
|
||||||
|
|
||||||
def test_required_env_var_missing_raises_error(self, mocker):
|
|
||||||
"""Test that missing required environment variable raises ValueError."""
|
|
||||||
mocker.patch.dict(os.environ, {}, clear=True)
|
|
||||||
with pytest.raises(
|
|
||||||
ValueError,
|
|
||||||
match="Required environment variable 'REQUIRED_VAR' is not set",
|
|
||||||
):
|
|
||||||
get_list_from_env("REQUIRED_VAR", required=True)
|
|
||||||
|
|
||||||
def test_required_env_var_with_default_does_not_raise(self, mocker):
|
|
||||||
"""Test that required environment variable with default does not raise error."""
|
|
||||||
mocker.patch.dict(os.environ, {}, clear=True)
|
|
||||||
result = get_list_from_env("REQUIRED_VAR", default=["default"], required=True)
|
|
||||||
assert result == ["default"]
|
|
||||||
|
|
||||||
def test_strip_whitespace_false(self, mocker):
|
|
||||||
"""Test that whitespace is preserved when strip_whitespace=False."""
|
|
||||||
mocker.patch.dict(os.environ, {"LIST_VAR": " a , b , c "})
|
|
||||||
result = get_list_from_env("LIST_VAR", strip_whitespace=False)
|
|
||||||
assert result == [" a ", " b ", " c "]
|
|
||||||
|
|
||||||
def test_remove_empty_false(self, mocker):
|
|
||||||
"""Test that empty elements are preserved when remove_empty=False."""
|
|
||||||
mocker.patch.dict(os.environ, {"LIST_VAR": "a,,b,,c"})
|
|
||||||
result = get_list_from_env("LIST_VAR", remove_empty=False)
|
|
||||||
assert result == ["a", "", "b", "", "c"]
|
|
||||||
|
|
||||||
|
|
||||||
class TestGetEnvChoice:
|
class TestGetEnvChoice:
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
def valid_choices(self) -> set[str]:
|
def valid_choices(self) -> set[str]:
|
||||||
@@ -614,3 +394,21 @@ class TestGetEnvChoice:
|
|||||||
result = get_choice_from_env("TEST_ENV", large_choices)
|
result = get_choice_from_env("TEST_ENV", large_choices)
|
||||||
|
|
||||||
assert result == "option_50"
|
assert result == "option_50"
|
||||||
|
|
||||||
|
def test_different_env_keys(
|
||||||
|
self,
|
||||||
|
mocker: MockerFixture,
|
||||||
|
valid_choices: set[str],
|
||||||
|
) -> None:
|
||||||
|
"""Test function works with different environment variable keys."""
|
||||||
|
test_cases = [
|
||||||
|
("DJANGO_ENV", "development"),
|
||||||
|
("DATABASE_BACKEND", "staging"),
|
||||||
|
("LOG_LEVEL", "production"),
|
||||||
|
("APP_MODE", "development"),
|
||||||
|
]
|
||||||
|
|
||||||
|
for env_key, env_value in test_cases:
|
||||||
|
mocker.patch.dict("os.environ", {env_key: env_value})
|
||||||
|
result = get_choice_from_env(env_key, valid_choices)
|
||||||
|
assert result == env_value
|
||||||
|
|||||||
@@ -1,56 +0,0 @@
|
|||||||
import os
|
|
||||||
from unittest import TestCase
|
|
||||||
from unittest import mock
|
|
||||||
|
|
||||||
from paperless.settings import _parse_paperless_url
|
|
||||||
from paperless.settings import default_threads_per_worker
|
|
||||||
|
|
||||||
|
|
||||||
class TestThreadCalculation(TestCase):
|
|
||||||
def test_workers_threads(self) -> None:
|
|
||||||
"""
|
|
||||||
GIVEN:
|
|
||||||
- Certain CPU counts
|
|
||||||
WHEN:
|
|
||||||
- Threads per worker is calculated
|
|
||||||
THEN:
|
|
||||||
- Threads per worker less than or equal to CPU count
|
|
||||||
- At least 1 thread per worker
|
|
||||||
"""
|
|
||||||
default_workers = 1
|
|
||||||
|
|
||||||
for i in range(1, 64):
|
|
||||||
with mock.patch(
|
|
||||||
"paperless.settings.multiprocessing.cpu_count",
|
|
||||||
) as cpu_count:
|
|
||||||
cpu_count.return_value = i
|
|
||||||
|
|
||||||
default_threads = default_threads_per_worker(default_workers)
|
|
||||||
|
|
||||||
self.assertGreaterEqual(default_threads, 1)
|
|
||||||
|
|
||||||
self.assertLessEqual(default_workers * default_threads, i)
|
|
||||||
|
|
||||||
|
|
||||||
class TestPaperlessURLSettings(TestCase):
|
|
||||||
def test_paperless_url(self) -> None:
|
|
||||||
"""
|
|
||||||
GIVEN:
|
|
||||||
- PAPERLESS_URL is set
|
|
||||||
WHEN:
|
|
||||||
- The URL is parsed
|
|
||||||
THEN:
|
|
||||||
- The URL is returned and present in related settings
|
|
||||||
"""
|
|
||||||
with mock.patch.dict(
|
|
||||||
os.environ,
|
|
||||||
{
|
|
||||||
"PAPERLESS_URL": "https://example.com",
|
|
||||||
},
|
|
||||||
):
|
|
||||||
url = _parse_paperless_url()
|
|
||||||
self.assertEqual("https://example.com", url)
|
|
||||||
from django.conf import settings
|
|
||||||
|
|
||||||
self.assertIn(url, settings.CSRF_TRUSTED_ORIGINS)
|
|
||||||
self.assertIn(url, settings.CORS_ALLOWED_ORIGINS)
|
|
||||||
@@ -1,100 +1,107 @@
|
|||||||
import logging
|
from unittest import mock
|
||||||
|
|
||||||
import pytest
|
|
||||||
from allauth.account.adapter import get_adapter
|
from allauth.account.adapter import get_adapter
|
||||||
from allauth.core import context
|
from allauth.core import context
|
||||||
from allauth.socialaccount.adapter import get_adapter as get_social_adapter
|
from allauth.socialaccount.adapter import get_adapter as get_social_adapter
|
||||||
|
from django.conf import settings
|
||||||
from django.contrib.auth.models import AnonymousUser
|
from django.contrib.auth.models import AnonymousUser
|
||||||
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.forms import ValidationError
|
from django.forms import ValidationError
|
||||||
from django.http import HttpRequest
|
from django.http import HttpRequest
|
||||||
|
from django.test import TestCase
|
||||||
|
from django.test import override_settings
|
||||||
from django.urls import reverse
|
from django.urls import reverse
|
||||||
from pytest_django.fixtures import SettingsWrapper
|
|
||||||
from pytest_mock import MockerFixture
|
|
||||||
from rest_framework.authtoken.models import Token
|
from rest_framework.authtoken.models import Token
|
||||||
|
|
||||||
from paperless.adapter import DrfTokenStrategy
|
from paperless.adapter import DrfTokenStrategy
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.django_db
|
class TestCustomAccountAdapter(TestCase):
|
||||||
class TestCustomAccountAdapter:
|
def test_is_open_for_signup(self) -> None:
|
||||||
def test_is_open_for_signup(self, settings: SettingsWrapper) -> None:
|
|
||||||
adapter = get_adapter()
|
adapter = get_adapter()
|
||||||
|
|
||||||
# With no accounts, signups should be allowed
|
# With no accounts, signups should be allowed
|
||||||
assert adapter.is_open_for_signup(None)
|
self.assertTrue(adapter.is_open_for_signup(None))
|
||||||
|
|
||||||
User.objects.create_user("testuser")
|
User.objects.create_user("testuser")
|
||||||
|
|
||||||
|
# Test when ACCOUNT_ALLOW_SIGNUPS is True
|
||||||
settings.ACCOUNT_ALLOW_SIGNUPS = True
|
settings.ACCOUNT_ALLOW_SIGNUPS = True
|
||||||
assert adapter.is_open_for_signup(None)
|
self.assertTrue(adapter.is_open_for_signup(None))
|
||||||
|
|
||||||
|
# Test when ACCOUNT_ALLOW_SIGNUPS is False
|
||||||
settings.ACCOUNT_ALLOW_SIGNUPS = False
|
settings.ACCOUNT_ALLOW_SIGNUPS = False
|
||||||
assert not adapter.is_open_for_signup(None)
|
self.assertFalse(adapter.is_open_for_signup(None))
|
||||||
|
|
||||||
def test_is_safe_url(self, settings: SettingsWrapper) -> None:
|
def test_is_safe_url(self) -> None:
|
||||||
request = HttpRequest()
|
request = HttpRequest()
|
||||||
request.get_host = lambda: "example.com"
|
request.get_host = mock.Mock(return_value="example.com")
|
||||||
with context.request_context(request):
|
with context.request_context(request):
|
||||||
adapter = get_adapter()
|
adapter = get_adapter()
|
||||||
|
with override_settings(ALLOWED_HOSTS=["*"]):
|
||||||
|
# True because request host is same
|
||||||
|
url = "https://example.com"
|
||||||
|
self.assertTrue(adapter.is_safe_url(url))
|
||||||
|
|
||||||
settings.ALLOWED_HOSTS = ["*"]
|
url = "https://evil.com"
|
||||||
# True because request host is same
|
|
||||||
assert adapter.is_safe_url("https://example.com")
|
|
||||||
# False despite wildcard because request host is different
|
# False despite wildcard because request host is different
|
||||||
assert not adapter.is_safe_url("https://evil.com")
|
self.assertFalse(adapter.is_safe_url(url))
|
||||||
|
|
||||||
settings.ALLOWED_HOSTS = ["example.com"]
|
settings.ALLOWED_HOSTS = ["example.com"]
|
||||||
|
url = "https://example.com"
|
||||||
# True because request host is same
|
# True because request host is same
|
||||||
assert adapter.is_safe_url("https://example.com")
|
self.assertTrue(adapter.is_safe_url(url))
|
||||||
|
|
||||||
settings.ALLOWED_HOSTS = ["*", "example.com"]
|
settings.ALLOWED_HOSTS = ["*", "example.com"]
|
||||||
|
url = "//evil.com"
|
||||||
# False because request host is not in allowed hosts
|
# False because request host is not in allowed hosts
|
||||||
assert not adapter.is_safe_url("//evil.com")
|
self.assertFalse(adapter.is_safe_url(url))
|
||||||
|
|
||||||
def test_pre_authenticate(
|
@mock.patch("allauth.core.internal.ratelimit.consume", return_value=True)
|
||||||
self,
|
def test_pre_authenticate(self, mock_consume) -> None:
|
||||||
settings: SettingsWrapper,
|
|
||||||
mocker: MockerFixture,
|
|
||||||
) -> None:
|
|
||||||
mocker.patch("allauth.core.internal.ratelimit.consume", return_value=True)
|
|
||||||
adapter = get_adapter()
|
adapter = get_adapter()
|
||||||
request = HttpRequest()
|
request = HttpRequest()
|
||||||
request.get_host = lambda: "example.com"
|
request.get_host = mock.Mock(return_value="example.com")
|
||||||
|
|
||||||
settings.DISABLE_REGULAR_LOGIN = False
|
settings.DISABLE_REGULAR_LOGIN = False
|
||||||
adapter.pre_authenticate(request)
|
adapter.pre_authenticate(request)
|
||||||
|
|
||||||
settings.DISABLE_REGULAR_LOGIN = True
|
settings.DISABLE_REGULAR_LOGIN = True
|
||||||
with pytest.raises(ValidationError):
|
with self.assertRaises(ValidationError):
|
||||||
adapter.pre_authenticate(request)
|
adapter.pre_authenticate(request)
|
||||||
|
|
||||||
def test_get_reset_password_from_key_url(self, settings: SettingsWrapper) -> None:
|
def test_get_reset_password_from_key_url(self) -> None:
|
||||||
request = HttpRequest()
|
request = HttpRequest()
|
||||||
request.get_host = lambda: "foo.org"
|
request.get_host = mock.Mock(return_value="foo.org")
|
||||||
with context.request_context(request):
|
with context.request_context(request):
|
||||||
adapter = get_adapter()
|
adapter = get_adapter()
|
||||||
|
|
||||||
settings.PAPERLESS_URL = None
|
# Test when PAPERLESS_URL is None
|
||||||
settings.ACCOUNT_DEFAULT_HTTP_PROTOCOL = "https"
|
with override_settings(
|
||||||
expected_url = f"https://foo.org{reverse('account_reset_password_from_key', kwargs={'uidb36': 'UID', 'key': 'KEY'})}"
|
PAPERLESS_URL=None,
|
||||||
assert adapter.get_reset_password_from_key_url("UID-KEY") == expected_url
|
ACCOUNT_DEFAULT_HTTP_PROTOCOL="https",
|
||||||
|
):
|
||||||
|
expected_url = f"https://foo.org{reverse('account_reset_password_from_key', kwargs={'uidb36': 'UID', 'key': 'KEY'})}"
|
||||||
|
self.assertEqual(
|
||||||
|
adapter.get_reset_password_from_key_url("UID-KEY"),
|
||||||
|
expected_url,
|
||||||
|
)
|
||||||
|
|
||||||
settings.PAPERLESS_URL = "https://bar.com"
|
# Test when PAPERLESS_URL is not None
|
||||||
expected_url = f"https://bar.com{reverse('account_reset_password_from_key', kwargs={'uidb36': 'UID', 'key': 'KEY'})}"
|
with override_settings(PAPERLESS_URL="https://bar.com"):
|
||||||
assert adapter.get_reset_password_from_key_url("UID-KEY") == expected_url
|
expected_url = f"https://bar.com{reverse('account_reset_password_from_key', kwargs={'uidb36': 'UID', 'key': 'KEY'})}"
|
||||||
|
self.assertEqual(
|
||||||
|
adapter.get_reset_password_from_key_url("UID-KEY"),
|
||||||
|
expected_url,
|
||||||
|
)
|
||||||
|
|
||||||
def test_save_user_adds_groups(
|
@override_settings(ACCOUNT_DEFAULT_GROUPS=["group1", "group2"])
|
||||||
self,
|
def test_save_user_adds_groups(self) -> None:
|
||||||
settings: SettingsWrapper,
|
|
||||||
mocker: MockerFixture,
|
|
||||||
) -> None:
|
|
||||||
settings.ACCOUNT_DEFAULT_GROUPS = ["group1", "group2"]
|
|
||||||
Group.objects.create(name="group1")
|
Group.objects.create(name="group1")
|
||||||
user = User.objects.create_user("testuser")
|
user = User.objects.create_user("testuser")
|
||||||
adapter = get_adapter()
|
adapter = get_adapter()
|
||||||
form = mocker.MagicMock(
|
form = mock.Mock(
|
||||||
cleaned_data={
|
cleaned_data={
|
||||||
"username": "testuser",
|
"username": "testuser",
|
||||||
"email": "user@example.com",
|
"email": "user@example.com",
|
||||||
@@ -103,81 +110,88 @@ class TestCustomAccountAdapter:
|
|||||||
|
|
||||||
user = adapter.save_user(HttpRequest(), user, form, commit=True)
|
user = adapter.save_user(HttpRequest(), user, form, commit=True)
|
||||||
|
|
||||||
assert user.groups.count() == 1
|
self.assertEqual(user.groups.count(), 1)
|
||||||
assert user.groups.filter(name="group1").exists()
|
self.assertTrue(user.groups.filter(name="group1").exists())
|
||||||
assert not user.groups.filter(name="group2").exists()
|
self.assertFalse(user.groups.filter(name="group2").exists())
|
||||||
|
|
||||||
def test_fresh_install_save_creates_superuser(self, mocker: MockerFixture) -> None:
|
def test_fresh_install_save_creates_superuser(self) -> None:
|
||||||
adapter = get_adapter()
|
adapter = get_adapter()
|
||||||
form = mocker.MagicMock(
|
form = mock.Mock(
|
||||||
cleaned_data={
|
cleaned_data={
|
||||||
"username": "testuser",
|
"username": "testuser",
|
||||||
"email": "user@paperless-ngx.com",
|
"email": "user@paperless-ngx.com",
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
user = adapter.save_user(HttpRequest(), User(), form, commit=True)
|
user = adapter.save_user(HttpRequest(), User(), form, commit=True)
|
||||||
assert user.is_superuser
|
self.assertTrue(user.is_superuser)
|
||||||
|
|
||||||
form = mocker.MagicMock(
|
# Next time, it should not create a superuser
|
||||||
|
form = mock.Mock(
|
||||||
cleaned_data={
|
cleaned_data={
|
||||||
"username": "testuser2",
|
"username": "testuser2",
|
||||||
"email": "user2@paperless-ngx.com",
|
"email": "user2@paperless-ngx.com",
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
user2 = adapter.save_user(HttpRequest(), User(), form, commit=True)
|
user2 = adapter.save_user(HttpRequest(), User(), form, commit=True)
|
||||||
assert not user2.is_superuser
|
self.assertFalse(user2.is_superuser)
|
||||||
|
|
||||||
|
|
||||||
class TestCustomSocialAccountAdapter:
|
class TestCustomSocialAccountAdapter(TestCase):
|
||||||
@pytest.mark.django_db
|
def test_is_open_for_signup(self) -> None:
|
||||||
def test_is_open_for_signup(self, settings: SettingsWrapper) -> None:
|
|
||||||
adapter = get_social_adapter()
|
adapter = get_social_adapter()
|
||||||
|
|
||||||
|
# Test when SOCIALACCOUNT_ALLOW_SIGNUPS is True
|
||||||
settings.SOCIALACCOUNT_ALLOW_SIGNUPS = True
|
settings.SOCIALACCOUNT_ALLOW_SIGNUPS = True
|
||||||
assert adapter.is_open_for_signup(None, None)
|
self.assertTrue(adapter.is_open_for_signup(None, None))
|
||||||
|
|
||||||
|
# Test when SOCIALACCOUNT_ALLOW_SIGNUPS is False
|
||||||
settings.SOCIALACCOUNT_ALLOW_SIGNUPS = False
|
settings.SOCIALACCOUNT_ALLOW_SIGNUPS = False
|
||||||
assert not adapter.is_open_for_signup(None, None)
|
self.assertFalse(adapter.is_open_for_signup(None, None))
|
||||||
|
|
||||||
def test_get_connect_redirect_url(self) -> None:
|
def test_get_connect_redirect_url(self) -> None:
|
||||||
adapter = get_social_adapter()
|
adapter = get_social_adapter()
|
||||||
assert adapter.get_connect_redirect_url(None, None) == reverse("base")
|
request = None
|
||||||
|
socialaccount = None
|
||||||
|
|
||||||
@pytest.mark.django_db
|
# Test the default URL
|
||||||
def test_save_user_adds_groups(
|
expected_url = reverse("base")
|
||||||
self,
|
self.assertEqual(
|
||||||
settings: SettingsWrapper,
|
adapter.get_connect_redirect_url(request, socialaccount),
|
||||||
mocker: MockerFixture,
|
expected_url,
|
||||||
) -> None:
|
)
|
||||||
settings.SOCIAL_ACCOUNT_DEFAULT_GROUPS = ["group1", "group2"]
|
|
||||||
|
@override_settings(SOCIAL_ACCOUNT_DEFAULT_GROUPS=["group1", "group2"])
|
||||||
|
def test_save_user_adds_groups(self) -> None:
|
||||||
Group.objects.create(name="group1")
|
Group.objects.create(name="group1")
|
||||||
adapter = get_social_adapter()
|
adapter = get_social_adapter()
|
||||||
|
request = HttpRequest()
|
||||||
user = User.objects.create_user("testuser")
|
user = User.objects.create_user("testuser")
|
||||||
sociallogin = mocker.MagicMock(user=user)
|
sociallogin = mock.Mock(
|
||||||
|
user=user,
|
||||||
|
)
|
||||||
|
|
||||||
user = adapter.save_user(HttpRequest(), sociallogin, None)
|
user = adapter.save_user(request, sociallogin, None)
|
||||||
|
|
||||||
assert user.groups.count() == 1
|
self.assertEqual(user.groups.count(), 1)
|
||||||
assert user.groups.filter(name="group1").exists()
|
self.assertTrue(user.groups.filter(name="group1").exists())
|
||||||
assert not user.groups.filter(name="group2").exists()
|
self.assertFalse(user.groups.filter(name="group2").exists())
|
||||||
|
|
||||||
def test_error_logged_on_authentication_error(
|
def test_error_logged_on_authentication_error(self) -> None:
|
||||||
self,
|
|
||||||
caplog: pytest.LogCaptureFixture,
|
|
||||||
) -> None:
|
|
||||||
adapter = get_social_adapter()
|
adapter = get_social_adapter()
|
||||||
with caplog.at_level(logging.INFO, logger="paperless.auth"):
|
request = HttpRequest()
|
||||||
|
with self.assertLogs("paperless.auth", level="INFO") as log_cm:
|
||||||
adapter.on_authentication_error(
|
adapter.on_authentication_error(
|
||||||
HttpRequest(),
|
request,
|
||||||
provider="test-provider",
|
provider="test-provider",
|
||||||
error="Error",
|
error="Error",
|
||||||
exception="Test authentication error",
|
exception="Test authentication error",
|
||||||
)
|
)
|
||||||
assert any("Test authentication error" in msg for msg in caplog.messages)
|
self.assertTrue(
|
||||||
|
any("Test authentication error" in message for message in log_cm.output),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.django_db
|
class TestDrfTokenStrategy(TestCase):
|
||||||
class TestDrfTokenStrategy:
|
|
||||||
def test_create_access_token_creates_new_token(self) -> None:
|
def test_create_access_token_creates_new_token(self) -> None:
|
||||||
"""
|
"""
|
||||||
GIVEN:
|
GIVEN:
|
||||||
@@ -187,6 +201,7 @@ class TestDrfTokenStrategy:
|
|||||||
THEN:
|
THEN:
|
||||||
- A new token is created and its key is returned
|
- A new token is created and its key is returned
|
||||||
"""
|
"""
|
||||||
|
|
||||||
user = User.objects.create_user("testuser")
|
user = User.objects.create_user("testuser")
|
||||||
request = HttpRequest()
|
request = HttpRequest()
|
||||||
request.user = user
|
request.user = user
|
||||||
@@ -194,9 +209,13 @@ class TestDrfTokenStrategy:
|
|||||||
strategy = DrfTokenStrategy()
|
strategy = DrfTokenStrategy()
|
||||||
token_key = strategy.create_access_token(request)
|
token_key = strategy.create_access_token(request)
|
||||||
|
|
||||||
assert token_key is not None
|
# Verify a token was created
|
||||||
assert Token.objects.filter(user=user).exists()
|
self.assertIsNotNone(token_key)
|
||||||
assert token_key == Token.objects.get(user=user).key
|
self.assertTrue(Token.objects.filter(user=user).exists())
|
||||||
|
|
||||||
|
# Verify the returned key matches the created token
|
||||||
|
token = Token.objects.get(user=user)
|
||||||
|
self.assertEqual(token_key, token.key)
|
||||||
|
|
||||||
def test_create_access_token_returns_existing_token(self) -> None:
|
def test_create_access_token_returns_existing_token(self) -> None:
|
||||||
"""
|
"""
|
||||||
@@ -207,6 +226,7 @@ class TestDrfTokenStrategy:
|
|||||||
THEN:
|
THEN:
|
||||||
- The same token key is returned (no new token created)
|
- The same token key is returned (no new token created)
|
||||||
"""
|
"""
|
||||||
|
|
||||||
user = User.objects.create_user("testuser")
|
user = User.objects.create_user("testuser")
|
||||||
existing_token = Token.objects.create(user=user)
|
existing_token = Token.objects.create(user=user)
|
||||||
|
|
||||||
@@ -216,8 +236,11 @@ class TestDrfTokenStrategy:
|
|||||||
strategy = DrfTokenStrategy()
|
strategy = DrfTokenStrategy()
|
||||||
token_key = strategy.create_access_token(request)
|
token_key = strategy.create_access_token(request)
|
||||||
|
|
||||||
assert token_key == existing_token.key
|
# Verify the existing token key is returned
|
||||||
assert Token.objects.filter(user=user).count() == 1
|
self.assertEqual(token_key, existing_token.key)
|
||||||
|
|
||||||
|
# Verify only one token exists (no duplicate created)
|
||||||
|
self.assertEqual(Token.objects.filter(user=user).count(), 1)
|
||||||
|
|
||||||
def test_create_access_token_returns_none_for_unauthenticated_user(self) -> None:
|
def test_create_access_token_returns_none_for_unauthenticated_user(self) -> None:
|
||||||
"""
|
"""
|
||||||
@@ -228,11 +251,12 @@ class TestDrfTokenStrategy:
|
|||||||
THEN:
|
THEN:
|
||||||
- None is returned and no token is created
|
- None is returned and no token is created
|
||||||
"""
|
"""
|
||||||
|
|
||||||
request = HttpRequest()
|
request = HttpRequest()
|
||||||
request.user = AnonymousUser()
|
request.user = AnonymousUser()
|
||||||
|
|
||||||
strategy = DrfTokenStrategy()
|
strategy = DrfTokenStrategy()
|
||||||
token_key = strategy.create_access_token(request)
|
token_key = strategy.create_access_token(request)
|
||||||
|
|
||||||
assert token_key is None
|
self.assertIsNone(token_key)
|
||||||
assert Token.objects.count() == 0
|
self.assertEqual(Token.objects.count(), 0)
|
||||||
|
|||||||
@@ -1,15 +1,16 @@
|
|||||||
import os
|
import os
|
||||||
from collections.abc import Callable
|
|
||||||
from dataclasses import dataclass
|
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from unittest import mock
|
from unittest import mock
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
from django.core.checks import Error
|
from django.core.checks import Error
|
||||||
from django.core.checks import Warning
|
from django.core.checks import Warning
|
||||||
from pytest_django.fixtures import SettingsWrapper
|
from django.test import TestCase
|
||||||
|
from django.test import override_settings
|
||||||
from pytest_mock import MockerFixture
|
from pytest_mock import MockerFixture
|
||||||
|
|
||||||
|
from documents.tests.utils import DirectoriesMixin
|
||||||
|
from documents.tests.utils import FileSystemAssertsMixin
|
||||||
from paperless.checks import audit_log_check
|
from paperless.checks import audit_log_check
|
||||||
from paperless.checks import binaries_check
|
from paperless.checks import binaries_check
|
||||||
from paperless.checks import check_deprecated_db_settings
|
from paperless.checks import check_deprecated_db_settings
|
||||||
@@ -19,84 +20,54 @@ from paperless.checks import paths_check
|
|||||||
from paperless.checks import settings_values_check
|
from paperless.checks import settings_values_check
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True, slots=True)
|
class TestChecks(DirectoriesMixin, TestCase):
|
||||||
class PaperlessTestDirs:
|
|
||||||
data_dir: Path
|
|
||||||
media_dir: Path
|
|
||||||
consumption_dir: Path
|
|
||||||
|
|
||||||
|
|
||||||
# TODO: consolidate with documents/tests/conftest.py PaperlessDirs/paperless_dirs
|
|
||||||
# once the paperless and documents test suites are ready to share fixtures.
|
|
||||||
@pytest.fixture()
|
|
||||||
def directories(tmp_path: Path, settings: SettingsWrapper) -> PaperlessTestDirs:
|
|
||||||
data_dir = tmp_path / "data"
|
|
||||||
media_dir = tmp_path / "media"
|
|
||||||
consumption_dir = tmp_path / "consumption"
|
|
||||||
|
|
||||||
for d in (data_dir, media_dir, consumption_dir):
|
|
||||||
d.mkdir()
|
|
||||||
|
|
||||||
settings.DATA_DIR = data_dir
|
|
||||||
settings.MEDIA_ROOT = media_dir
|
|
||||||
settings.CONSUMPTION_DIR = consumption_dir
|
|
||||||
|
|
||||||
return PaperlessTestDirs(
|
|
||||||
data_dir=data_dir,
|
|
||||||
media_dir=media_dir,
|
|
||||||
consumption_dir=consumption_dir,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class TestChecks:
|
|
||||||
def test_binaries(self) -> None:
|
def test_binaries(self) -> None:
|
||||||
assert binaries_check(None) == []
|
self.assertEqual(binaries_check(None), [])
|
||||||
|
|
||||||
def test_binaries_fail(self, settings: SettingsWrapper) -> None:
|
@override_settings(CONVERT_BINARY="uuuhh")
|
||||||
settings.CONVERT_BINARY = "uuuhh"
|
def test_binaries_fail(self) -> None:
|
||||||
assert len(binaries_check(None)) == 1
|
self.assertEqual(len(binaries_check(None)), 1)
|
||||||
|
|
||||||
@pytest.mark.usefixtures("directories")
|
|
||||||
def test_paths_check(self) -> None:
|
def test_paths_check(self) -> None:
|
||||||
assert paths_check(None) == []
|
self.assertEqual(paths_check(None), [])
|
||||||
|
|
||||||
def test_paths_check_dont_exist(self, settings: SettingsWrapper) -> None:
|
@override_settings(
|
||||||
settings.MEDIA_ROOT = Path("uuh")
|
MEDIA_ROOT=Path("uuh"),
|
||||||
settings.DATA_DIR = Path("whatever")
|
DATA_DIR=Path("whatever"),
|
||||||
settings.CONSUMPTION_DIR = Path("idontcare")
|
CONSUMPTION_DIR=Path("idontcare"),
|
||||||
|
)
|
||||||
|
def test_paths_check_dont_exist(self) -> None:
|
||||||
|
msgs = paths_check(None)
|
||||||
|
self.assertEqual(len(msgs), 3, str(msgs))
|
||||||
|
|
||||||
|
for msg in msgs:
|
||||||
|
self.assertTrue(msg.msg.endswith("is set but doesn't exist."))
|
||||||
|
|
||||||
|
def test_paths_check_no_access(self) -> None:
|
||||||
|
Path(self.dirs.data_dir).chmod(0o000)
|
||||||
|
Path(self.dirs.media_dir).chmod(0o000)
|
||||||
|
Path(self.dirs.consumption_dir).chmod(0o000)
|
||||||
|
|
||||||
|
self.addCleanup(os.chmod, self.dirs.data_dir, 0o777)
|
||||||
|
self.addCleanup(os.chmod, self.dirs.media_dir, 0o777)
|
||||||
|
self.addCleanup(os.chmod, self.dirs.consumption_dir, 0o777)
|
||||||
|
|
||||||
msgs = paths_check(None)
|
msgs = paths_check(None)
|
||||||
|
self.assertEqual(len(msgs), 3)
|
||||||
|
|
||||||
assert len(msgs) == 3, str(msgs)
|
|
||||||
for msg in msgs:
|
for msg in msgs:
|
||||||
assert msg.msg.endswith("is set but doesn't exist.")
|
self.assertTrue(msg.msg.endswith("is not writeable"))
|
||||||
|
|
||||||
def test_paths_check_no_access(self, directories: PaperlessTestDirs) -> None:
|
@override_settings(DEBUG=False)
|
||||||
directories.data_dir.chmod(0o000)
|
def test_debug_disabled(self) -> None:
|
||||||
directories.media_dir.chmod(0o000)
|
self.assertEqual(debug_mode_check(None), [])
|
||||||
directories.consumption_dir.chmod(0o000)
|
|
||||||
|
|
||||||
try:
|
@override_settings(DEBUG=True)
|
||||||
msgs = paths_check(None)
|
def test_debug_enabled(self) -> None:
|
||||||
finally:
|
self.assertEqual(len(debug_mode_check(None)), 1)
|
||||||
directories.data_dir.chmod(0o777)
|
|
||||||
directories.media_dir.chmod(0o777)
|
|
||||||
directories.consumption_dir.chmod(0o777)
|
|
||||||
|
|
||||||
assert len(msgs) == 3
|
|
||||||
for msg in msgs:
|
|
||||||
assert msg.msg.endswith("is not writeable")
|
|
||||||
|
|
||||||
def test_debug_disabled(self, settings: SettingsWrapper) -> None:
|
|
||||||
settings.DEBUG = False
|
|
||||||
assert debug_mode_check(None) == []
|
|
||||||
|
|
||||||
def test_debug_enabled(self, settings: SettingsWrapper) -> None:
|
|
||||||
settings.DEBUG = True
|
|
||||||
assert len(debug_mode_check(None)) == 1
|
|
||||||
|
|
||||||
|
|
||||||
class TestSettingsChecksAgainstDefaults:
|
class TestSettingsChecksAgainstDefaults(DirectoriesMixin, TestCase):
|
||||||
def test_all_valid(self) -> None:
|
def test_all_valid(self) -> None:
|
||||||
"""
|
"""
|
||||||
GIVEN:
|
GIVEN:
|
||||||
@@ -107,71 +78,104 @@ class TestSettingsChecksAgainstDefaults:
|
|||||||
- No system check errors reported
|
- No system check errors reported
|
||||||
"""
|
"""
|
||||||
msgs = settings_values_check(None)
|
msgs = settings_values_check(None)
|
||||||
assert len(msgs) == 0
|
self.assertEqual(len(msgs), 0)
|
||||||
|
|
||||||
|
|
||||||
class TestOcrSettingsChecks:
|
class TestOcrSettingsChecks(DirectoriesMixin, TestCase):
|
||||||
@pytest.mark.parametrize(
|
@override_settings(OCR_OUTPUT_TYPE="notapdf")
|
||||||
("setting", "value", "expected_msg"),
|
def test_invalid_output_type(self) -> None:
|
||||||
[
|
|
||||||
pytest.param(
|
|
||||||
"OCR_OUTPUT_TYPE",
|
|
||||||
"notapdf",
|
|
||||||
'OCR output type "notapdf"',
|
|
||||||
id="invalid-output-type",
|
|
||||||
),
|
|
||||||
pytest.param(
|
|
||||||
"OCR_MODE",
|
|
||||||
"makeitso",
|
|
||||||
'OCR output mode "makeitso"',
|
|
||||||
id="invalid-mode",
|
|
||||||
),
|
|
||||||
pytest.param(
|
|
||||||
"OCR_MODE",
|
|
||||||
"skip_noarchive",
|
|
||||||
"deprecated",
|
|
||||||
id="deprecated-mode",
|
|
||||||
),
|
|
||||||
pytest.param(
|
|
||||||
"OCR_SKIP_ARCHIVE_FILE",
|
|
||||||
"invalid",
|
|
||||||
'OCR_SKIP_ARCHIVE_FILE setting "invalid"',
|
|
||||||
id="invalid-skip-archive-file",
|
|
||||||
),
|
|
||||||
pytest.param(
|
|
||||||
"OCR_CLEAN",
|
|
||||||
"cleanme",
|
|
||||||
'OCR clean mode "cleanme"',
|
|
||||||
id="invalid-clean",
|
|
||||||
),
|
|
||||||
],
|
|
||||||
)
|
|
||||||
def test_invalid_setting_produces_one_error(
|
|
||||||
self,
|
|
||||||
settings: SettingsWrapper,
|
|
||||||
setting: str,
|
|
||||||
value: str,
|
|
||||||
expected_msg: str,
|
|
||||||
) -> None:
|
|
||||||
"""
|
"""
|
||||||
GIVEN:
|
GIVEN:
|
||||||
- Default settings
|
- Default settings
|
||||||
- One OCR setting is set to an invalid value
|
- OCR output type is invalid
|
||||||
WHEN:
|
WHEN:
|
||||||
- Settings are validated
|
- Settings are validated
|
||||||
THEN:
|
THEN:
|
||||||
- Exactly one system check error is reported containing the expected message
|
- system check error reported for OCR output type
|
||||||
"""
|
"""
|
||||||
setattr(settings, setting, value)
|
|
||||||
|
|
||||||
msgs = settings_values_check(None)
|
msgs = settings_values_check(None)
|
||||||
|
self.assertEqual(len(msgs), 1)
|
||||||
|
|
||||||
assert len(msgs) == 1
|
msg = msgs[0]
|
||||||
assert expected_msg in msgs[0].msg
|
|
||||||
|
self.assertIn('OCR output type "notapdf"', msg.msg)
|
||||||
|
|
||||||
|
@override_settings(OCR_MODE="makeitso")
|
||||||
|
def test_invalid_ocr_type(self) -> None:
|
||||||
|
"""
|
||||||
|
GIVEN:
|
||||||
|
- Default settings
|
||||||
|
- OCR type is invalid
|
||||||
|
WHEN:
|
||||||
|
- Settings are validated
|
||||||
|
THEN:
|
||||||
|
- system check error reported for OCR type
|
||||||
|
"""
|
||||||
|
msgs = settings_values_check(None)
|
||||||
|
self.assertEqual(len(msgs), 1)
|
||||||
|
|
||||||
|
msg = msgs[0]
|
||||||
|
|
||||||
|
self.assertIn('OCR output mode "makeitso"', msg.msg)
|
||||||
|
|
||||||
|
@override_settings(OCR_MODE="skip_noarchive")
|
||||||
|
def test_deprecated_ocr_type(self) -> None:
|
||||||
|
"""
|
||||||
|
GIVEN:
|
||||||
|
- Default settings
|
||||||
|
- OCR type is deprecated
|
||||||
|
WHEN:
|
||||||
|
- Settings are validated
|
||||||
|
THEN:
|
||||||
|
- deprecation warning reported for OCR type
|
||||||
|
"""
|
||||||
|
msgs = settings_values_check(None)
|
||||||
|
self.assertEqual(len(msgs), 1)
|
||||||
|
|
||||||
|
msg = msgs[0]
|
||||||
|
|
||||||
|
self.assertIn("deprecated", msg.msg)
|
||||||
|
|
||||||
|
@override_settings(OCR_SKIP_ARCHIVE_FILE="invalid")
|
||||||
|
def test_invalid_ocr_skip_archive_file(self) -> None:
|
||||||
|
"""
|
||||||
|
GIVEN:
|
||||||
|
- Default settings
|
||||||
|
- OCR_SKIP_ARCHIVE_FILE is invalid
|
||||||
|
WHEN:
|
||||||
|
- Settings are validated
|
||||||
|
THEN:
|
||||||
|
- system check error reported for OCR_SKIP_ARCHIVE_FILE
|
||||||
|
"""
|
||||||
|
msgs = settings_values_check(None)
|
||||||
|
self.assertEqual(len(msgs), 1)
|
||||||
|
|
||||||
|
msg = msgs[0]
|
||||||
|
|
||||||
|
self.assertIn('OCR_SKIP_ARCHIVE_FILE setting "invalid"', msg.msg)
|
||||||
|
|
||||||
|
@override_settings(OCR_CLEAN="cleanme")
|
||||||
|
def test_invalid_ocr_clean(self) -> None:
|
||||||
|
"""
|
||||||
|
GIVEN:
|
||||||
|
- Default settings
|
||||||
|
- OCR cleaning type is invalid
|
||||||
|
WHEN:
|
||||||
|
- Settings are validated
|
||||||
|
THEN:
|
||||||
|
- system check error reported for OCR cleaning type
|
||||||
|
"""
|
||||||
|
msgs = settings_values_check(None)
|
||||||
|
self.assertEqual(len(msgs), 1)
|
||||||
|
|
||||||
|
msg = msgs[0]
|
||||||
|
|
||||||
|
self.assertIn('OCR clean mode "cleanme"', msg.msg)
|
||||||
|
|
||||||
|
|
||||||
class TestTimezoneSettingsChecks:
|
class TestTimezoneSettingsChecks(DirectoriesMixin, TestCase):
|
||||||
def test_invalid_timezone(self, settings: SettingsWrapper) -> None:
|
@override_settings(TIME_ZONE="TheMoon\\MyCrater")
|
||||||
|
def test_invalid_timezone(self) -> None:
|
||||||
"""
|
"""
|
||||||
GIVEN:
|
GIVEN:
|
||||||
- Default settings
|
- Default settings
|
||||||
@@ -181,16 +185,17 @@ class TestTimezoneSettingsChecks:
|
|||||||
THEN:
|
THEN:
|
||||||
- system check error reported for timezone
|
- system check error reported for timezone
|
||||||
"""
|
"""
|
||||||
settings.TIME_ZONE = "TheMoon\\MyCrater"
|
|
||||||
|
|
||||||
msgs = settings_values_check(None)
|
msgs = settings_values_check(None)
|
||||||
|
self.assertEqual(len(msgs), 1)
|
||||||
|
|
||||||
assert len(msgs) == 1
|
msg = msgs[0]
|
||||||
assert 'Timezone "TheMoon\\MyCrater"' in msgs[0].msg
|
|
||||||
|
self.assertIn('Timezone "TheMoon\\MyCrater"', msg.msg)
|
||||||
|
|
||||||
|
|
||||||
class TestEmailCertSettingsChecks:
|
class TestEmailCertSettingsChecks(DirectoriesMixin, FileSystemAssertsMixin, TestCase):
|
||||||
def test_not_valid_file(self, settings: SettingsWrapper) -> None:
|
@override_settings(EMAIL_CERTIFICATE_FILE=Path("/tmp/not_actually_here.pem"))
|
||||||
|
def test_not_valid_file(self) -> None:
|
||||||
"""
|
"""
|
||||||
GIVEN:
|
GIVEN:
|
||||||
- Default settings
|
- Default settings
|
||||||
@@ -200,22 +205,19 @@ class TestEmailCertSettingsChecks:
|
|||||||
THEN:
|
THEN:
|
||||||
- system check error reported for email certificate
|
- system check error reported for email certificate
|
||||||
"""
|
"""
|
||||||
cert_path = Path("/tmp/not_actually_here.pem")
|
self.assertIsNotFile("/tmp/not_actually_here.pem")
|
||||||
assert not cert_path.is_file()
|
|
||||||
settings.EMAIL_CERTIFICATE_FILE = cert_path
|
|
||||||
|
|
||||||
msgs = settings_values_check(None)
|
msgs = settings_values_check(None)
|
||||||
|
|
||||||
assert len(msgs) == 1
|
self.assertEqual(len(msgs), 1)
|
||||||
assert "Email cert /tmp/not_actually_here.pem is not a file" in msgs[0].msg
|
|
||||||
|
msg = msgs[0]
|
||||||
|
|
||||||
|
self.assertIn("Email cert /tmp/not_actually_here.pem is not a file", msg.msg)
|
||||||
|
|
||||||
|
|
||||||
class TestAuditLogChecks:
|
class TestAuditLogChecks(TestCase):
|
||||||
def test_was_enabled_once(
|
def test_was_enabled_once(self) -> None:
|
||||||
self,
|
|
||||||
settings: SettingsWrapper,
|
|
||||||
mocker: MockerFixture,
|
|
||||||
) -> None:
|
|
||||||
"""
|
"""
|
||||||
GIVEN:
|
GIVEN:
|
||||||
- Audit log is not enabled
|
- Audit log is not enabled
|
||||||
@@ -224,18 +226,23 @@ class TestAuditLogChecks:
|
|||||||
THEN:
|
THEN:
|
||||||
- system check error reported for disabling audit log
|
- system check error reported for disabling audit log
|
||||||
"""
|
"""
|
||||||
settings.AUDIT_LOG_ENABLED = False
|
introspect_mock = mock.MagicMock()
|
||||||
introspect_mock = mocker.MagicMock()
|
|
||||||
introspect_mock.introspection.table_names.return_value = ["auditlog_logentry"]
|
introspect_mock.introspection.table_names.return_value = ["auditlog_logentry"]
|
||||||
mocker.patch.dict(
|
with override_settings(AUDIT_LOG_ENABLED=False):
|
||||||
"paperless.checks.connections",
|
with mock.patch.dict(
|
||||||
{"default": introspect_mock},
|
"paperless.checks.connections",
|
||||||
)
|
{"default": introspect_mock},
|
||||||
|
):
|
||||||
|
msgs = audit_log_check(None)
|
||||||
|
|
||||||
msgs = audit_log_check(None)
|
self.assertEqual(len(msgs), 1)
|
||||||
|
|
||||||
assert len(msgs) == 1
|
msg = msgs[0]
|
||||||
assert "auditlog table was found but audit log is disabled." in msgs[0].msg
|
|
||||||
|
self.assertIn(
|
||||||
|
("auditlog table was found but audit log is disabled."),
|
||||||
|
msg.msg,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
DEPRECATED_VARS: dict[str, str] = {
|
DEPRECATED_VARS: dict[str, str] = {
|
||||||
@@ -264,16 +271,20 @@ class TestDeprecatedDbSettings:
|
|||||||
@pytest.mark.parametrize(
|
@pytest.mark.parametrize(
|
||||||
("env_var", "db_option_key"),
|
("env_var", "db_option_key"),
|
||||||
[
|
[
|
||||||
pytest.param("PAPERLESS_DB_TIMEOUT", "timeout", id="db-timeout"),
|
("PAPERLESS_DB_TIMEOUT", "timeout"),
|
||||||
pytest.param(
|
("PAPERLESS_DB_POOLSIZE", "pool.min_size / pool.max_size"),
|
||||||
"PAPERLESS_DB_POOLSIZE",
|
("PAPERLESS_DBSSLMODE", "sslmode"),
|
||||||
"pool.min_size / pool.max_size",
|
("PAPERLESS_DBSSLROOTCERT", "sslrootcert"),
|
||||||
id="db-poolsize",
|
("PAPERLESS_DBSSLCERT", "sslcert"),
|
||||||
),
|
("PAPERLESS_DBSSLKEY", "sslkey"),
|
||||||
pytest.param("PAPERLESS_DBSSLMODE", "sslmode", id="ssl-mode"),
|
],
|
||||||
pytest.param("PAPERLESS_DBSSLROOTCERT", "sslrootcert", id="ssl-rootcert"),
|
ids=[
|
||||||
pytest.param("PAPERLESS_DBSSLCERT", "sslcert", id="ssl-cert"),
|
"db-timeout",
|
||||||
pytest.param("PAPERLESS_DBSSLKEY", "sslkey", id="ssl-key"),
|
"db-poolsize",
|
||||||
|
"ssl-mode",
|
||||||
|
"ssl-rootcert",
|
||||||
|
"ssl-cert",
|
||||||
|
"ssl-key",
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
def test_single_deprecated_var_produces_one_warning(
|
def test_single_deprecated_var_produces_one_warning(
|
||||||
@@ -392,10 +403,7 @@ class TestV3MinimumUpgradeVersionCheck:
|
|||||||
"""Test suite for check_v3_minimum_upgrade_version system check."""
|
"""Test suite for check_v3_minimum_upgrade_version system check."""
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
def build_conn_mock(
|
def build_conn_mock(self, mocker: MockerFixture):
|
||||||
self,
|
|
||||||
mocker: MockerFixture,
|
|
||||||
) -> Callable[[list[str], list[str]], mock.MagicMock]:
|
|
||||||
"""Factory fixture that builds a connections['default'] mock.
|
"""Factory fixture that builds a connections['default'] mock.
|
||||||
|
|
||||||
Usage::
|
Usage::
|
||||||
@@ -415,7 +423,7 @@ class TestV3MinimumUpgradeVersionCheck:
|
|||||||
def test_no_migrations_table_fresh_install(
|
def test_no_migrations_table_fresh_install(
|
||||||
self,
|
self,
|
||||||
mocker: MockerFixture,
|
mocker: MockerFixture,
|
||||||
build_conn_mock: Callable[[list[str], list[str]], mock.MagicMock],
|
build_conn_mock,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""
|
"""
|
||||||
GIVEN:
|
GIVEN:
|
||||||
@@ -434,7 +442,7 @@ class TestV3MinimumUpgradeVersionCheck:
|
|||||||
def test_no_documents_migrations_fresh_install(
|
def test_no_documents_migrations_fresh_install(
|
||||||
self,
|
self,
|
||||||
mocker: MockerFixture,
|
mocker: MockerFixture,
|
||||||
build_conn_mock: Callable[[list[str], list[str]], mock.MagicMock],
|
build_conn_mock,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""
|
"""
|
||||||
GIVEN:
|
GIVEN:
|
||||||
@@ -453,7 +461,7 @@ class TestV3MinimumUpgradeVersionCheck:
|
|||||||
def test_v3_state_with_0001_squashed(
|
def test_v3_state_with_0001_squashed(
|
||||||
self,
|
self,
|
||||||
mocker: MockerFixture,
|
mocker: MockerFixture,
|
||||||
build_conn_mock: Callable[[list[str], list[str]], mock.MagicMock],
|
build_conn_mock,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""
|
"""
|
||||||
GIVEN:
|
GIVEN:
|
||||||
@@ -477,7 +485,7 @@ class TestV3MinimumUpgradeVersionCheck:
|
|||||||
def test_v3_state_with_0002_squashed_only(
|
def test_v3_state_with_0002_squashed_only(
|
||||||
self,
|
self,
|
||||||
mocker: MockerFixture,
|
mocker: MockerFixture,
|
||||||
build_conn_mock: Callable[[list[str], list[str]], mock.MagicMock],
|
build_conn_mock,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""
|
"""
|
||||||
GIVEN:
|
GIVEN:
|
||||||
@@ -496,7 +504,7 @@ class TestV3MinimumUpgradeVersionCheck:
|
|||||||
def test_v2_20_9_state_ready_to_upgrade(
|
def test_v2_20_9_state_ready_to_upgrade(
|
||||||
self,
|
self,
|
||||||
mocker: MockerFixture,
|
mocker: MockerFixture,
|
||||||
build_conn_mock: Callable[[list[str], list[str]], mock.MagicMock],
|
build_conn_mock,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""
|
"""
|
||||||
GIVEN:
|
GIVEN:
|
||||||
@@ -523,7 +531,7 @@ class TestV3MinimumUpgradeVersionCheck:
|
|||||||
def test_v2_20_8_raises_error(
|
def test_v2_20_8_raises_error(
|
||||||
self,
|
self,
|
||||||
mocker: MockerFixture,
|
mocker: MockerFixture,
|
||||||
build_conn_mock: Callable[[list[str], list[str]], mock.MagicMock],
|
build_conn_mock,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""
|
"""
|
||||||
GIVEN:
|
GIVEN:
|
||||||
@@ -550,7 +558,7 @@ class TestV3MinimumUpgradeVersionCheck:
|
|||||||
def test_very_old_version_raises_error(
|
def test_very_old_version_raises_error(
|
||||||
self,
|
self,
|
||||||
mocker: MockerFixture,
|
mocker: MockerFixture,
|
||||||
build_conn_mock: Callable[[list[str], list[str]], mock.MagicMock],
|
build_conn_mock,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""
|
"""
|
||||||
GIVEN:
|
GIVEN:
|
||||||
@@ -577,15 +585,15 @@ class TestV3MinimumUpgradeVersionCheck:
|
|||||||
def test_error_hint_mentions_v2_20_9(
|
def test_error_hint_mentions_v2_20_9(
|
||||||
self,
|
self,
|
||||||
mocker: MockerFixture,
|
mocker: MockerFixture,
|
||||||
build_conn_mock: Callable[[list[str], list[str]], mock.MagicMock],
|
build_conn_mock,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""
|
"""
|
||||||
GIVEN:
|
GIVEN:
|
||||||
- DB is on an old v2 version (pre-v2.20.10)
|
- DB is on an old v2 version (pre-v2.20.9)
|
||||||
WHEN:
|
WHEN:
|
||||||
- The v3 upgrade check runs
|
- The v3 upgrade check runs
|
||||||
THEN:
|
THEN:
|
||||||
- The error hint explicitly references v2.20.10 so users know what to do
|
- The error hint explicitly references v2.20.9 so users know what to do
|
||||||
"""
|
"""
|
||||||
mocker.patch.dict(
|
mocker.patch.dict(
|
||||||
"paperless.checks.connections",
|
"paperless.checks.connections",
|
||||||
@@ -593,7 +601,7 @@ class TestV3MinimumUpgradeVersionCheck:
|
|||||||
)
|
)
|
||||||
result = check_v3_minimum_upgrade_version(None)
|
result = check_v3_minimum_upgrade_version(None)
|
||||||
assert len(result) == 1
|
assert len(result) == 1
|
||||||
assert "v2.20.10" in result[0].hint
|
assert "v2.20.9" in result[0].hint
|
||||||
|
|
||||||
def test_db_error_is_swallowed(self, mocker: MockerFixture) -> None:
|
def test_db_error_is_swallowed(self, mocker: MockerFixture) -> None:
|
||||||
"""
|
"""
|
||||||
|
|||||||
482
src/paperless/tests/test_settings.py
Normal file
482
src/paperless/tests/test_settings.py
Normal file
@@ -0,0 +1,482 @@
|
|||||||
|
import datetime
|
||||||
|
import os
|
||||||
|
from unittest import TestCase
|
||||||
|
from unittest import mock
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from celery.schedules import crontab
|
||||||
|
|
||||||
|
from paperless.settings import _parse_base_paths
|
||||||
|
from paperless.settings import _parse_beat_schedule
|
||||||
|
from paperless.settings import _parse_dateparser_languages
|
||||||
|
from paperless.settings import _parse_ignore_dates
|
||||||
|
from paperless.settings import _parse_paperless_url
|
||||||
|
from paperless.settings import _parse_redis_url
|
||||||
|
from paperless.settings import default_threads_per_worker
|
||||||
|
|
||||||
|
|
||||||
|
class TestIgnoreDateParsing(TestCase):
|
||||||
|
"""
|
||||||
|
Tests the parsing of the PAPERLESS_IGNORE_DATES setting value
|
||||||
|
"""
|
||||||
|
|
||||||
|
def _parse_checker(self, test_cases) -> None:
|
||||||
|
"""
|
||||||
|
Helper function to check ignore date parsing
|
||||||
|
|
||||||
|
Args:
|
||||||
|
test_cases (_type_): _description_
|
||||||
|
"""
|
||||||
|
for env_str, date_format, expected_date_set in test_cases:
|
||||||
|
self.assertSetEqual(
|
||||||
|
_parse_ignore_dates(env_str, date_format),
|
||||||
|
expected_date_set,
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_no_ignore_dates_set(self) -> None:
|
||||||
|
"""
|
||||||
|
GIVEN:
|
||||||
|
- No ignore dates are set
|
||||||
|
THEN:
|
||||||
|
- No ignore dates are parsed
|
||||||
|
"""
|
||||||
|
self.assertSetEqual(_parse_ignore_dates(""), set())
|
||||||
|
|
||||||
|
def test_single_ignore_dates_set(self) -> None:
|
||||||
|
"""
|
||||||
|
GIVEN:
|
||||||
|
- Ignore dates are set per certain inputs
|
||||||
|
THEN:
|
||||||
|
- All ignore dates are parsed
|
||||||
|
"""
|
||||||
|
test_cases = [
|
||||||
|
("1985-05-01", "YMD", {datetime.date(1985, 5, 1)}),
|
||||||
|
(
|
||||||
|
"1985-05-01,1991-12-05",
|
||||||
|
"YMD",
|
||||||
|
{datetime.date(1985, 5, 1), datetime.date(1991, 12, 5)},
|
||||||
|
),
|
||||||
|
("2010-12-13", "YMD", {datetime.date(2010, 12, 13)}),
|
||||||
|
("11.01.10", "DMY", {datetime.date(2010, 1, 11)}),
|
||||||
|
(
|
||||||
|
"11.01.2001,15-06-1996",
|
||||||
|
"DMY",
|
||||||
|
{datetime.date(2001, 1, 11), datetime.date(1996, 6, 15)},
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
||||||
|
self._parse_checker(test_cases)
|
||||||
|
|
||||||
|
|
||||||
|
class TestThreadCalculation(TestCase):
|
||||||
|
def test_workers_threads(self) -> None:
|
||||||
|
"""
|
||||||
|
GIVEN:
|
||||||
|
- Certain CPU counts
|
||||||
|
WHEN:
|
||||||
|
- Threads per worker is calculated
|
||||||
|
THEN:
|
||||||
|
- Threads per worker less than or equal to CPU count
|
||||||
|
- At least 1 thread per worker
|
||||||
|
"""
|
||||||
|
default_workers = 1
|
||||||
|
|
||||||
|
for i in range(1, 64):
|
||||||
|
with mock.patch(
|
||||||
|
"paperless.settings.multiprocessing.cpu_count",
|
||||||
|
) as cpu_count:
|
||||||
|
cpu_count.return_value = i
|
||||||
|
|
||||||
|
default_threads = default_threads_per_worker(default_workers)
|
||||||
|
|
||||||
|
self.assertGreaterEqual(default_threads, 1)
|
||||||
|
|
||||||
|
self.assertLessEqual(default_workers * default_threads, i)
|
||||||
|
|
||||||
|
|
||||||
|
class TestRedisSocketConversion(TestCase):
|
||||||
|
def test_redis_socket_parsing(self) -> None:
|
||||||
|
"""
|
||||||
|
GIVEN:
|
||||||
|
- Various Redis connection URI formats
|
||||||
|
WHEN:
|
||||||
|
- The URI is parsed
|
||||||
|
THEN:
|
||||||
|
- Socket based URIs are translated
|
||||||
|
- Non-socket URIs are unchanged
|
||||||
|
- None provided uses default
|
||||||
|
"""
|
||||||
|
|
||||||
|
for input, expected in [
|
||||||
|
# Nothing is set
|
||||||
|
(None, ("redis://localhost:6379", "redis://localhost:6379")),
|
||||||
|
# celery style
|
||||||
|
(
|
||||||
|
"redis+socket:///run/redis/redis.sock",
|
||||||
|
(
|
||||||
|
"redis+socket:///run/redis/redis.sock",
|
||||||
|
"unix:///run/redis/redis.sock",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
# redis-py / channels-redis style
|
||||||
|
(
|
||||||
|
"unix:///run/redis/redis.sock",
|
||||||
|
(
|
||||||
|
"redis+socket:///run/redis/redis.sock",
|
||||||
|
"unix:///run/redis/redis.sock",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
# celery style with db
|
||||||
|
(
|
||||||
|
"redis+socket:///run/redis/redis.sock?virtual_host=5",
|
||||||
|
(
|
||||||
|
"redis+socket:///run/redis/redis.sock?virtual_host=5",
|
||||||
|
"unix:///run/redis/redis.sock?db=5",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
# redis-py / channels-redis style with db
|
||||||
|
(
|
||||||
|
"unix:///run/redis/redis.sock?db=10",
|
||||||
|
(
|
||||||
|
"redis+socket:///run/redis/redis.sock?virtual_host=10",
|
||||||
|
"unix:///run/redis/redis.sock?db=10",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
# Just a host with a port
|
||||||
|
(
|
||||||
|
"redis://myredishost:6379",
|
||||||
|
("redis://myredishost:6379", "redis://myredishost:6379"),
|
||||||
|
),
|
||||||
|
]:
|
||||||
|
result = _parse_redis_url(input)
|
||||||
|
self.assertTupleEqual(expected, result)
|
||||||
|
|
||||||
|
|
||||||
|
class TestCeleryScheduleParsing(TestCase):
|
||||||
|
MAIL_EXPIRE_TIME = 9.0 * 60.0
|
||||||
|
CLASSIFIER_EXPIRE_TIME = 59.0 * 60.0
|
||||||
|
INDEX_EXPIRE_TIME = 23.0 * 60.0 * 60.0
|
||||||
|
SANITY_EXPIRE_TIME = ((7.0 * 24.0) - 1.0) * 60.0 * 60.0
|
||||||
|
EMPTY_TRASH_EXPIRE_TIME = 23.0 * 60.0 * 60.0
|
||||||
|
RUN_SCHEDULED_WORKFLOWS_EXPIRE_TIME = 59.0 * 60.0
|
||||||
|
LLM_INDEX_EXPIRE_TIME = 23.0 * 60.0 * 60.0
|
||||||
|
CLEANUP_EXPIRED_SHARE_BUNDLES_EXPIRE_TIME = 23.0 * 60.0 * 60.0
|
||||||
|
|
||||||
|
def test_schedule_configuration_default(self) -> None:
|
||||||
|
"""
|
||||||
|
GIVEN:
|
||||||
|
- No configured task schedules
|
||||||
|
WHEN:
|
||||||
|
- The celery beat schedule is built
|
||||||
|
THEN:
|
||||||
|
- The default schedule is returned
|
||||||
|
"""
|
||||||
|
schedule = _parse_beat_schedule()
|
||||||
|
|
||||||
|
self.assertDictEqual(
|
||||||
|
{
|
||||||
|
"Check all e-mail accounts": {
|
||||||
|
"task": "paperless_mail.tasks.process_mail_accounts",
|
||||||
|
"schedule": crontab(minute="*/10"),
|
||||||
|
"options": {"expires": self.MAIL_EXPIRE_TIME},
|
||||||
|
},
|
||||||
|
"Train the classifier": {
|
||||||
|
"task": "documents.tasks.train_classifier",
|
||||||
|
"schedule": crontab(minute="5", hour="*/1"),
|
||||||
|
"options": {"expires": self.CLASSIFIER_EXPIRE_TIME},
|
||||||
|
},
|
||||||
|
"Optimize the index": {
|
||||||
|
"task": "documents.tasks.index_optimize",
|
||||||
|
"schedule": crontab(minute=0, hour=0),
|
||||||
|
"options": {"expires": self.INDEX_EXPIRE_TIME},
|
||||||
|
},
|
||||||
|
"Perform sanity check": {
|
||||||
|
"task": "documents.tasks.sanity_check",
|
||||||
|
"schedule": crontab(minute=30, hour=0, day_of_week="sun"),
|
||||||
|
"options": {"expires": self.SANITY_EXPIRE_TIME},
|
||||||
|
},
|
||||||
|
"Empty trash": {
|
||||||
|
"task": "documents.tasks.empty_trash",
|
||||||
|
"schedule": crontab(minute=0, hour="1"),
|
||||||
|
"options": {"expires": self.EMPTY_TRASH_EXPIRE_TIME},
|
||||||
|
},
|
||||||
|
"Check and run scheduled workflows": {
|
||||||
|
"task": "documents.tasks.check_scheduled_workflows",
|
||||||
|
"schedule": crontab(minute="5", hour="*/1"),
|
||||||
|
"options": {"expires": self.RUN_SCHEDULED_WORKFLOWS_EXPIRE_TIME},
|
||||||
|
},
|
||||||
|
"Rebuild LLM index": {
|
||||||
|
"task": "documents.tasks.llmindex_index",
|
||||||
|
"schedule": crontab(minute=10, hour=2),
|
||||||
|
"options": {
|
||||||
|
"expires": self.LLM_INDEX_EXPIRE_TIME,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"Cleanup expired share link bundles": {
|
||||||
|
"task": "documents.tasks.cleanup_expired_share_link_bundles",
|
||||||
|
"schedule": crontab(minute=0, hour=2),
|
||||||
|
"options": {
|
||||||
|
"expires": self.CLEANUP_EXPIRED_SHARE_BUNDLES_EXPIRE_TIME,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
schedule,
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_schedule_configuration_changed(self) -> None:
|
||||||
|
"""
|
||||||
|
GIVEN:
|
||||||
|
- Email task is configured non-default
|
||||||
|
WHEN:
|
||||||
|
- The celery beat schedule is built
|
||||||
|
THEN:
|
||||||
|
- The email task is configured per environment
|
||||||
|
- The default schedule is returned for other tasks
|
||||||
|
"""
|
||||||
|
with mock.patch.dict(
|
||||||
|
os.environ,
|
||||||
|
{"PAPERLESS_EMAIL_TASK_CRON": "*/50 * * * mon"},
|
||||||
|
):
|
||||||
|
schedule = _parse_beat_schedule()
|
||||||
|
|
||||||
|
self.assertDictEqual(
|
||||||
|
{
|
||||||
|
"Check all e-mail accounts": {
|
||||||
|
"task": "paperless_mail.tasks.process_mail_accounts",
|
||||||
|
"schedule": crontab(minute="*/50", day_of_week="mon"),
|
||||||
|
"options": {"expires": self.MAIL_EXPIRE_TIME},
|
||||||
|
},
|
||||||
|
"Train the classifier": {
|
||||||
|
"task": "documents.tasks.train_classifier",
|
||||||
|
"schedule": crontab(minute="5", hour="*/1"),
|
||||||
|
"options": {"expires": self.CLASSIFIER_EXPIRE_TIME},
|
||||||
|
},
|
||||||
|
"Optimize the index": {
|
||||||
|
"task": "documents.tasks.index_optimize",
|
||||||
|
"schedule": crontab(minute=0, hour=0),
|
||||||
|
"options": {"expires": self.INDEX_EXPIRE_TIME},
|
||||||
|
},
|
||||||
|
"Perform sanity check": {
|
||||||
|
"task": "documents.tasks.sanity_check",
|
||||||
|
"schedule": crontab(minute=30, hour=0, day_of_week="sun"),
|
||||||
|
"options": {"expires": self.SANITY_EXPIRE_TIME},
|
||||||
|
},
|
||||||
|
"Empty trash": {
|
||||||
|
"task": "documents.tasks.empty_trash",
|
||||||
|
"schedule": crontab(minute=0, hour="1"),
|
||||||
|
"options": {"expires": self.EMPTY_TRASH_EXPIRE_TIME},
|
||||||
|
},
|
||||||
|
"Check and run scheduled workflows": {
|
||||||
|
"task": "documents.tasks.check_scheduled_workflows",
|
||||||
|
"schedule": crontab(minute="5", hour="*/1"),
|
||||||
|
"options": {"expires": self.RUN_SCHEDULED_WORKFLOWS_EXPIRE_TIME},
|
||||||
|
},
|
||||||
|
"Rebuild LLM index": {
|
||||||
|
"task": "documents.tasks.llmindex_index",
|
||||||
|
"schedule": crontab(minute=10, hour=2),
|
||||||
|
"options": {
|
||||||
|
"expires": self.LLM_INDEX_EXPIRE_TIME,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"Cleanup expired share link bundles": {
|
||||||
|
"task": "documents.tasks.cleanup_expired_share_link_bundles",
|
||||||
|
"schedule": crontab(minute=0, hour=2),
|
||||||
|
"options": {
|
||||||
|
"expires": self.CLEANUP_EXPIRED_SHARE_BUNDLES_EXPIRE_TIME,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
schedule,
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_schedule_configuration_disabled(self) -> None:
|
||||||
|
"""
|
||||||
|
GIVEN:
|
||||||
|
- Search index task is disabled
|
||||||
|
WHEN:
|
||||||
|
- The celery beat schedule is built
|
||||||
|
THEN:
|
||||||
|
- The search index task is not present
|
||||||
|
- The default schedule is returned for other tasks
|
||||||
|
"""
|
||||||
|
with mock.patch.dict(os.environ, {"PAPERLESS_INDEX_TASK_CRON": "disable"}):
|
||||||
|
schedule = _parse_beat_schedule()
|
||||||
|
|
||||||
|
self.assertDictEqual(
|
||||||
|
{
|
||||||
|
"Check all e-mail accounts": {
|
||||||
|
"task": "paperless_mail.tasks.process_mail_accounts",
|
||||||
|
"schedule": crontab(minute="*/10"),
|
||||||
|
"options": {"expires": self.MAIL_EXPIRE_TIME},
|
||||||
|
},
|
||||||
|
"Train the classifier": {
|
||||||
|
"task": "documents.tasks.train_classifier",
|
||||||
|
"schedule": crontab(minute="5", hour="*/1"),
|
||||||
|
"options": {"expires": self.CLASSIFIER_EXPIRE_TIME},
|
||||||
|
},
|
||||||
|
"Perform sanity check": {
|
||||||
|
"task": "documents.tasks.sanity_check",
|
||||||
|
"schedule": crontab(minute=30, hour=0, day_of_week="sun"),
|
||||||
|
"options": {"expires": self.SANITY_EXPIRE_TIME},
|
||||||
|
},
|
||||||
|
"Empty trash": {
|
||||||
|
"task": "documents.tasks.empty_trash",
|
||||||
|
"schedule": crontab(minute=0, hour="1"),
|
||||||
|
"options": {"expires": self.EMPTY_TRASH_EXPIRE_TIME},
|
||||||
|
},
|
||||||
|
"Check and run scheduled workflows": {
|
||||||
|
"task": "documents.tasks.check_scheduled_workflows",
|
||||||
|
"schedule": crontab(minute="5", hour="*/1"),
|
||||||
|
"options": {"expires": self.RUN_SCHEDULED_WORKFLOWS_EXPIRE_TIME},
|
||||||
|
},
|
||||||
|
"Rebuild LLM index": {
|
||||||
|
"task": "documents.tasks.llmindex_index",
|
||||||
|
"schedule": crontab(minute=10, hour=2),
|
||||||
|
"options": {
|
||||||
|
"expires": self.LLM_INDEX_EXPIRE_TIME,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"Cleanup expired share link bundles": {
|
||||||
|
"task": "documents.tasks.cleanup_expired_share_link_bundles",
|
||||||
|
"schedule": crontab(minute=0, hour=2),
|
||||||
|
"options": {
|
||||||
|
"expires": self.CLEANUP_EXPIRED_SHARE_BUNDLES_EXPIRE_TIME,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
schedule,
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_schedule_configuration_disabled_all(self) -> None:
|
||||||
|
"""
|
||||||
|
GIVEN:
|
||||||
|
- All tasks are disabled
|
||||||
|
WHEN:
|
||||||
|
- The celery beat schedule is built
|
||||||
|
THEN:
|
||||||
|
- No tasks are scheduled
|
||||||
|
"""
|
||||||
|
with mock.patch.dict(
|
||||||
|
os.environ,
|
||||||
|
{
|
||||||
|
"PAPERLESS_EMAIL_TASK_CRON": "disable",
|
||||||
|
"PAPERLESS_TRAIN_TASK_CRON": "disable",
|
||||||
|
"PAPERLESS_SANITY_TASK_CRON": "disable",
|
||||||
|
"PAPERLESS_INDEX_TASK_CRON": "disable",
|
||||||
|
"PAPERLESS_EMPTY_TRASH_TASK_CRON": "disable",
|
||||||
|
"PAPERLESS_WORKFLOW_SCHEDULED_TASK_CRON": "disable",
|
||||||
|
"PAPERLESS_LLM_INDEX_TASK_CRON": "disable",
|
||||||
|
"PAPERLESS_SHARE_LINK_BUNDLE_CLEANUP_CRON": "disable",
|
||||||
|
},
|
||||||
|
):
|
||||||
|
schedule = _parse_beat_schedule()
|
||||||
|
|
||||||
|
self.assertDictEqual(
|
||||||
|
{},
|
||||||
|
schedule,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class TestPaperlessURLSettings(TestCase):
|
||||||
|
def test_paperless_url(self) -> None:
|
||||||
|
"""
|
||||||
|
GIVEN:
|
||||||
|
- PAPERLESS_URL is set
|
||||||
|
WHEN:
|
||||||
|
- The URL is parsed
|
||||||
|
THEN:
|
||||||
|
- The URL is returned and present in related settings
|
||||||
|
"""
|
||||||
|
with mock.patch.dict(
|
||||||
|
os.environ,
|
||||||
|
{
|
||||||
|
"PAPERLESS_URL": "https://example.com",
|
||||||
|
},
|
||||||
|
):
|
||||||
|
url = _parse_paperless_url()
|
||||||
|
self.assertEqual("https://example.com", url)
|
||||||
|
from django.conf import settings
|
||||||
|
|
||||||
|
self.assertIn(url, settings.CSRF_TRUSTED_ORIGINS)
|
||||||
|
self.assertIn(url, settings.CORS_ALLOWED_ORIGINS)
|
||||||
|
|
||||||
|
|
||||||
|
class TestPathSettings(TestCase):
|
||||||
|
def test_default_paths(self) -> None:
|
||||||
|
"""
|
||||||
|
GIVEN:
|
||||||
|
- PAPERLESS_FORCE_SCRIPT_NAME is not set
|
||||||
|
WHEN:
|
||||||
|
- Settings are parsed
|
||||||
|
THEN:
|
||||||
|
- Paths are as expected
|
||||||
|
"""
|
||||||
|
base_paths = _parse_base_paths()
|
||||||
|
self.assertEqual(None, base_paths[0]) # FORCE_SCRIPT_NAME
|
||||||
|
self.assertEqual("/", base_paths[1]) # BASE_URL
|
||||||
|
self.assertEqual("/accounts/login/", base_paths[2]) # LOGIN_URL
|
||||||
|
self.assertEqual("/dashboard", base_paths[3]) # LOGIN_REDIRECT_URL
|
||||||
|
self.assertEqual(
|
||||||
|
"/accounts/login/?loggedout=1",
|
||||||
|
base_paths[4],
|
||||||
|
) # LOGOUT_REDIRECT_URL
|
||||||
|
|
||||||
|
@mock.patch("os.environ", {"PAPERLESS_FORCE_SCRIPT_NAME": "/paperless"})
|
||||||
|
def test_subpath(self) -> None:
|
||||||
|
"""
|
||||||
|
GIVEN:
|
||||||
|
- PAPERLESS_FORCE_SCRIPT_NAME is set
|
||||||
|
WHEN:
|
||||||
|
- Settings are parsed
|
||||||
|
THEN:
|
||||||
|
- The path is returned and present in related settings
|
||||||
|
"""
|
||||||
|
base_paths = _parse_base_paths()
|
||||||
|
self.assertEqual("/paperless", base_paths[0]) # FORCE_SCRIPT_NAME
|
||||||
|
self.assertEqual("/paperless/", base_paths[1]) # BASE_URL
|
||||||
|
self.assertEqual("/paperless/accounts/login/", base_paths[2]) # LOGIN_URL
|
||||||
|
self.assertEqual("/paperless/dashboard", base_paths[3]) # LOGIN_REDIRECT_URL
|
||||||
|
self.assertEqual(
|
||||||
|
"/paperless/accounts/login/?loggedout=1",
|
||||||
|
base_paths[4],
|
||||||
|
) # LOGOUT_REDIRECT_URL
|
||||||
|
|
||||||
|
@mock.patch(
|
||||||
|
"os.environ",
|
||||||
|
{
|
||||||
|
"PAPERLESS_FORCE_SCRIPT_NAME": "/paperless",
|
||||||
|
"PAPERLESS_LOGOUT_REDIRECT_URL": "/foobar/",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
def test_subpath_with_explicit_logout_url(self) -> None:
|
||||||
|
"""
|
||||||
|
GIVEN:
|
||||||
|
- PAPERLESS_FORCE_SCRIPT_NAME is set and so is PAPERLESS_LOGOUT_REDIRECT_URL
|
||||||
|
WHEN:
|
||||||
|
- Settings are parsed
|
||||||
|
THEN:
|
||||||
|
- The correct logout redirect URL is returned
|
||||||
|
"""
|
||||||
|
base_paths = _parse_base_paths()
|
||||||
|
self.assertEqual("/paperless/", base_paths[1]) # BASE_URL
|
||||||
|
self.assertEqual("/foobar/", base_paths[4]) # LOGOUT_REDIRECT_URL
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
("languages", "expected"),
|
||||||
|
[
|
||||||
|
("de", ["de"]),
|
||||||
|
("zh", ["zh"]),
|
||||||
|
("fr+en", ["fr", "en"]),
|
||||||
|
# Locales must be supported
|
||||||
|
("en-001+fr-CA", ["en-001", "fr-CA"]),
|
||||||
|
("en-001+fr", ["en-001", "fr"]),
|
||||||
|
# Special case for Chinese: variants seem to miss some dates,
|
||||||
|
# so we always add "zh" as a fallback.
|
||||||
|
("en+zh-Hans-HK", ["en", "zh-Hans-HK", "zh"]),
|
||||||
|
("en+zh-Hans", ["en", "zh-Hans", "zh"]),
|
||||||
|
("en+zh-Hans+zh-Hant", ["en", "zh-Hans", "zh-Hant", "zh"]),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_parser_date_parser_languages(languages, expected) -> None:
|
||||||
|
assert sorted(_parse_dateparser_languages(languages)) == sorted(expected)
|
||||||
@@ -9,50 +9,35 @@ from paperless.utils import ocr_to_dateparser_languages
|
|||||||
@pytest.mark.parametrize(
|
@pytest.mark.parametrize(
|
||||||
("ocr_language", "expected"),
|
("ocr_language", "expected"),
|
||||||
[
|
[
|
||||||
pytest.param("eng", ["en"], id="single-language"),
|
# One language
|
||||||
pytest.param("fra+ita+lao", ["fr", "it", "lo"], id="multiple-languages"),
|
("eng", ["en"]),
|
||||||
pytest.param("fil", ["fil"], id="no-two-letter-equivalent"),
|
# Multiple languages
|
||||||
pytest.param(
|
("fra+ita+lao", ["fr", "it", "lo"]),
|
||||||
"aze_cyrl+srp_latn",
|
# Languages that don't have a two-letter equivalent
|
||||||
["az-Cyrl", "sr-Latn"],
|
("fil", ["fil"]),
|
||||||
id="script-supported-by-dateparser",
|
# Languages with a script part supported by dateparser
|
||||||
),
|
("aze_cyrl+srp_latn", ["az-Cyrl", "sr-Latn"]),
|
||||||
pytest.param(
|
# Languages with a script part not supported by dateparser
|
||||||
"deu_frak",
|
# In this case, default to the language without script
|
||||||
["de"],
|
("deu_frak", ["de"]),
|
||||||
id="script-not-supported-falls-back-to-language",
|
# Traditional and simplified chinese don't have the same name in dateparser,
|
||||||
),
|
# so they're converted to the general chinese language
|
||||||
pytest.param(
|
("chi_tra+chi_sim", ["zh"]),
|
||||||
"chi_tra+chi_sim",
|
# If a language is not supported by dateparser, fallback to the supported ones
|
||||||
["zh"],
|
("eng+unsupported_language+por", ["en", "pt"]),
|
||||||
id="chinese-variants-collapse-to-general",
|
# If no language is supported, fallback to default
|
||||||
),
|
("unsupported1+unsupported2", []),
|
||||||
pytest.param(
|
# Duplicate languages, should not duplicate in result
|
||||||
"eng+unsupported_language+por",
|
("eng+eng", ["en"]),
|
||||||
["en", "pt"],
|
# Language with script, but script is not mapped
|
||||||
id="unsupported-language-skipped",
|
("ita_unknownscript", ["it"]),
|
||||||
),
|
|
||||||
pytest.param(
|
|
||||||
"unsupported1+unsupported2",
|
|
||||||
[],
|
|
||||||
id="all-unsupported-returns-empty",
|
|
||||||
),
|
|
||||||
pytest.param("eng+eng", ["en"], id="duplicates-deduplicated"),
|
|
||||||
pytest.param(
|
|
||||||
"ita_unknownscript",
|
|
||||||
["it"],
|
|
||||||
id="unknown-script-falls-back-to-language",
|
|
||||||
),
|
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
def test_ocr_to_dateparser_languages(ocr_language: str, expected: list[str]) -> None:
|
def test_ocr_to_dateparser_languages(ocr_language, expected):
|
||||||
assert sorted(ocr_to_dateparser_languages(ocr_language)) == sorted(expected)
|
assert sorted(ocr_to_dateparser_languages(ocr_language)) == sorted(expected)
|
||||||
|
|
||||||
|
|
||||||
def test_ocr_to_dateparser_languages_exception(
|
def test_ocr_to_dateparser_languages_exception(monkeypatch, caplog):
|
||||||
monkeypatch: pytest.MonkeyPatch,
|
|
||||||
caplog: pytest.LogCaptureFixture,
|
|
||||||
) -> None:
|
|
||||||
# Patch LocaleDataLoader.get_locale_map to raise an exception
|
# Patch LocaleDataLoader.get_locale_map to raise an exception
|
||||||
class DummyLoader:
|
class DummyLoader:
|
||||||
def get_locale_map(self, locales=None):
|
def get_locale_map(self, locales=None):
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user