mirror of
https://github.com/paperless-ngx/paperless-ngx.git
synced 2026-04-02 22:28:51 +00:00
Compare commits
20 Commits
ci-sa
...
dependabot
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
74ed8f0b93 | ||
|
|
05c9e21fac | ||
|
|
aed9abe48c | ||
|
|
e01a762e81 | ||
|
|
14cc6a7ca4 | ||
|
|
32876f0334 | ||
|
|
e7884cb505 | ||
|
|
63f4e939d5 | ||
|
|
c813a1846d | ||
|
|
045afa7419 | ||
|
|
e827581f2a | ||
|
|
2aa0c9f0b4 | ||
|
|
d2328b776a | ||
|
|
2bb7c7ae17 | ||
|
|
e1da2a1efe | ||
|
|
245514ad10 | ||
|
|
020057e1a4 | ||
|
|
f715533770 | ||
|
|
0292edbee7 | ||
|
|
5b755528da |
1
.github/ISSUE_TEMPLATE/bug-report.yml
vendored
1
.github/ISSUE_TEMPLATE/bug-report.yml
vendored
@@ -21,6 +21,7 @@ body:
|
|||||||
- [The installation instructions](https://docs.paperless-ngx.com/setup/#installation).
|
- [The installation instructions](https://docs.paperless-ngx.com/setup/#installation).
|
||||||
- [Existing issues and discussions](https://github.com/paperless-ngx/paperless-ngx/search?q=&type=issues).
|
- [Existing issues and discussions](https://github.com/paperless-ngx/paperless-ngx/search?q=&type=issues).
|
||||||
- Disable any custom container initialization scripts, if using
|
- Disable any custom container initialization scripts, if using
|
||||||
|
- Remove any third-party parser plugins — issues caused by or requiring changes to a third-party plugin will be closed without investigation.
|
||||||
|
|
||||||
If you encounter issues while installing or configuring Paperless-ngx, please post in the ["Support" section of the discussions](https://github.com/paperless-ngx/paperless-ngx/discussions/new?category=support).
|
If you encounter issues while installing or configuring Paperless-ngx, please post in the ["Support" section of the discussions](https://github.com/paperless-ngx/paperless-ngx/discussions/new?category=support).
|
||||||
- type: textarea
|
- type: textarea
|
||||||
|
|||||||
9
.github/workflows/ci-backend.yml
vendored
9
.github/workflows/ci-backend.yml
vendored
@@ -24,7 +24,6 @@ jobs:
|
|||||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||||
with:
|
with:
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
persist-credentials: false
|
|
||||||
- name: Decide run mode
|
- name: Decide run mode
|
||||||
id: force
|
id: force
|
||||||
run: |
|
run: |
|
||||||
@@ -50,7 +49,7 @@ jobs:
|
|||||||
- name: Detect changes
|
- name: Detect changes
|
||||||
id: filter
|
id: filter
|
||||||
if: steps.force.outputs.run_all != 'true'
|
if: steps.force.outputs.run_all != 'true'
|
||||||
uses: dorny/paths-filter@de90cc6fb38fc0963ad72b210f1f284cd68cea36 # v3.0.2
|
uses: dorny/paths-filter@fbd0ab8f3e69293af611ebaee6363fc25e6d187d # v4.0.1
|
||||||
with:
|
with:
|
||||||
base: ${{ steps.range.outputs.base }}
|
base: ${{ steps.range.outputs.base }}
|
||||||
ref: ${{ steps.range.outputs.ref }}
|
ref: ${{ steps.range.outputs.ref }}
|
||||||
@@ -73,8 +72,6 @@ jobs:
|
|||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||||
with:
|
|
||||||
persist-credentials: false
|
|
||||||
- name: Start containers
|
- name: Start containers
|
||||||
run: |
|
run: |
|
||||||
docker compose --file docker/compose/docker-compose.ci-test.yml pull --quiet
|
docker compose --file docker/compose/docker-compose.ci-test.yml pull --quiet
|
||||||
@@ -148,8 +145,6 @@ jobs:
|
|||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||||
with:
|
|
||||||
persist-credentials: false
|
|
||||||
- name: Set up Python
|
- name: Set up Python
|
||||||
id: setup-python
|
id: setup-python
|
||||||
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
|
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
|
||||||
@@ -178,7 +173,7 @@ jobs:
|
|||||||
check \
|
check \
|
||||||
src/
|
src/
|
||||||
- name: Cache Mypy
|
- name: Cache Mypy
|
||||||
uses: actions/cache@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v5.0.3
|
uses: actions/cache@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4
|
||||||
with:
|
with:
|
||||||
path: .mypy_cache
|
path: .mypy_cache
|
||||||
# Keyed by OS, Python version, and dependency hashes
|
# Keyed by OS, Python version, and dependency hashes
|
||||||
|
|||||||
4
.github/workflows/ci-docker.yml
vendored
4
.github/workflows/ci-docker.yml
vendored
@@ -42,8 +42,6 @@ jobs:
|
|||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||||
with:
|
|
||||||
persist-credentials: false
|
|
||||||
- name: Determine ref name
|
- name: Determine ref name
|
||||||
id: ref
|
id: ref
|
||||||
run: |
|
run: |
|
||||||
@@ -171,7 +169,7 @@ jobs:
|
|||||||
packages: write
|
packages: write
|
||||||
steps:
|
steps:
|
||||||
- name: Download digests
|
- name: Download digests
|
||||||
uses: actions/download-artifact@70fc10c6e5e1ce46ad2ea6f2b72d43f7d47b13c3 # v8.0.0
|
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||||
with:
|
with:
|
||||||
path: /tmp/digests
|
path: /tmp/digests
|
||||||
pattern: digest-*.txt
|
pattern: digest-*.txt
|
||||||
|
|||||||
9
.github/workflows/ci-docs.yml
vendored
9
.github/workflows/ci-docs.yml
vendored
@@ -26,7 +26,6 @@ jobs:
|
|||||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||||
with:
|
with:
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
persist-credentials: false
|
|
||||||
- name: Decide run mode
|
- name: Decide run mode
|
||||||
id: force
|
id: force
|
||||||
run: |
|
run: |
|
||||||
@@ -52,7 +51,7 @@ jobs:
|
|||||||
- name: Detect changes
|
- name: Detect changes
|
||||||
id: filter
|
id: filter
|
||||||
if: steps.force.outputs.run_all != 'true'
|
if: steps.force.outputs.run_all != 'true'
|
||||||
uses: dorny/paths-filter@de90cc6fb38fc0963ad72b210f1f284cd68cea36 # v3.0.2
|
uses: dorny/paths-filter@fbd0ab8f3e69293af611ebaee6363fc25e6d187d # v4.0.1
|
||||||
with:
|
with:
|
||||||
base: ${{ steps.range.outputs.base }}
|
base: ${{ steps.range.outputs.base }}
|
||||||
ref: ${{ steps.range.outputs.ref }}
|
ref: ${{ steps.range.outputs.ref }}
|
||||||
@@ -69,11 +68,9 @@ jobs:
|
|||||||
name: Build Documentation
|
name: Build Documentation
|
||||||
runs-on: ubuntu-24.04
|
runs-on: ubuntu-24.04
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/configure-pages@983d7736d9b0ae728b81ab479565c72886d7745b # v5.0.0
|
- uses: actions/configure-pages@45bfe0192ca1faeb007ade9deae92b16b8254a0d # v6.0.0
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||||
with:
|
|
||||||
persist-credentials: false
|
|
||||||
- name: Set up Python
|
- name: Set up Python
|
||||||
id: setup-python
|
id: setup-python
|
||||||
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
|
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
|
||||||
@@ -110,7 +107,7 @@ jobs:
|
|||||||
url: ${{ steps.deployment.outputs.page_url }}
|
url: ${{ steps.deployment.outputs.page_url }}
|
||||||
steps:
|
steps:
|
||||||
- name: Deploy GitHub Pages
|
- name: Deploy GitHub Pages
|
||||||
uses: actions/deploy-pages@d6db90164ac5ed86f2b6aed7e0febac5b3c0c03e # v4.0.5
|
uses: actions/deploy-pages@cd2ce8fcbc39b97be8ca5fce6e763baed58fa128 # v5.0.0
|
||||||
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 }}
|
||||||
|
|||||||
31
.github/workflows/ci-frontend.yml
vendored
31
.github/workflows/ci-frontend.yml
vendored
@@ -46,7 +46,7 @@ jobs:
|
|||||||
- name: Detect changes
|
- name: Detect changes
|
||||||
id: filter
|
id: filter
|
||||||
if: steps.force.outputs.run_all != 'true'
|
if: steps.force.outputs.run_all != 'true'
|
||||||
uses: dorny/paths-filter@de90cc6fb38fc0963ad72b210f1f284cd68cea36 # v3.0.2
|
uses: dorny/paths-filter@fbd0ab8f3e69293af611ebaee6363fc25e6d187d # v4.0.1
|
||||||
with:
|
with:
|
||||||
base: ${{ steps.range.outputs.base }}
|
base: ${{ steps.range.outputs.base }}
|
||||||
ref: ${{ steps.range.outputs.ref }}
|
ref: ${{ steps.range.outputs.ref }}
|
||||||
@@ -62,10 +62,8 @@ jobs:
|
|||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||||
with:
|
|
||||||
persist-credentials: false
|
|
||||||
- name: Install pnpm
|
- name: Install pnpm
|
||||||
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
|
uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v5.0.0
|
||||||
with:
|
with:
|
||||||
version: 10
|
version: 10
|
||||||
- name: Use Node.js 24
|
- name: Use Node.js 24
|
||||||
@@ -76,7 +74,7 @@ jobs:
|
|||||||
cache-dependency-path: 'src-ui/pnpm-lock.yaml'
|
cache-dependency-path: 'src-ui/pnpm-lock.yaml'
|
||||||
- name: Cache frontend dependencies
|
- name: Cache frontend dependencies
|
||||||
id: cache-frontend-deps
|
id: cache-frontend-deps
|
||||||
uses: actions/cache@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v5.0.3
|
uses: actions/cache@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4
|
||||||
with:
|
with:
|
||||||
path: |
|
path: |
|
||||||
~/.pnpm-store
|
~/.pnpm-store
|
||||||
@@ -92,10 +90,8 @@ jobs:
|
|||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||||
with:
|
|
||||||
persist-credentials: false
|
|
||||||
- name: Install pnpm
|
- name: Install pnpm
|
||||||
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
|
uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v5.0.0
|
||||||
with:
|
with:
|
||||||
version: 10
|
version: 10
|
||||||
- name: Use Node.js 24
|
- name: Use Node.js 24
|
||||||
@@ -105,7 +101,7 @@ jobs:
|
|||||||
cache: 'pnpm'
|
cache: 'pnpm'
|
||||||
cache-dependency-path: 'src-ui/pnpm-lock.yaml'
|
cache-dependency-path: 'src-ui/pnpm-lock.yaml'
|
||||||
- name: Cache frontend dependencies
|
- name: Cache frontend dependencies
|
||||||
uses: actions/cache@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v5.0.3
|
uses: actions/cache@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4
|
||||||
with:
|
with:
|
||||||
path: |
|
path: |
|
||||||
~/.pnpm-store
|
~/.pnpm-store
|
||||||
@@ -129,10 +125,8 @@ jobs:
|
|||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||||
with:
|
|
||||||
persist-credentials: false
|
|
||||||
- name: Install pnpm
|
- name: Install pnpm
|
||||||
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
|
uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v5.0.0
|
||||||
with:
|
with:
|
||||||
version: 10
|
version: 10
|
||||||
- name: Use Node.js 24
|
- name: Use Node.js 24
|
||||||
@@ -142,7 +136,7 @@ jobs:
|
|||||||
cache: 'pnpm'
|
cache: 'pnpm'
|
||||||
cache-dependency-path: 'src-ui/pnpm-lock.yaml'
|
cache-dependency-path: 'src-ui/pnpm-lock.yaml'
|
||||||
- name: Cache frontend dependencies
|
- name: Cache frontend dependencies
|
||||||
uses: actions/cache@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v5.0.3
|
uses: actions/cache@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4
|
||||||
with:
|
with:
|
||||||
path: |
|
path: |
|
||||||
~/.pnpm-store
|
~/.pnpm-store
|
||||||
@@ -182,10 +176,8 @@ jobs:
|
|||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||||
with:
|
|
||||||
persist-credentials: false
|
|
||||||
- name: Install pnpm
|
- name: Install pnpm
|
||||||
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
|
uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v5.0.0
|
||||||
with:
|
with:
|
||||||
version: 10
|
version: 10
|
||||||
- name: Use Node.js 24
|
- name: Use Node.js 24
|
||||||
@@ -195,7 +187,7 @@ jobs:
|
|||||||
cache: 'pnpm'
|
cache: 'pnpm'
|
||||||
cache-dependency-path: 'src-ui/pnpm-lock.yaml'
|
cache-dependency-path: 'src-ui/pnpm-lock.yaml'
|
||||||
- name: Cache frontend dependencies
|
- name: Cache frontend dependencies
|
||||||
uses: actions/cache@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v5.0.3
|
uses: actions/cache@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4
|
||||||
with:
|
with:
|
||||||
path: |
|
path: |
|
||||||
~/.pnpm-store
|
~/.pnpm-store
|
||||||
@@ -217,9 +209,8 @@ jobs:
|
|||||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||||
with:
|
with:
|
||||||
fetch-depth: 2
|
fetch-depth: 2
|
||||||
persist-credentials: false
|
|
||||||
- name: Install pnpm
|
- name: Install pnpm
|
||||||
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
|
uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v5.0.0
|
||||||
with:
|
with:
|
||||||
version: 10
|
version: 10
|
||||||
- name: Use Node.js 24
|
- name: Use Node.js 24
|
||||||
@@ -229,7 +220,7 @@ jobs:
|
|||||||
cache: 'pnpm'
|
cache: 'pnpm'
|
||||||
cache-dependency-path: 'src-ui/pnpm-lock.yaml'
|
cache-dependency-path: 'src-ui/pnpm-lock.yaml'
|
||||||
- name: Cache frontend dependencies
|
- name: Cache frontend dependencies
|
||||||
uses: actions/cache@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v5.0.3
|
uses: actions/cache@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4
|
||||||
with:
|
with:
|
||||||
path: |
|
path: |
|
||||||
~/.pnpm-store
|
~/.pnpm-store
|
||||||
|
|||||||
4
.github/workflows/ci-lint.yml
vendored
4
.github/workflows/ci-lint.yml
vendored
@@ -16,11 +16,9 @@ jobs:
|
|||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||||
with:
|
|
||||||
persist-credentials: false
|
|
||||||
- name: Install Python
|
- name: Install Python
|
||||||
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
|
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
|
||||||
with:
|
with:
|
||||||
python-version: "3.14"
|
python-version: "3.14"
|
||||||
- name: Run prek
|
- name: Run prek
|
||||||
uses: j178/prek-action@0bb87d7f00b0c99306c8bcb8b8beba1eb581c037 # v1.1.1
|
uses: j178/prek-action@53276d8b0d10f8b6672aa85b4588c6921d0370cc # v2.0.1
|
||||||
|
|||||||
11
.github/workflows/ci-release.yml
vendored
11
.github/workflows/ci-release.yml
vendored
@@ -29,11 +29,9 @@ jobs:
|
|||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||||
with:
|
|
||||||
persist-credentials: false
|
|
||||||
# ---- Frontend Build ----
|
# ---- Frontend Build ----
|
||||||
- name: Install pnpm
|
- name: Install pnpm
|
||||||
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
|
uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v5.0.0
|
||||||
with:
|
with:
|
||||||
version: 10
|
version: 10
|
||||||
- name: Use Node.js 24
|
- name: Use Node.js 24
|
||||||
@@ -135,7 +133,7 @@ jobs:
|
|||||||
version: ${{ steps.get-version.outputs.version }}
|
version: ${{ steps.get-version.outputs.version }}
|
||||||
steps:
|
steps:
|
||||||
- name: Download release artifact
|
- name: Download release artifact
|
||||||
uses: actions/download-artifact@70fc10c6e5e1ce46ad2ea6f2b72d43f7d47b13c3 # v8.0.0
|
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||||
with:
|
with:
|
||||||
name: release
|
name: release
|
||||||
path: ./
|
path: ./
|
||||||
@@ -150,7 +148,7 @@ jobs:
|
|||||||
fi
|
fi
|
||||||
- name: Create release and changelog
|
- name: Create release and changelog
|
||||||
id: create-release
|
id: create-release
|
||||||
uses: release-drafter/release-drafter@6db134d15f3909ccc9eefd369f02bd1e9cffdf97 # v6.2.0
|
uses: release-drafter/release-drafter@139054aeaa9adc52ab36ddf67437541f039b88e2 # v7.1.1
|
||||||
with:
|
with:
|
||||||
name: Paperless-ngx ${{ steps.get-version.outputs.version }}
|
name: Paperless-ngx ${{ steps.get-version.outputs.version }}
|
||||||
tag: ${{ steps.get-version.outputs.version }}
|
tag: ${{ steps.get-version.outputs.version }}
|
||||||
@@ -161,7 +159,7 @@ jobs:
|
|||||||
env:
|
env:
|
||||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
- name: Upload release archive
|
- name: Upload release archive
|
||||||
uses: shogo82148/actions-upload-release-asset@8f6863c6c894ba46f9e676ef5cccec4752723c1e # v1.9.2
|
uses: shogo82148/actions-upload-release-asset@96bc1f0cb850b65efd58a6b5eaa0a69f88d38077 # v1.10.0
|
||||||
with:
|
with:
|
||||||
github_token: ${{ secrets.GITHUB_TOKEN }}
|
github_token: ${{ secrets.GITHUB_TOKEN }}
|
||||||
upload_url: ${{ steps.create-release.outputs.upload_url }}
|
upload_url: ${{ steps.create-release.outputs.upload_url }}
|
||||||
@@ -181,7 +179,6 @@ jobs:
|
|||||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||||
with:
|
with:
|
||||||
ref: main
|
ref: main
|
||||||
persist-credentials: true # for pushing changelog branch
|
|
||||||
- name: Set up Python
|
- name: Set up Python
|
||||||
id: setup-python
|
id: setup-python
|
||||||
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
|
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
|
||||||
|
|||||||
42
.github/workflows/ci-static-analysis.yml
vendored
42
.github/workflows/ci-static-analysis.yml
vendored
@@ -1,42 +0,0 @@
|
|||||||
name: Static Analysis
|
|
||||||
on:
|
|
||||||
push:
|
|
||||||
branches-ignore:
|
|
||||||
- 'translations**'
|
|
||||||
pull_request:
|
|
||||||
branches-ignore:
|
|
||||||
- 'translations**'
|
|
||||||
workflow_dispatch:
|
|
||||||
concurrency:
|
|
||||||
group: static-analysis-${{ github.event.pull_request.number || github.ref }}
|
|
||||||
cancel-in-progress: true
|
|
||||||
permissions:
|
|
||||||
contents: read
|
|
||||||
jobs:
|
|
||||||
zizmor:
|
|
||||||
name: Run zizmor
|
|
||||||
runs-on: ubuntu-24.04
|
|
||||||
permissions:
|
|
||||||
contents: read
|
|
||||||
actions: read
|
|
||||||
security-events: write
|
|
||||||
steps:
|
|
||||||
- name: Checkout
|
|
||||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
|
||||||
with:
|
|
||||||
persist-credentials: false
|
|
||||||
- name: Run zizmor
|
|
||||||
uses: zizmorcore/zizmor-action@71321a20a9ded102f6e9ce5718a2fcec2c4f70d8 # v0.5.2
|
|
||||||
semgrep:
|
|
||||||
name: Semgrep CE
|
|
||||||
runs-on: ubuntu-24.04
|
|
||||||
container:
|
|
||||||
image: semgrep/semgrep:1.155.0@sha256:cc869c685dcc0fe497c86258da9f205397d8108e56d21a86082ea4886e52784d
|
|
||||||
if: github.actor != 'dependabot[bot]'
|
|
||||||
steps:
|
|
||||||
- name: Checkout
|
|
||||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
|
||||||
with:
|
|
||||||
persist-credentials: false
|
|
||||||
- name: Run Semgrep
|
|
||||||
run: semgrep scan --config auto
|
|
||||||
2
.github/workflows/codeql-analysis.yml
vendored
2
.github/workflows/codeql-analysis.yml
vendored
@@ -35,8 +35,6 @@ jobs:
|
|||||||
steps:
|
steps:
|
||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||||
with:
|
|
||||||
persist-credentials: false
|
|
||||||
# Initializes the CodeQL tools for scanning.
|
# Initializes the CodeQL tools for scanning.
|
||||||
- name: Initialize CodeQL
|
- name: Initialize CodeQL
|
||||||
uses: github/codeql-action/init@c793b717bc78562f491db7b0e93a3a178b099162 # v4.32.5
|
uses: github/codeql-action/init@c793b717bc78562f491db7b0e93a3a178b099162 # v4.32.5
|
||||||
|
|||||||
1
.github/workflows/crowdin.yml
vendored
1
.github/workflows/crowdin.yml
vendored
@@ -16,7 +16,6 @@ jobs:
|
|||||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||||
with:
|
with:
|
||||||
token: ${{ secrets.PNGX_BOT_PAT }}
|
token: ${{ secrets.PNGX_BOT_PAT }}
|
||||||
persist-credentials: false
|
|
||||||
- name: crowdin action
|
- name: crowdin action
|
||||||
uses: crowdin/github-action@8818ff65bfc4322384f983ea37e3926948c11745 # v2.15.0
|
uses: crowdin/github-action@8818ff65bfc4322384f983ea37e3926948c11745 # v2.15.0
|
||||||
with:
|
with:
|
||||||
|
|||||||
2
.github/workflows/project-actions.yml
vendored
2
.github/workflows/project-actions.yml
vendored
@@ -19,6 +19,6 @@ jobs:
|
|||||||
if: github.event_name == 'pull_request_target' && (github.event.action == 'opened' || github.event.action == 'reopened') && github.event.pull_request.user.login != 'dependabot'
|
if: github.event_name == 'pull_request_target' && (github.event.action == 'opened' || github.event.action == 'reopened') && github.event.pull_request.user.login != 'dependabot'
|
||||||
steps:
|
steps:
|
||||||
- name: Label PR with release-drafter
|
- name: Label PR with release-drafter
|
||||||
uses: release-drafter/release-drafter@6db134d15f3909ccc9eefd369f02bd1e9cffdf97 # v6.2.0
|
uses: release-drafter/release-drafter@139054aeaa9adc52ab36ddf67437541f039b88e2 # v7.1.1
|
||||||
env:
|
env:
|
||||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
|||||||
5
.github/workflows/translate-strings.yml
vendored
5
.github/workflows/translate-strings.yml
vendored
@@ -17,7 +17,6 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
token: ${{ secrets.PNGX_BOT_PAT }}
|
token: ${{ secrets.PNGX_BOT_PAT }}
|
||||||
ref: ${{ env.GH_REF }}
|
ref: ${{ env.GH_REF }}
|
||||||
persist-credentials: true # for pushing translation branch
|
|
||||||
- name: Set up Python
|
- name: Set up Python
|
||||||
id: setup-python
|
id: setup-python
|
||||||
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
|
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
|
||||||
@@ -37,7 +36,7 @@ jobs:
|
|||||||
- name: Generate backend translation strings
|
- name: Generate backend translation strings
|
||||||
run: cd src/ && uv run manage.py makemessages -l en_US -i "samples*"
|
run: cd src/ && uv run manage.py makemessages -l en_US -i "samples*"
|
||||||
- name: Install pnpm
|
- name: Install pnpm
|
||||||
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
|
uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v5.0.0
|
||||||
with:
|
with:
|
||||||
version: 10
|
version: 10
|
||||||
- name: Use Node.js 24
|
- name: Use Node.js 24
|
||||||
@@ -48,7 +47,7 @@ jobs:
|
|||||||
cache-dependency-path: 'src-ui/pnpm-lock.yaml'
|
cache-dependency-path: 'src-ui/pnpm-lock.yaml'
|
||||||
- name: Cache frontend dependencies
|
- name: Cache frontend dependencies
|
||||||
id: cache-frontend-deps
|
id: cache-frontend-deps
|
||||||
uses: actions/cache@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v5.0.3
|
uses: actions/cache@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4
|
||||||
with:
|
with:
|
||||||
path: |
|
path: |
|
||||||
~/.pnpm-store
|
~/.pnpm-store
|
||||||
|
|||||||
@@ -50,12 +50,12 @@ repos:
|
|||||||
- 'prettier-plugin-organize-imports@4.3.0'
|
- 'prettier-plugin-organize-imports@4.3.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.6
|
rev: v0.15.8
|
||||||
hooks:
|
hooks:
|
||||||
- id: ruff-check
|
- id: ruff-check
|
||||||
- id: ruff-format
|
- id: ruff-format
|
||||||
- repo: https://github.com/tox-dev/pyproject-fmt
|
- repo: https://github.com/tox-dev/pyproject-fmt
|
||||||
rev: "v2.12.1"
|
rev: "v2.21.0"
|
||||||
hooks:
|
hooks:
|
||||||
- id: pyproject-fmt
|
- id: pyproject-fmt
|
||||||
# Dockerfile hooks
|
# Dockerfile hooks
|
||||||
|
|||||||
@@ -3,26 +3,10 @@
|
|||||||
|
|
||||||
declare -r log_prefix="[init-index]"
|
declare -r log_prefix="[init-index]"
|
||||||
|
|
||||||
declare -r index_version=9
|
echo "${log_prefix} Checking search index..."
|
||||||
declare -r data_dir="${PAPERLESS_DATA_DIR:-/usr/src/paperless/data}"
|
cd "${PAPERLESS_SRC_DIR}"
|
||||||
declare -r index_version_file="${data_dir}/.index_version"
|
if [[ -n "${USER_IS_NON_ROOT}" ]]; then
|
||||||
|
python3 manage.py document_index reindex --if-needed --no-progress-bar
|
||||||
update_index () {
|
else
|
||||||
echo "${log_prefix} Search index out of date. Updating..."
|
s6-setuidgid paperless python3 manage.py document_index reindex --if-needed --no-progress-bar
|
||||||
cd "${PAPERLESS_SRC_DIR}"
|
|
||||||
if [[ -n "${USER_IS_NON_ROOT}" ]]; then
|
|
||||||
python3 manage.py document_index reindex --no-progress-bar
|
|
||||||
echo ${index_version} | tee "${index_version_file}" > /dev/null
|
|
||||||
else
|
|
||||||
s6-setuidgid paperless python3 manage.py document_index reindex --no-progress-bar
|
|
||||||
echo ${index_version} | s6-setuidgid paperless tee "${index_version_file}" > /dev/null
|
|
||||||
fi
|
|
||||||
}
|
|
||||||
|
|
||||||
if [[ (! -f "${index_version_file}") ]]; then
|
|
||||||
echo "${log_prefix} No index version file found"
|
|
||||||
update_index
|
|
||||||
elif [[ $(<"${index_version_file}") != "$index_version" ]]; then
|
|
||||||
echo "${log_prefix} index version updated"
|
|
||||||
update_index
|
|
||||||
fi
|
fi
|
||||||
|
|||||||
@@ -180,6 +180,16 @@ following:
|
|||||||
This might not actually do anything. Not every new paperless version
|
This might not actually do anything. Not every new paperless version
|
||||||
comes with new database migrations.
|
comes with new database migrations.
|
||||||
|
|
||||||
|
4. Rebuild the search index if needed.
|
||||||
|
|
||||||
|
```shell-session
|
||||||
|
cd src
|
||||||
|
python3 manage.py document_index reindex --if-needed
|
||||||
|
```
|
||||||
|
|
||||||
|
This is a no-op if the index is already up to date, so it is safe to
|
||||||
|
run on every upgrade.
|
||||||
|
|
||||||
### Database Upgrades
|
### Database Upgrades
|
||||||
|
|
||||||
Paperless-ngx is compatible with Django-supported versions of PostgreSQL and MariaDB and it is generally
|
Paperless-ngx is compatible with Django-supported versions of PostgreSQL and MariaDB and it is generally
|
||||||
@@ -453,17 +463,42 @@ the search yields non-existing documents or won't find anything, you
|
|||||||
may need to recreate the index manually.
|
may need to recreate the index manually.
|
||||||
|
|
||||||
```
|
```
|
||||||
document_index {reindex,optimize}
|
document_index {reindex,optimize} [--recreate] [--if-needed]
|
||||||
```
|
```
|
||||||
|
|
||||||
Specify `reindex` to have the index created from scratch. This may take
|
Specify `reindex` to rebuild the index from all documents in the database. This
|
||||||
some time.
|
may take some time.
|
||||||
|
|
||||||
Specify `optimize` to optimize the index. This updates certain aspects
|
Pass `--recreate` to wipe the existing index before rebuilding. Use this when the
|
||||||
of the index and usually makes queries faster and also ensures that the
|
index is corrupted or you want a fully clean rebuild.
|
||||||
autocompletion works properly. This command is regularly invoked by the
|
|
||||||
|
Pass `--if-needed` to skip the rebuild if the index is already up to date (schema
|
||||||
|
version and search language match). Safe to run on every startup or upgrade.
|
||||||
|
|
||||||
|
Specify `optimize` to optimize the index. This command is regularly invoked by the
|
||||||
task scheduler.
|
task scheduler.
|
||||||
|
|
||||||
|
!!! note
|
||||||
|
|
||||||
|
The `optimize` subcommand is deprecated and is now a no-op. Tantivy manages
|
||||||
|
segment merging automatically; no manual optimization step is needed.
|
||||||
|
|
||||||
|
!!! note
|
||||||
|
|
||||||
|
**Docker users:** On every startup, the container runs
|
||||||
|
`document_index reindex --if-needed` automatically. Schema changes, language
|
||||||
|
changes, and missing indexes are all detected and rebuilt before the webserver
|
||||||
|
starts. No manual step is required.
|
||||||
|
|
||||||
|
**Bare metal users:** Run the following command after each upgrade (and after
|
||||||
|
changing `PAPERLESS_SEARCH_LANGUAGE`). It is a no-op if the index is already
|
||||||
|
up to date:
|
||||||
|
|
||||||
|
```shell-session
|
||||||
|
cd src
|
||||||
|
python3 manage.py document_index reindex --if-needed
|
||||||
|
```
|
||||||
|
|
||||||
### Clearing the database read cache
|
### Clearing the database read cache
|
||||||
|
|
||||||
If the database read cache is enabled, **you must run this command** after making any changes to the database outside the application context.
|
If the database read cache is enabled, **you must run this command** after making any changes to the database outside the application context.
|
||||||
|
|||||||
@@ -723,6 +723,81 @@ services:
|
|||||||
|
|
||||||
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
|
||||||
|
|
||||||
|
## Installing third-party parser plugins {#parser-plugins}
|
||||||
|
|
||||||
|
Third-party parser plugins extend Paperless-ngx to support additional file
|
||||||
|
formats. A plugin is a Python package that advertises itself under the
|
||||||
|
`paperless_ngx.parsers` entry point group. Refer to the
|
||||||
|
[developer documentation](development.md#making-custom-parsers) for how to
|
||||||
|
create one.
|
||||||
|
|
||||||
|
!!! warning "Third-party plugins are not officially supported"
|
||||||
|
|
||||||
|
The Paperless-ngx maintainers do not provide support for third-party
|
||||||
|
plugins. Issues caused by or requiring changes to a third-party plugin
|
||||||
|
will be closed without further investigation. Always reproduce problems
|
||||||
|
with all plugins removed before filing a bug report.
|
||||||
|
|
||||||
|
### Docker
|
||||||
|
|
||||||
|
Use a [custom container initialization script](#custom-container-initialization)
|
||||||
|
to install the package before the webserver starts. Create a shell script and
|
||||||
|
mount it into `/custom-cont-init.d`:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
#!/bin/bash
|
||||||
|
# /path/to/my/scripts/install-parsers.sh
|
||||||
|
|
||||||
|
pip install my-paperless-parser-package
|
||||||
|
```
|
||||||
|
|
||||||
|
Mount it in your `docker-compose.yml`:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
services:
|
||||||
|
webserver:
|
||||||
|
# ...
|
||||||
|
volumes:
|
||||||
|
- /path/to/my/scripts:/custom-cont-init.d:ro
|
||||||
|
```
|
||||||
|
|
||||||
|
The script runs as `root` before the webserver starts, so the package will be
|
||||||
|
available when Paperless-ngx discovers plugins at startup.
|
||||||
|
|
||||||
|
### Bare metal
|
||||||
|
|
||||||
|
Install the package into the same Python environment that runs Paperless-ngx.
|
||||||
|
If you followed the standard bare-metal install guide, that is the `paperless`
|
||||||
|
user's environment:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo -Hu paperless pip3 install my-paperless-parser-package
|
||||||
|
```
|
||||||
|
|
||||||
|
If you are using `uv` or a virtual environment, activate it first and then run:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
uv pip install my-paperless-parser-package
|
||||||
|
# or
|
||||||
|
pip install my-paperless-parser-package
|
||||||
|
```
|
||||||
|
|
||||||
|
Restart all Paperless-ngx services after installation so the new plugin is
|
||||||
|
discovered.
|
||||||
|
|
||||||
|
### Verifying installation
|
||||||
|
|
||||||
|
On the next startup, check the application logs for a line confirming
|
||||||
|
discovery:
|
||||||
|
|
||||||
|
```
|
||||||
|
Loaded third-party parser 'My Parser' v1.0.0 by Acme Corp (entrypoint: 'my_parser').
|
||||||
|
```
|
||||||
|
|
||||||
|
If this line does not appear, verify that the package is installed in the
|
||||||
|
correct environment and that its `pyproject.toml` declares the
|
||||||
|
`paperless_ngx.parsers` entry point.
|
||||||
|
|
||||||
## MySQL Caveats {#mysql-caveats}
|
## MySQL Caveats {#mysql-caveats}
|
||||||
|
|
||||||
### Case Sensitivity
|
### Case Sensitivity
|
||||||
|
|||||||
@@ -167,9 +167,8 @@ 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 are ordered by how many of the user's visible documents contain
|
||||||
in the document index. The first result is the term that has the highest
|
each matching word. The first result is the word that appears in the most documents.
|
||||||
[Tf/Idf](https://en.wikipedia.org/wiki/Tf%E2%80%93idf) score in the index.
|
|
||||||
|
|
||||||
```json
|
```json
|
||||||
["term1", "term3", "term6", "term4"]
|
["term1", "term3", "term6", "term4"]
|
||||||
@@ -437,3 +436,6 @@ Initial API version.
|
|||||||
moved from the bulk edit endpoint to their own individual endpoints. Using these methods via
|
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
|
the bulk edit endpoint is still supported for compatibility with versions < 10 until support
|
||||||
for API v9 is dropped.
|
for API v9 is dropped.
|
||||||
|
- The `all` parameter of list endpoints is now deprecated and will be removed in a future version.
|
||||||
|
- The bulk edit objects endpoint now supports `all` and `filters` parameters to avoid having to send
|
||||||
|
large lists of object IDs for operations affecting many objects.
|
||||||
|
|||||||
@@ -1103,6 +1103,32 @@ should be a valid crontab(5) expression describing when to run.
|
|||||||
|
|
||||||
Defaults to `0 0 * * *` or daily at midnight.
|
Defaults to `0 0 * * *` or daily at midnight.
|
||||||
|
|
||||||
|
#### [`PAPERLESS_SEARCH_LANGUAGE=<language>`](#PAPERLESS_SEARCH_LANGUAGE) {#PAPERLESS_SEARCH_LANGUAGE}
|
||||||
|
|
||||||
|
: Sets the stemmer language for the full-text search index.
|
||||||
|
Stemming improves recall by matching word variants (e.g. "running" matches "run").
|
||||||
|
Changing this setting causes the index to be rebuilt automatically on next startup.
|
||||||
|
An invalid value raises an error at startup.
|
||||||
|
|
||||||
|
: Use the ISO 639-1 two-letter code (e.g. `en`, `de`, `fr`). Lowercase full names
|
||||||
|
(e.g. `english`, `german`, `french`) are also accepted. The capitalized names shown
|
||||||
|
in the [Tantivy Language enum](https://docs.rs/tantivy/latest/tantivy/tokenizer/enum.Language.html)
|
||||||
|
documentation are **not** valid — use the lowercase equivalent.
|
||||||
|
|
||||||
|
: If not set, paperless infers the language from
|
||||||
|
[`PAPERLESS_OCR_LANGUAGE`](#PAPERLESS_OCR_LANGUAGE). If the OCR language has no
|
||||||
|
Tantivy stemmer equivalent, stemming is disabled.
|
||||||
|
|
||||||
|
Defaults to unset (inferred from `PAPERLESS_OCR_LANGUAGE`).
|
||||||
|
|
||||||
|
#### [`PAPERLESS_ADVANCED_FUZZY_SEARCH_THRESHOLD=<float>`](#PAPERLESS_ADVANCED_FUZZY_SEARCH_THRESHOLD) {#PAPERLESS_ADVANCED_FUZZY_SEARCH_THRESHOLD}
|
||||||
|
|
||||||
|
: When set to a float value, approximate/fuzzy matching is applied alongside exact
|
||||||
|
matching. Fuzzy results rank below exact matches. A value of `0.5` is a reasonable
|
||||||
|
starting point. Leave unset to disable fuzzy matching entirely.
|
||||||
|
|
||||||
|
Defaults to unset (disabled).
|
||||||
|
|
||||||
#### [`PAPERLESS_SANITY_TASK_CRON=<cron expression>`](#PAPERLESS_SANITY_TASK_CRON) {#PAPERLESS_SANITY_TASK_CRON}
|
#### [`PAPERLESS_SANITY_TASK_CRON=<cron expression>`](#PAPERLESS_SANITY_TASK_CRON) {#PAPERLESS_SANITY_TASK_CRON}
|
||||||
|
|
||||||
: Configures the scheduled sanity checker frequency. The value should be a
|
: Configures the scheduled sanity checker frequency. The value should be a
|
||||||
|
|||||||
@@ -370,121 +370,367 @@ docker build --file Dockerfile --tag paperless:local .
|
|||||||
|
|
||||||
## Extending Paperless-ngx
|
## Extending Paperless-ngx
|
||||||
|
|
||||||
Paperless-ngx does not have any fancy plugin systems and will probably never
|
Paperless-ngx supports third-party document parsers via a Python entry point
|
||||||
have. However, some parts of the application have been designed to allow
|
plugin system. Plugins are distributed as ordinary Python packages and
|
||||||
easy integration of additional features without any modification to the
|
discovered automatically at startup — no changes to the Paperless-ngx source
|
||||||
base code.
|
are required.
|
||||||
|
|
||||||
|
!!! warning "Third-party plugins are not officially supported"
|
||||||
|
|
||||||
|
The Paperless-ngx maintainers do not provide support for third-party
|
||||||
|
plugins. Issues that are caused by or require changes to a third-party
|
||||||
|
plugin will be closed without further investigation. If you believe you
|
||||||
|
have found a bug in Paperless-ngx itself (not in a plugin), please
|
||||||
|
reproduce it with all third-party plugins removed before filing an issue.
|
||||||
|
|
||||||
### Making custom parsers
|
### Making custom parsers
|
||||||
|
|
||||||
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
|
- Extracting plain-text content from the document
|
||||||
- Creating a thumbnail
|
- Generating a thumbnail image
|
||||||
- _optional:_ Retrieving a created date from the original
|
- _optional:_ Detecting the document's creation date
|
||||||
- _optional:_ Creating an archived document from the original
|
- _optional:_ Producing a searchable PDF archive copy
|
||||||
|
|
||||||
Custom parsers can be added to Paperless-ngx to support more file types. In
|
Custom parsers are distributed as ordinary Python packages and registered
|
||||||
order to do that, you need to write the parser itself and announce its
|
via a [setuptools entry point](https://setuptools.pypa.io/en/latest/userguide/entry_point.html).
|
||||||
existence to Paperless-ngx.
|
No changes to the Paperless-ngx source are required.
|
||||||
|
|
||||||
The parser itself must extend `documents.parsers.DocumentParser` and
|
#### 1. Implementing the parser class
|
||||||
must implement the methods `parse` and `get_thumbnail`. You can provide
|
|
||||||
your own implementation to `get_date` if you don't want to rely on
|
Your parser must satisfy the `ParserProtocol` structural interface defined in
|
||||||
Paperless-ngx' default date guessing mechanisms.
|
`paperless.parsers`. The simplest approach is to write a plain class — no base
|
||||||
|
class is required, only the right attributes and methods.
|
||||||
|
|
||||||
|
**Class-level identity attributes**
|
||||||
|
|
||||||
|
The registry reads these before instantiating the parser, so they must be
|
||||||
|
plain class attributes (not instance attributes or properties):
|
||||||
|
|
||||||
```python
|
```python
|
||||||
class MyCustomParser(DocumentParser):
|
class MyCustomParser:
|
||||||
|
name = "My Format Parser" # human-readable name shown in logs
|
||||||
def parse(self, document_path, mime_type):
|
version = "1.0.0" # semantic version string
|
||||||
# This method does not return anything. Rather, you should assign
|
author = "Acme Corp" # author / organisation
|
||||||
# whatever you got from the document to the following fields:
|
url = "https://example.com/my-parser" # docs or issue tracker
|
||||||
|
|
||||||
# The content of the document.
|
|
||||||
self.text = "content"
|
|
||||||
|
|
||||||
# Optional: path to a PDF document that you created from the original.
|
|
||||||
self.archive_path = os.path.join(self.tempdir, "archived.pdf")
|
|
||||||
|
|
||||||
# Optional: "created" date of the document.
|
|
||||||
self.date = get_created_from_metadata(document_path)
|
|
||||||
|
|
||||||
def get_thumbnail(self, document_path, mime_type):
|
|
||||||
# This should return the path to a thumbnail you created for this
|
|
||||||
# document.
|
|
||||||
return os.path.join(self.tempdir, "thumb.webp")
|
|
||||||
```
|
```
|
||||||
|
|
||||||
If you encounter any issues during parsing, raise a
|
**Declaring supported MIME types**
|
||||||
`documents.parsers.ParseError`.
|
|
||||||
|
|
||||||
The `self.tempdir` directory is a temporary directory that is guaranteed
|
Return a `dict` mapping MIME type strings to preferred file extensions
|
||||||
to be empty and removed after consumption finished. You can use that
|
(including the leading dot). Paperless-ngx uses the extension when storing
|
||||||
directory to store any intermediate files and also use it to store the
|
archive copies and serving files for download.
|
||||||
thumbnail / archived document.
|
|
||||||
|
|
||||||
After that, you need to announce your parser to Paperless-ngx. You need to
|
|
||||||
connect a handler to the `document_consumer_declaration` signal. Have a
|
|
||||||
look in the file `src/paperless_tesseract/apps.py` on how that's done.
|
|
||||||
The handler is a method that returns information about your parser:
|
|
||||||
|
|
||||||
```python
|
```python
|
||||||
def myparser_consumer_declaration(sender, **kwargs):
|
@classmethod
|
||||||
|
def supported_mime_types(cls) -> dict[str, str]:
|
||||||
return {
|
return {
|
||||||
"parser": MyCustomParser,
|
"application/x-my-format": ".myf",
|
||||||
"weight": 0,
|
"application/x-my-format-alt": ".myf",
|
||||||
"mime_types": {
|
|
||||||
"application/pdf": ".pdf",
|
|
||||||
"image/jpeg": ".jpg",
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
- `parser` is a reference to a class that extends `DocumentParser`.
|
**Scoring**
|
||||||
- `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
|
|
||||||
override the parsers provided by Paperless-ngx.
|
|
||||||
- `mime_types` is a dictionary. The keys are the mime types your
|
|
||||||
parser supports and the value is the default file extension that
|
|
||||||
Paperless-ngx should use when storing files and serving them for
|
|
||||||
download. We could guess that from the file extensions, but some
|
|
||||||
mime types have many extensions associated with them and the Python
|
|
||||||
methods responsible for guessing the extension do not always return
|
|
||||||
the same value.
|
|
||||||
|
|
||||||
## Using Visual Studio Code devcontainer
|
When more than one parser can handle a file, the registry calls `score()` on
|
||||||
|
each candidate and picks the one with the highest result and equal scores favor third-party parsers over built-ins. Return `None` to
|
||||||
|
decline handling a file even though the MIME type is listed as supported (for
|
||||||
|
example, when a required external service is not configured).
|
||||||
|
|
||||||
Another easy way to get started with development is to use Visual Studio
|
| Score | Meaning |
|
||||||
Code devcontainers. This approach will create a preconfigured development
|
| ------ | --------------------------------------------------------------------------------- |
|
||||||
environment with all of the required tools and dependencies.
|
| `None` | Decline — do not handle this file |
|
||||||
[Learn more about devcontainers](https://code.visualstudio.com/docs/devcontainers/containers).
|
| `10` | Default priority used by all built-in parsers |
|
||||||
The .devcontainer/vscode/tasks.json and .devcontainer/vscode/launch.json files
|
| `20` | Priority used by the remote OCR built-in parser, allowing it to replace Tesseract |
|
||||||
contain more information about the specific tasks and launch configurations (see the
|
| `> 10` | Override a built-in parser for the same MIME type |
|
||||||
non-standard "description" field).
|
|
||||||
|
|
||||||
To get started:
|
```python
|
||||||
|
@classmethod
|
||||||
|
def score(
|
||||||
|
cls,
|
||||||
|
mime_type: str,
|
||||||
|
filename: str,
|
||||||
|
path: "Path | None" = None,
|
||||||
|
) -> int | None:
|
||||||
|
# Inspect filename or file bytes here if needed.
|
||||||
|
return 10
|
||||||
|
```
|
||||||
|
|
||||||
1. Clone the repository on your machine and open the Paperless-ngx folder in VS Code.
|
**Archive and rendition flags**
|
||||||
|
|
||||||
2. VS Code will prompt you with "Reopen in container". Do so and wait for the environment to start.
|
```python
|
||||||
|
@property
|
||||||
|
def can_produce_archive(self) -> bool:
|
||||||
|
"""True if parse() can produce a searchable PDF archive copy."""
|
||||||
|
return True # or False if your parser doesn't produce PDFs
|
||||||
|
|
||||||
3. In case your host operating system is Windows:
|
@property
|
||||||
- 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.
|
def requires_pdf_rendition(self) -> bool:
|
||||||
- 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.
|
"""True if the original format cannot be displayed by a browser
|
||||||
|
(e.g. DOCX, ODT) and the PDF output must always be kept."""
|
||||||
|
return False
|
||||||
|
```
|
||||||
|
|
||||||
4. Initialize the project by running the task **Project Setup: Run all Init Tasks**. This
|
**Context manager — temp directory lifecycle**
|
||||||
will initialize the database tables and create a superuser. Then you can compile the front end
|
|
||||||
for production or run the frontend in debug mode.
|
|
||||||
|
|
||||||
5. The project is ready for debugging, start either run the fullstack debug or individual debug
|
Paperless-ngx always uses parsers as context managers. Create a temporary
|
||||||
processes. Yo spin up the project without debugging run the task **Project Start: Run all Services**
|
working directory in `__enter__` (or `__init__`) and remove it in `__exit__`
|
||||||
|
regardless of whether an exception occurred. Store intermediate files,
|
||||||
|
thumbnails, and archive PDFs inside this directory.
|
||||||
|
|
||||||
## Developing Date Parser Plugins
|
```python
|
||||||
|
import shutil
|
||||||
|
import tempfile
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Self
|
||||||
|
from types import TracebackType
|
||||||
|
|
||||||
|
from django.conf import settings
|
||||||
|
|
||||||
|
class MyCustomParser:
|
||||||
|
...
|
||||||
|
|
||||||
|
def __init__(self, logging_group: object = None) -> None:
|
||||||
|
settings.SCRATCH_DIR.mkdir(parents=True, exist_ok=True)
|
||||||
|
self._tempdir = Path(
|
||||||
|
tempfile.mkdtemp(prefix="paperless-", dir=settings.SCRATCH_DIR)
|
||||||
|
)
|
||||||
|
self._text: str | None = None
|
||||||
|
self._archive_path: Path | None = None
|
||||||
|
|
||||||
|
def __enter__(self) -> Self:
|
||||||
|
return self
|
||||||
|
|
||||||
|
def __exit__(
|
||||||
|
self,
|
||||||
|
exc_type: type[BaseException] | None,
|
||||||
|
exc_val: BaseException | None,
|
||||||
|
exc_tb: TracebackType | None,
|
||||||
|
) -> None:
|
||||||
|
shutil.rmtree(self._tempdir, ignore_errors=True)
|
||||||
|
```
|
||||||
|
|
||||||
|
**Optional context — `configure()`**
|
||||||
|
|
||||||
|
The consumer calls `configure()` with a `ParserContext` after instantiation
|
||||||
|
and before `parse()`. If your parser doesn't need context, a no-op
|
||||||
|
implementation is fine:
|
||||||
|
|
||||||
|
```python
|
||||||
|
from paperless.parsers import ParserContext
|
||||||
|
|
||||||
|
def configure(self, context: ParserContext) -> None:
|
||||||
|
pass # override if you need context.mailrule_id, etc.
|
||||||
|
```
|
||||||
|
|
||||||
|
**Parsing**
|
||||||
|
|
||||||
|
`parse()` is the core method. It must not return a value; instead, store
|
||||||
|
results in instance attributes and expose them via the accessor methods below.
|
||||||
|
Raise `documents.parsers.ParseError` on any unrecoverable failure.
|
||||||
|
|
||||||
|
```python
|
||||||
|
from documents.parsers import ParseError
|
||||||
|
|
||||||
|
def parse(
|
||||||
|
self,
|
||||||
|
document_path: Path,
|
||||||
|
mime_type: str,
|
||||||
|
*,
|
||||||
|
produce_archive: bool = True,
|
||||||
|
) -> None:
|
||||||
|
try:
|
||||||
|
self._text = extract_text_from_my_format(document_path)
|
||||||
|
except Exception as e:
|
||||||
|
raise ParseError(f"Failed to parse {document_path}: {e}") from e
|
||||||
|
|
||||||
|
if produce_archive and self.can_produce_archive:
|
||||||
|
archive = self._tempdir / "archived.pdf"
|
||||||
|
convert_to_pdf(document_path, archive)
|
||||||
|
self._archive_path = archive
|
||||||
|
```
|
||||||
|
|
||||||
|
**Result accessors**
|
||||||
|
|
||||||
|
```python
|
||||||
|
def get_text(self) -> str | None:
|
||||||
|
return self._text
|
||||||
|
|
||||||
|
def get_date(self) -> "datetime.datetime | None":
|
||||||
|
# Return a datetime extracted from the document, or None to let
|
||||||
|
# Paperless-ngx use its default date-guessing logic.
|
||||||
|
return None
|
||||||
|
|
||||||
|
def get_archive_path(self) -> Path | None:
|
||||||
|
return self._archive_path
|
||||||
|
|
||||||
|
def get_page_count(self, document_path: Path, mime_type: str) -> int | None:
|
||||||
|
# If the format doesn't have the concept of pages, return None
|
||||||
|
return count_pages(document_path)
|
||||||
|
|
||||||
|
```
|
||||||
|
|
||||||
|
**Thumbnail**
|
||||||
|
|
||||||
|
`get_thumbnail()` may be called independently of `parse()`. Return the path
|
||||||
|
to a WebP image inside `self._tempdir`. The image should be roughly 500 × 700
|
||||||
|
pixels.
|
||||||
|
|
||||||
|
```python
|
||||||
|
def get_thumbnail(self, document_path: Path, mime_type: str) -> Path:
|
||||||
|
thumb = self._tempdir / "thumb.webp"
|
||||||
|
render_thumbnail(document_path, thumb)
|
||||||
|
return thumb
|
||||||
|
```
|
||||||
|
|
||||||
|
**Optional methods**
|
||||||
|
|
||||||
|
These are called by the API on demand, not during the consumption pipeline.
|
||||||
|
Implement them if your format supports the information; otherwise return
|
||||||
|
`None` / `[]`.
|
||||||
|
|
||||||
|
```python
|
||||||
|
|
||||||
|
def extract_metadata(
|
||||||
|
self,
|
||||||
|
document_path: Path,
|
||||||
|
mime_type: str,
|
||||||
|
) -> "list[MetadataEntry]":
|
||||||
|
# Must never raise. Return [] if metadata cannot be read.
|
||||||
|
from paperless.parsers import MetadataEntry
|
||||||
|
return [
|
||||||
|
MetadataEntry(
|
||||||
|
namespace="https://example.com/ns/",
|
||||||
|
prefix="ex",
|
||||||
|
key="Author",
|
||||||
|
value="Alice",
|
||||||
|
)
|
||||||
|
]
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 2. Registering via entry point
|
||||||
|
|
||||||
|
Add the following to your package's `pyproject.toml`. The key (left of `=`)
|
||||||
|
is an arbitrary name used only in log output; the value is the
|
||||||
|
`module:ClassName` import path.
|
||||||
|
|
||||||
|
```toml
|
||||||
|
[project.entry-points."paperless_ngx.parsers"]
|
||||||
|
my_parser = "my_package.parsers:MyCustomParser"
|
||||||
|
```
|
||||||
|
|
||||||
|
Install your package into the same Python environment as Paperless-ngx (or
|
||||||
|
add it to the Docker image), and the parser will be discovered automatically
|
||||||
|
on the next startup. No configuration changes are needed.
|
||||||
|
|
||||||
|
To verify discovery, check the application logs at startup for a line like:
|
||||||
|
|
||||||
|
```
|
||||||
|
Loaded third-party parser 'My Format Parser' v1.0.0 by Acme Corp (entrypoint: 'my_parser').
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 3. Utilities
|
||||||
|
|
||||||
|
`paperless.parsers.utils` provides helpers you can import directly:
|
||||||
|
|
||||||
|
| Function | Description |
|
||||||
|
| --------------------------------------- | ---------------------------------------------------------------- |
|
||||||
|
| `read_file_handle_unicode_errors(path)` | Read a file as UTF-8, replacing invalid bytes instead of raising |
|
||||||
|
| `get_page_count_for_pdf(path)` | Count pages in a PDF using pikepdf |
|
||||||
|
| `extract_pdf_metadata(path)` | Extract XMP metadata from a PDF as a `list[MetadataEntry]` |
|
||||||
|
|
||||||
|
#### Minimal example
|
||||||
|
|
||||||
|
A complete, working parser for a hypothetical plain-XML format:
|
||||||
|
|
||||||
|
```python
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import shutil
|
||||||
|
import tempfile
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Self
|
||||||
|
from types import TracebackType
|
||||||
|
import xml.etree.ElementTree as ET
|
||||||
|
|
||||||
|
from django.conf import settings
|
||||||
|
|
||||||
|
from documents.parsers import ParseError
|
||||||
|
from paperless.parsers import ParserContext
|
||||||
|
|
||||||
|
|
||||||
|
class XmlDocumentParser:
|
||||||
|
name = "XML Parser"
|
||||||
|
version = "1.0.0"
|
||||||
|
author = "Acme Corp"
|
||||||
|
url = "https://example.com/xml-parser"
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def supported_mime_types(cls) -> dict[str, str]:
|
||||||
|
return {"application/xml": ".xml", "text/xml": ".xml"}
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def score(cls, mime_type: str, filename: str, path: Path | None = None) -> int | None:
|
||||||
|
return 10
|
||||||
|
|
||||||
|
@property
|
||||||
|
def can_produce_archive(self) -> bool:
|
||||||
|
return False
|
||||||
|
|
||||||
|
@property
|
||||||
|
def requires_pdf_rendition(self) -> bool:
|
||||||
|
return False
|
||||||
|
|
||||||
|
def __init__(self, logging_group: object = None) -> None:
|
||||||
|
settings.SCRATCH_DIR.mkdir(parents=True, exist_ok=True)
|
||||||
|
self._tempdir = Path(tempfile.mkdtemp(prefix="paperless-", dir=settings.SCRATCH_DIR))
|
||||||
|
self._text: str | None = None
|
||||||
|
|
||||||
|
def __enter__(self) -> Self:
|
||||||
|
return self
|
||||||
|
|
||||||
|
def __exit__(self, exc_type, exc_val, exc_tb) -> None:
|
||||||
|
shutil.rmtree(self._tempdir, ignore_errors=True)
|
||||||
|
|
||||||
|
def configure(self, context: ParserContext) -> None:
|
||||||
|
pass
|
||||||
|
|
||||||
|
def parse(self, document_path: Path, mime_type: str, *, produce_archive: bool = True) -> None:
|
||||||
|
try:
|
||||||
|
tree = ET.parse(document_path)
|
||||||
|
self._text = " ".join(tree.getroot().itertext())
|
||||||
|
except ET.ParseError as e:
|
||||||
|
raise ParseError(f"XML parse error: {e}") from e
|
||||||
|
|
||||||
|
def get_text(self) -> str | None:
|
||||||
|
return self._text
|
||||||
|
|
||||||
|
def get_date(self):
|
||||||
|
return None
|
||||||
|
|
||||||
|
def get_archive_path(self) -> Path | None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
def get_thumbnail(self, document_path: Path, mime_type: str) -> Path:
|
||||||
|
from PIL import Image, ImageDraw
|
||||||
|
img = Image.new("RGB", (500, 700), color="white")
|
||||||
|
ImageDraw.Draw(img).text((10, 10), "XML Document", fill="black")
|
||||||
|
out = self._tempdir / "thumb.webp"
|
||||||
|
img.save(out, format="WEBP")
|
||||||
|
return out
|
||||||
|
|
||||||
|
def get_page_count(self, document_path: Path, mime_type: str) -> int | None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
def extract_metadata(self, document_path: Path, mime_type: str) -> list:
|
||||||
|
return []
|
||||||
|
```
|
||||||
|
|
||||||
|
### Developing date parser plugins
|
||||||
|
|
||||||
Paperless-ngx uses a plugin system for date parsing, allowing you to extend or replace the default date parsing behavior. Plugins are discovered using [Python entry points](https://setuptools.pypa.io/en/latest/userguide/entry_point.html).
|
Paperless-ngx uses a plugin system for date parsing, allowing you to extend or replace the default date parsing behavior. Plugins are discovered using [Python entry points](https://setuptools.pypa.io/en/latest/userguide/entry_point.html).
|
||||||
|
|
||||||
### Creating a Date Parser Plugin
|
#### Creating a Date Parser Plugin
|
||||||
|
|
||||||
To create a custom date parser plugin, you need to:
|
To create a custom date parser plugin, you need to:
|
||||||
|
|
||||||
@@ -492,7 +738,7 @@ To create a custom date parser plugin, you need to:
|
|||||||
2. Implement the required abstract method
|
2. Implement the required abstract method
|
||||||
3. Register your plugin via an entry point
|
3. Register your plugin via an entry point
|
||||||
|
|
||||||
#### 1. Implementing the Parser Class
|
##### 1. Implementing the Parser Class
|
||||||
|
|
||||||
Your parser must extend `documents.plugins.date_parsing.DateParserPluginBase` and implement the `parse` method:
|
Your parser must extend `documents.plugins.date_parsing.DateParserPluginBase` and implement the `parse` method:
|
||||||
|
|
||||||
@@ -532,7 +778,7 @@ class MyDateParserPlugin(DateParserPluginBase):
|
|||||||
yield another_datetime
|
yield another_datetime
|
||||||
```
|
```
|
||||||
|
|
||||||
#### 2. Configuration and Helper Methods
|
##### 2. Configuration and Helper Methods
|
||||||
|
|
||||||
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:
|
||||||
|
|
||||||
@@ -565,11 +811,11 @@ def _filter_date(
|
|||||||
"""
|
"""
|
||||||
```
|
```
|
||||||
|
|
||||||
#### 3. Resource Management (Optional)
|
##### 3. Resource Management (Optional)
|
||||||
|
|
||||||
If your plugin needs to acquire or release resources (database connections, API clients, etc.), override the context manager methods. Paperless-ngx will always use plugins as context managers, ensuring resources can be released even in the event of errors.
|
If your plugin needs to acquire or release resources (database connections, API clients, etc.), override the context manager methods. Paperless-ngx will always use plugins as context managers, ensuring resources can be released even in the event of errors.
|
||||||
|
|
||||||
#### 4. Registering Your Plugin
|
##### 4. Registering Your Plugin
|
||||||
|
|
||||||
Register your plugin using a setuptools entry point in your package's `pyproject.toml`:
|
Register your plugin using a setuptools entry point in your package's `pyproject.toml`:
|
||||||
|
|
||||||
@@ -580,7 +826,7 @@ my_parser = "my_package.parsers:MyDateParserPlugin"
|
|||||||
|
|
||||||
The entry point name (e.g., `"my_parser"`) is used for sorting when multiple plugins are found. Paperless-ngx will use the first plugin alphabetically by name if multiple plugins are discovered.
|
The entry point name (e.g., `"my_parser"`) is used for sorting when multiple plugins are found. Paperless-ngx will use the first plugin alphabetically by name if multiple plugins are discovered.
|
||||||
|
|
||||||
### Plugin Discovery
|
#### Plugin Discovery
|
||||||
|
|
||||||
Paperless-ngx automatically discovers and loads date parser plugins at runtime. The discovery process:
|
Paperless-ngx automatically discovers and loads date parser plugins at runtime. The discovery process:
|
||||||
|
|
||||||
@@ -591,7 +837,7 @@ Paperless-ngx automatically discovers and loads date parser plugins at runtime.
|
|||||||
|
|
||||||
If multiple plugins are installed, a warning is logged indicating which plugin was selected.
|
If multiple plugins are installed, a warning is logged indicating which plugin was selected.
|
||||||
|
|
||||||
### Example: Simple Date Parser
|
#### Example: Simple Date Parser
|
||||||
|
|
||||||
Here's a minimal example that only looks for ISO 8601 dates:
|
Here's a minimal example that only looks for ISO 8601 dates:
|
||||||
|
|
||||||
@@ -623,3 +869,30 @@ class ISODateParserPlugin(DateParserPluginBase):
|
|||||||
if filtered_date is not None:
|
if filtered_date is not None:
|
||||||
yield filtered_date
|
yield filtered_date
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## Using Visual Studio Code devcontainer
|
||||||
|
|
||||||
|
Another easy way to get started with development is to use Visual Studio
|
||||||
|
Code devcontainers. This approach will create a preconfigured development
|
||||||
|
environment with all of the required tools and dependencies.
|
||||||
|
[Learn more about devcontainers](https://code.visualstudio.com/docs/devcontainers/containers).
|
||||||
|
The .devcontainer/vscode/tasks.json and .devcontainer/vscode/launch.json files
|
||||||
|
contain more information about the specific tasks and launch configurations (see the
|
||||||
|
non-standard "description" field).
|
||||||
|
|
||||||
|
To get started:
|
||||||
|
|
||||||
|
1. Clone the repository on your machine and open the Paperless-ngx folder in VS Code.
|
||||||
|
|
||||||
|
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:
|
||||||
|
- 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
|
||||||
|
will initialize the database tables and create a superuser. Then you can compile the front end
|
||||||
|
for production or run the frontend in debug mode.
|
||||||
|
|
||||||
|
5. The project is ready for debugging, start either run the fullstack debug or individual debug
|
||||||
|
processes. Yo spin up the project without debugging run the task **Project Start: Run all Services**
|
||||||
|
|||||||
@@ -104,6 +104,37 @@ Multiple options are combined in a single value:
|
|||||||
PAPERLESS_DB_OPTIONS="sslmode=require;sslrootcert=/certs/ca.pem;pool.max_size=10"
|
PAPERLESS_DB_OPTIONS="sslmode=require;sslrootcert=/certs/ca.pem;pool.max_size=10"
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## Search Index (Whoosh -> Tantivy)
|
||||||
|
|
||||||
|
The full-text search backend has been replaced with [Tantivy](https://github.com/quickwit-oss/tantivy).
|
||||||
|
The index format is incompatible with Whoosh, so **the search index is automatically rebuilt from
|
||||||
|
scratch on first startup after upgrading**. No manual action is required for the rebuild itself.
|
||||||
|
|
||||||
|
### Note and custom field search syntax
|
||||||
|
|
||||||
|
The old Whoosh index exposed `note` and `custom_field` as flat text fields that were included in
|
||||||
|
unqualified searches (e.g. just typing `invoice` would match note content). With Tantivy these are
|
||||||
|
now structured JSON fields accessed via dotted paths:
|
||||||
|
|
||||||
|
| Old syntax | New syntax |
|
||||||
|
| -------------------- | --------------------------- |
|
||||||
|
| `note:query` | `notes.note:query` |
|
||||||
|
| `custom_field:query` | `custom_fields.value:query` |
|
||||||
|
|
||||||
|
**Saved views are migrated automatically.** Any saved view filter rule that used an explicit
|
||||||
|
`note:` or `custom_field:` field prefix in a fulltext query is rewritten to the new syntax by a
|
||||||
|
data migration that runs on upgrade.
|
||||||
|
|
||||||
|
**Unqualified queries are not migrated.** If you had a saved view with a plain search term (e.g.
|
||||||
|
`invoice`) that happened to match note content or custom field values, it will no longer return
|
||||||
|
those matches. Update those queries to use the explicit prefix, for example:
|
||||||
|
|
||||||
|
```
|
||||||
|
invoice OR notes.note:invoice OR custom_fields.value:invoice
|
||||||
|
```
|
||||||
|
|
||||||
|
Custom field names can also be searched with `custom_fields.name:fieldname`.
|
||||||
|
|
||||||
## OpenID Connect Token Endpoint Authentication
|
## OpenID Connect Token Endpoint Authentication
|
||||||
|
|
||||||
Some existing OpenID Connect setups may require an explicit token endpoint authentication method after upgrading to v3.
|
Some existing OpenID Connect setups may require an explicit token endpoint authentication method after upgrading to v3.
|
||||||
|
|||||||
@@ -804,13 +804,20 @@ contract you signed 8 years ago).
|
|||||||
|
|
||||||
When you search paperless for a document, it tries to match this query
|
When you search paperless for a document, it tries to match this query
|
||||||
against your documents. Paperless will look for matching documents by
|
against your documents. Paperless will look for matching documents by
|
||||||
inspecting their content, title, correspondent, type and tags. Paperless
|
inspecting their content, title, correspondent, type, tags, notes, and
|
||||||
returns a scored list of results, so that documents matching your query
|
custom field values. Paperless returns a scored list of results, so that
|
||||||
better will appear further up in the search results.
|
documents matching your query better will appear further up in the search
|
||||||
|
results.
|
||||||
|
|
||||||
By default, paperless returns only documents which contain all words
|
By default, paperless returns only documents which contain all words
|
||||||
typed in the search bar. However, paperless also offers advanced search
|
typed in the search bar. A few things to know about how matching works:
|
||||||
syntax if you want to drill down the results further.
|
|
||||||
|
- **Word-order-independent**: "invoice unpaid" and "unpaid invoice" return the same results.
|
||||||
|
- **Accent-insensitive**: searching `resume` also finds `résumé`, `cafe` finds `café`.
|
||||||
|
- **Separator-agnostic**: punctuation and separators are stripped during indexing, so
|
||||||
|
searching a partial number like `1312` finds documents containing `A-1312/B`.
|
||||||
|
|
||||||
|
Paperless also offers advanced search syntax if you want to drill down further.
|
||||||
|
|
||||||
Matching documents with logical expressions:
|
Matching documents with logical expressions:
|
||||||
|
|
||||||
@@ -839,18 +846,69 @@ Matching inexact words:
|
|||||||
produ*name
|
produ*name
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Matching natural date keywords:
|
||||||
|
|
||||||
|
```
|
||||||
|
added:today
|
||||||
|
modified:yesterday
|
||||||
|
created:this_week
|
||||||
|
added:last_month
|
||||||
|
modified:this_year
|
||||||
|
```
|
||||||
|
|
||||||
|
Supported date keywords: `today`, `yesterday`, `this_week`, `last_week`,
|
||||||
|
`this_month`, `last_month`, `this_year`, `last_year`.
|
||||||
|
|
||||||
|
#### Searching custom fields
|
||||||
|
|
||||||
|
Custom field values are included in the full-text index, so a plain search
|
||||||
|
already matches documents whose custom field values contain your search terms.
|
||||||
|
To narrow by field name or value specifically:
|
||||||
|
|
||||||
|
```
|
||||||
|
custom_fields.value:policy
|
||||||
|
custom_fields.name:"Contract Number"
|
||||||
|
custom_fields.name:Insurance custom_fields.value:policy
|
||||||
|
```
|
||||||
|
|
||||||
|
- `custom_fields.value` matches against the value of any custom field.
|
||||||
|
- `custom_fields.name` matches the name of the field (use quotes for multi-word names).
|
||||||
|
- Combine both to find documents where a specific named field contains a specific value.
|
||||||
|
|
||||||
|
Because separators are stripped during indexing, individual parts of formatted
|
||||||
|
codes are searchable on their own. A value stored as `A-1312/99.50` produces the
|
||||||
|
tokens `a`, `1312`, `99`, `50` — each searchable independently:
|
||||||
|
|
||||||
|
```
|
||||||
|
custom_fields.value:1312
|
||||||
|
custom_fields.name:"Contract Number" custom_fields.value:1312
|
||||||
|
```
|
||||||
|
|
||||||
!!! note
|
!!! note
|
||||||
|
|
||||||
Inexact terms are hard for search indexes. These queries might take a
|
Custom date fields do not support relative date syntax (e.g. `[now to 2 weeks]`).
|
||||||
while to execute. That's why paperless offers auto complete and query
|
For date ranges on custom date fields, use the document list filters in the web UI.
|
||||||
correction.
|
|
||||||
|
#### Searching notes
|
||||||
|
|
||||||
|
Notes content is included in full-text search automatically. To search
|
||||||
|
by note author or content specifically:
|
||||||
|
|
||||||
|
```
|
||||||
|
notes.user:alice
|
||||||
|
notes.note:reminder
|
||||||
|
notes.user:alice notes.note:insurance
|
||||||
|
```
|
||||||
|
|
||||||
All of these constructs can be combined as you see fit. If you want to
|
All of these constructs can be combined as you see fit. If you want to
|
||||||
learn more about the query language used by paperless, paperless uses
|
learn more about the query language used by paperless, see the
|
||||||
Whoosh's default query language. Head over to [Whoosh query
|
[Tantivy query language documentation](https://docs.rs/tantivy/latest/tantivy/query/struct.QueryParser.html).
|
||||||
language](https://whoosh.readthedocs.io/en/latest/querylang.html). For
|
|
||||||
details on what date parsing utilities are available, see [Date
|
!!! note
|
||||||
parsing](https://whoosh.readthedocs.io/en/latest/dates.html#parsing-date-queries).
|
|
||||||
|
Fuzzy (approximate) matching can be enabled by setting
|
||||||
|
[`PAPERLESS_ADVANCED_FUZZY_SEARCH_THRESHOLD`](configuration.md#PAPERLESS_ADVANCED_FUZZY_SEARCH_THRESHOLD).
|
||||||
|
When enabled, paperless will include near-miss results ranked below exact matches.
|
||||||
|
|
||||||
## Keyboard shortcuts / hotkeys
|
## Keyboard shortcuts / hotkeys
|
||||||
|
|
||||||
|
|||||||
201
pyproject.toml
201
pyproject.toml
@@ -13,7 +13,6 @@ classifiers = [
|
|||||||
]
|
]
|
||||||
# TODO: Move certain things to groups and then utilize that further
|
# TODO: Move certain things to groups and then utilize that further
|
||||||
# This will allow testing to not install a webserver, mysql, etc
|
# This will allow testing to not install a webserver, mysql, etc
|
||||||
|
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"azure-ai-documentintelligence>=1.0.2",
|
"azure-ai-documentintelligence>=1.0.2",
|
||||||
"babel>=2.17",
|
"babel>=2.17",
|
||||||
@@ -47,7 +46,7 @@ dependencies = [
|
|||||||
"faiss-cpu>=1.10",
|
"faiss-cpu>=1.10",
|
||||||
"filelock~=3.25.2",
|
"filelock~=3.25.2",
|
||||||
"flower~=2.0.1",
|
"flower~=2.0.1",
|
||||||
"gotenberg-client~=0.13.1",
|
"gotenberg-client~=0.14.0",
|
||||||
"httpx-oauth~=0.16",
|
"httpx-oauth~=0.16",
|
||||||
"ijson>=3.2",
|
"ijson>=3.2",
|
||||||
"imap-tools~=1.11.0",
|
"imap-tools~=1.11.0",
|
||||||
@@ -60,7 +59,7 @@ dependencies = [
|
|||||||
"llama-index-llms-openai>=0.6.13",
|
"llama-index-llms-openai>=0.6.13",
|
||||||
"llama-index-vector-stores-faiss>=0.5.2",
|
"llama-index-vector-stores-faiss>=0.5.2",
|
||||||
"nltk~=3.9.1",
|
"nltk~=3.9.1",
|
||||||
"ocrmypdf~=17.3.0",
|
"ocrmypdf~=17.4.0",
|
||||||
"openai>=1.76",
|
"openai>=1.76",
|
||||||
"pathvalidate~=3.3.1",
|
"pathvalidate~=3.3.1",
|
||||||
"pdf2image~=1.17.0",
|
"pdf2image~=1.17.0",
|
||||||
@@ -75,39 +74,40 @@ dependencies = [
|
|||||||
"scikit-learn~=1.8.0",
|
"scikit-learn~=1.8.0",
|
||||||
"sentence-transformers>=4.1",
|
"sentence-transformers>=4.1",
|
||||||
"setproctitle~=1.3.4",
|
"setproctitle~=1.3.4",
|
||||||
"tika-client~=0.10.0",
|
"tantivy>=0.25.1",
|
||||||
|
"tika-client~=0.11.0",
|
||||||
"torch~=2.10.0",
|
"torch~=2.10.0",
|
||||||
"watchfiles>=1.1.1",
|
"watchfiles>=1.1.1",
|
||||||
"whitenoise~=6.11",
|
"whitenoise~=6.11",
|
||||||
"whoosh-reloaded>=2.7.5",
|
|
||||||
"zxing-cpp~=3.0.0",
|
"zxing-cpp~=3.0.0",
|
||||||
]
|
]
|
||||||
|
[project.optional-dependencies]
|
||||||
optional-dependencies.mariadb = [
|
mariadb = [
|
||||||
"mysqlclient~=2.2.7",
|
"mysqlclient~=2.2.7",
|
||||||
]
|
]
|
||||||
optional-dependencies.postgres = [
|
postgres = [
|
||||||
"psycopg[c,pool]==3.3",
|
"psycopg[c,pool]==3.3",
|
||||||
# Direct dependency for proper resolution of the pre-built wheels
|
# Direct dependency for proper resolution of the pre-built wheels
|
||||||
"psycopg-c==3.3",
|
"psycopg-c==3.3",
|
||||||
"psycopg-pool==3.3",
|
"psycopg-pool==3.3",
|
||||||
]
|
]
|
||||||
optional-dependencies.webserver = [
|
webserver = [
|
||||||
"granian[uvloop]~=2.7.0",
|
"granian[uvloop]~=2.7.0",
|
||||||
]
|
]
|
||||||
|
|
||||||
[dependency-groups]
|
[dependency-groups]
|
||||||
|
|
||||||
dev = [
|
dev = [
|
||||||
{ "include-group" = "docs" },
|
{ include-group = "docs" },
|
||||||
{ "include-group" = "testing" },
|
{ include-group = "lint" },
|
||||||
{ "include-group" = "lint" },
|
{ include-group = "testing" },
|
||||||
]
|
]
|
||||||
|
|
||||||
docs = [
|
docs = [
|
||||||
"zensical>=0.0.21",
|
"zensical>=0.0.21",
|
||||||
]
|
]
|
||||||
|
lint = [
|
||||||
|
"prek~=0.3.0",
|
||||||
|
"ruff~=0.15.0",
|
||||||
|
]
|
||||||
testing = [
|
testing = [
|
||||||
"daphne",
|
"daphne",
|
||||||
"factory-boy~=3.3.1",
|
"factory-boy~=3.3.1",
|
||||||
@@ -119,17 +119,12 @@ testing = [
|
|||||||
"pytest-env~=1.5.0",
|
"pytest-env~=1.5.0",
|
||||||
"pytest-httpx",
|
"pytest-httpx",
|
||||||
"pytest-mock~=3.15.1",
|
"pytest-mock~=3.15.1",
|
||||||
#"pytest-randomly~=4.0.1",
|
# "pytest-randomly~=4.0.1",
|
||||||
"pytest-rerunfailures~=16.1",
|
"pytest-rerunfailures~=16.1",
|
||||||
"pytest-sugar",
|
"pytest-sugar",
|
||||||
"pytest-xdist~=3.8.0",
|
"pytest-xdist~=3.8.0",
|
||||||
|
"time-machine>=2.13",
|
||||||
]
|
]
|
||||||
|
|
||||||
lint = [
|
|
||||||
"prek~=0.3.0",
|
|
||||||
"ruff~=0.15.0",
|
|
||||||
]
|
|
||||||
|
|
||||||
typing = [
|
typing = [
|
||||||
"celery-types",
|
"celery-types",
|
||||||
"django-filter-stubs",
|
"django-filter-stubs",
|
||||||
@@ -154,24 +149,21 @@ typing = [
|
|||||||
|
|
||||||
[tool.uv]
|
[tool.uv]
|
||||||
required-version = ">=0.9.0"
|
required-version = ">=0.9.0"
|
||||||
package = false
|
|
||||||
environments = [
|
environments = [
|
||||||
"sys_platform == 'darwin'",
|
"sys_platform == 'darwin'",
|
||||||
"sys_platform == 'linux'",
|
"sys_platform == 'linux'",
|
||||||
]
|
]
|
||||||
|
package = false
|
||||||
[[tool.uv.index]]
|
[[tool.uv.index]]
|
||||||
name = "pytorch-cpu"
|
name = "pytorch-cpu"
|
||||||
url = "https://download.pytorch.org/whl/cpu"
|
url = "https://download.pytorch.org/whl/cpu"
|
||||||
explicit = true
|
explicit = true
|
||||||
|
|
||||||
[tool.uv.sources]
|
[tool.uv.sources]
|
||||||
# Markers are chosen to select these almost exclusively when building the Docker image
|
# Markers are chosen to select these almost exclusively when building the Docker image
|
||||||
psycopg-c = [
|
psycopg-c = [
|
||||||
{ url = "https://github.com/paperless-ngx/builder/releases/download/psycopg-trixie-3.3.0/psycopg_c-3.3.0-cp312-cp312-linux_x86_64.whl", marker = "sys_platform == 'linux' and platform_machine == 'x86_64' and python_version == '3.12'" },
|
{ url = "https://github.com/paperless-ngx/builder/releases/download/psycopg-trixie-3.3.0/psycopg_c-3.3.0-cp312-cp312-linux_x86_64.whl", marker = "sys_platform == 'linux' and platform_machine == 'x86_64' and python_version == '3.12'" },
|
||||||
{ url = "https://github.com/paperless-ngx/builder/releases/download/psycopg-trixie-3.3.0/psycopg_c-3.3.0-cp312-cp312-linux_aarch64.whl", marker = "sys_platform == 'linux' and platform_machine == 'aarch64' and python_version == '3.12'" },
|
{ url = "https://github.com/paperless-ngx/builder/releases/download/psycopg-trixie-3.3.0/psycopg_c-3.3.0-cp312-cp312-linux_aarch64.whl", marker = "sys_platform == 'linux' and platform_machine == 'aarch64' and python_version == '3.12'" },
|
||||||
]
|
]
|
||||||
|
|
||||||
torch = [
|
torch = [
|
||||||
{ index = "pytorch-cpu" },
|
{ index = "pytorch-cpu" },
|
||||||
]
|
]
|
||||||
@@ -186,10 +178,10 @@ respect-gitignore = true
|
|||||||
# https://docs.astral.sh/ruff/settings/
|
# https://docs.astral.sh/ruff/settings/
|
||||||
fix = true
|
fix = true
|
||||||
show-fixes = true
|
show-fixes = true
|
||||||
|
|
||||||
output-format = "grouped"
|
output-format = "grouped"
|
||||||
|
[tool.ruff.lint]
|
||||||
# https://docs.astral.sh/ruff/rules/
|
# https://docs.astral.sh/ruff/rules/
|
||||||
lint.extend-select = [
|
extend-select = [
|
||||||
"COM", # https://docs.astral.sh/ruff/rules/#flake8-commas-com
|
"COM", # https://docs.astral.sh/ruff/rules/#flake8-commas-com
|
||||||
"DJ", # https://docs.astral.sh/ruff/rules/#flake8-django-dj
|
"DJ", # https://docs.astral.sh/ruff/rules/#flake8-django-dj
|
||||||
"EXE", # https://docs.astral.sh/ruff/rules/#flake8-executable-exe
|
"EXE", # https://docs.astral.sh/ruff/rules/#flake8-executable-exe
|
||||||
@@ -214,115 +206,52 @@ lint.extend-select = [
|
|||||||
"UP", # https://docs.astral.sh/ruff/rules/#pyupgrade-up
|
"UP", # https://docs.astral.sh/ruff/rules/#pyupgrade-up
|
||||||
"W", # https://docs.astral.sh/ruff/rules/#pycodestyle-e-w
|
"W", # https://docs.astral.sh/ruff/rules/#pycodestyle-e-w
|
||||||
]
|
]
|
||||||
lint.ignore = [
|
ignore = [
|
||||||
"DJ001",
|
"DJ001",
|
||||||
"PLC0415",
|
"PLC0415",
|
||||||
"RUF012",
|
"RUF012",
|
||||||
"SIM105",
|
"SIM105",
|
||||||
]
|
]
|
||||||
# Migrations
|
# Migrations
|
||||||
lint.per-file-ignores."*/migrations/*.py" = [
|
per-file-ignores."*/migrations/*.py" = [
|
||||||
"E501",
|
"E501",
|
||||||
"SIM",
|
"SIM",
|
||||||
"T201",
|
"T201",
|
||||||
]
|
]
|
||||||
# Testing
|
# Testing
|
||||||
lint.per-file-ignores."*/tests/*.py" = [
|
per-file-ignores."*/tests/*.py" = [
|
||||||
"E501",
|
"E501",
|
||||||
"SIM117",
|
"SIM117",
|
||||||
]
|
]
|
||||||
lint.per-file-ignores.".github/scripts/*.py" = [
|
per-file-ignores.".github/scripts/*.py" = [
|
||||||
"E501",
|
"E501",
|
||||||
"INP001",
|
"INP001",
|
||||||
"SIM117",
|
"SIM117",
|
||||||
]
|
]
|
||||||
# Docker specific
|
# Docker specific
|
||||||
lint.per-file-ignores."docker/rootfs/usr/local/bin/wait-for-redis.py" = [
|
per-file-ignores."docker/rootfs/usr/local/bin/wait-for-redis.py" = [
|
||||||
"INP001",
|
"INP001",
|
||||||
"T201",
|
"T201",
|
||||||
]
|
]
|
||||||
lint.per-file-ignores."docker/wait-for-redis.py" = [
|
per-file-ignores."docker/wait-for-redis.py" = [
|
||||||
"INP001",
|
"INP001",
|
||||||
"T201",
|
"T201",
|
||||||
]
|
]
|
||||||
lint.per-file-ignores."src/documents/models.py" = [
|
per-file-ignores."src/documents/models.py" = [
|
||||||
"SIM115",
|
"SIM115",
|
||||||
]
|
]
|
||||||
|
isort.force-single-line = true
|
||||||
lint.isort.force-single-line = true
|
|
||||||
|
|
||||||
[tool.codespell]
|
[tool.codespell]
|
||||||
write-changes = true
|
write-changes = true
|
||||||
ignore-words-list = "criterias,afterall,valeu,ureue,equest,ure,assertIn,Oktober,commitish"
|
ignore-words-list = "criterias,afterall,valeu,ureue,equest,ure,assertIn,Oktober,commitish"
|
||||||
skip = "src-ui/src/locale/*,src-ui/pnpm-lock.yaml,src-ui/e2e/*,src/paperless_mail/tests/samples/*,src/paperless/tests/samples/mail/*,src/documents/tests/samples/*,*.po,*.json"
|
skip = """\
|
||||||
|
src-ui/src/locale/*,src-ui/pnpm-lock.yaml,src-ui/e2e/*,src/paperless_mail/tests/samples/*,src/paperless/tests/samples\
|
||||||
|
/mail/*,src/documents/tests/samples/*,*.po,*.json\
|
||||||
|
"""
|
||||||
|
|
||||||
[tool.pytest]
|
[tool.pyproject-fmt]
|
||||||
minversion = "9.0"
|
table_format = "long"
|
||||||
pythonpath = [ "src" ]
|
|
||||||
|
|
||||||
strict_config = true
|
|
||||||
strict_markers = true
|
|
||||||
strict_parametrization_ids = true
|
|
||||||
strict_xfail = true
|
|
||||||
|
|
||||||
testpaths = [
|
|
||||||
"src/documents/tests/",
|
|
||||||
"src/paperless/tests/",
|
|
||||||
"src/paperless_mail/tests/",
|
|
||||||
"src/paperless_ai/tests",
|
|
||||||
]
|
|
||||||
|
|
||||||
addopts = [
|
|
||||||
"--pythonwarnings=all",
|
|
||||||
"--cov",
|
|
||||||
"--cov-report=html",
|
|
||||||
"--cov-report=xml",
|
|
||||||
"--numprocesses=auto",
|
|
||||||
"--maxprocesses=16",
|
|
||||||
"--dist=loadscope",
|
|
||||||
"--durations=50",
|
|
||||||
"--durations-min=0.5",
|
|
||||||
"--junitxml=junit.xml",
|
|
||||||
"-o",
|
|
||||||
"junit_family=legacy",
|
|
||||||
]
|
|
||||||
|
|
||||||
norecursedirs = [ "src/locale/", ".venv/", "src-ui/" ]
|
|
||||||
|
|
||||||
DJANGO_SETTINGS_MODULE = "paperless.settings"
|
|
||||||
|
|
||||||
markers = [
|
|
||||||
"live: Integration tests requiring external services (Gotenberg, Tika, nginx, etc)",
|
|
||||||
"nginx: Tests that make HTTP requests to the local nginx service",
|
|
||||||
"gotenberg: Tests requiring Gotenberg service",
|
|
||||||
"tika: Tests requiring Tika service",
|
|
||||||
"greenmail: Tests requiring Greenmail service",
|
|
||||||
"date_parsing: Tests which cover date parsing from content or filename",
|
|
||||||
"management: Tests which cover management commands/functionality",
|
|
||||||
]
|
|
||||||
|
|
||||||
[tool.pytest_env]
|
|
||||||
PAPERLESS_DISABLE_DBHANDLER = "true"
|
|
||||||
PAPERLESS_CACHE_BACKEND = "django.core.cache.backends.locmem.LocMemCache"
|
|
||||||
PAPERLESS_CHANNELS_BACKEND = "channels.layers.InMemoryChannelLayer"
|
|
||||||
|
|
||||||
[tool.coverage.report]
|
|
||||||
exclude_also = [
|
|
||||||
"if settings.AUDIT_LOG_ENABLED:",
|
|
||||||
"if AUDIT_LOG_ENABLED:",
|
|
||||||
"if TYPE_CHECKING:",
|
|
||||||
]
|
|
||||||
|
|
||||||
[tool.coverage.run]
|
|
||||||
source = [
|
|
||||||
"src/",
|
|
||||||
]
|
|
||||||
omit = [
|
|
||||||
"*/tests/*",
|
|
||||||
"manage.py",
|
|
||||||
"paperless/wsgi.py",
|
|
||||||
"paperless/auth.py",
|
|
||||||
]
|
|
||||||
|
|
||||||
[tool.mypy]
|
[tool.mypy]
|
||||||
mypy_path = "src"
|
mypy_path = "src"
|
||||||
@@ -345,6 +274,68 @@ python-platform = "linux"
|
|||||||
[tool.django-stubs]
|
[tool.django-stubs]
|
||||||
django_settings_module = "paperless.settings"
|
django_settings_module = "paperless.settings"
|
||||||
|
|
||||||
|
[tool.pytest]
|
||||||
|
minversion = "9.0"
|
||||||
|
pythonpath = [ "src" ]
|
||||||
|
strict_config = true
|
||||||
|
strict_markers = true
|
||||||
|
strict_parametrization_ids = true
|
||||||
|
strict_xfail = true
|
||||||
|
testpaths = [
|
||||||
|
"src/documents/tests/",
|
||||||
|
"src/paperless/tests/",
|
||||||
|
"src/paperless_mail/tests/",
|
||||||
|
"src/paperless_ai/tests",
|
||||||
|
]
|
||||||
|
addopts = [
|
||||||
|
"--pythonwarnings=all",
|
||||||
|
"--cov",
|
||||||
|
"--cov-report=html",
|
||||||
|
"--cov-report=xml",
|
||||||
|
"--numprocesses=auto",
|
||||||
|
"--maxprocesses=16",
|
||||||
|
"--dist=loadscope",
|
||||||
|
"--durations=50",
|
||||||
|
"--durations-min=0.5",
|
||||||
|
"--junitxml=junit.xml",
|
||||||
|
"-o",
|
||||||
|
"junit_family=legacy",
|
||||||
|
]
|
||||||
|
norecursedirs = [ "src/locale/", ".venv/", "src-ui/" ]
|
||||||
|
DJANGO_SETTINGS_MODULE = "paperless.settings"
|
||||||
|
markers = [
|
||||||
|
"live: Integration tests requiring external services (Gotenberg, Tika, nginx, etc)",
|
||||||
|
"nginx: Tests that make HTTP requests to the local nginx service",
|
||||||
|
"gotenberg: Tests requiring Gotenberg service",
|
||||||
|
"tika: Tests requiring Tika service",
|
||||||
|
"greenmail: Tests requiring Greenmail service",
|
||||||
|
"date_parsing: Tests which cover date parsing from content or filename",
|
||||||
|
"management: Tests which cover management commands/functionality",
|
||||||
|
"search: Tests for the Tantivy search backend",
|
||||||
|
]
|
||||||
|
|
||||||
|
[tool.pytest_env]
|
||||||
|
PAPERLESS_DISABLE_DBHANDLER = "true"
|
||||||
|
PAPERLESS_CACHE_BACKEND = "django.core.cache.backends.locmem.LocMemCache"
|
||||||
|
PAPERLESS_CHANNELS_BACKEND = "channels.layers.InMemoryChannelLayer"
|
||||||
|
|
||||||
|
[tool.coverage.report]
|
||||||
|
exclude_also = [
|
||||||
|
"if settings.AUDIT_LOG_ENABLED:",
|
||||||
|
"if AUDIT_LOG_ENABLED:",
|
||||||
|
"if TYPE_CHECKING:",
|
||||||
|
]
|
||||||
|
[tool.coverage.run]
|
||||||
|
source = [
|
||||||
|
"src/",
|
||||||
|
]
|
||||||
|
omit = [
|
||||||
|
"*/tests/*",
|
||||||
|
"manage.py",
|
||||||
|
"paperless/wsgi.py",
|
||||||
|
"paperless/auth.py",
|
||||||
|
]
|
||||||
|
|
||||||
[tool.mypy-baseline]
|
[tool.mypy-baseline]
|
||||||
baseline_path = ".mypy-baseline.txt"
|
baseline_path = ".mypy-baseline.txt"
|
||||||
sort_baseline = true
|
sort_baseline = true
|
||||||
|
|||||||
@@ -468,7 +468,7 @@
|
|||||||
"time": 0.951,
|
"time": 0.951,
|
||||||
"request": {
|
"request": {
|
||||||
"method": "GET",
|
"method": "GET",
|
||||||
"url": "http://localhost:8000/api/documents/?page=1&page_size=50&ordering=-created&truncate_content=true&tags__id__in=9",
|
"url": "http://localhost:8000/api/documents/?page=1&page_size=50&ordering=-created&truncate_content=true&include_selection_data=true&tags__id__in=9",
|
||||||
"httpVersion": "HTTP/1.1",
|
"httpVersion": "HTTP/1.1",
|
||||||
"cookies": [],
|
"cookies": [],
|
||||||
"headers": [
|
"headers": [
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
@@ -534,7 +534,7 @@
|
|||||||
"time": 0.653,
|
"time": 0.653,
|
||||||
"request": {
|
"request": {
|
||||||
"method": "GET",
|
"method": "GET",
|
||||||
"url": "http://localhost:8000/api/documents/?page=1&page_size=50&ordering=-created&truncate_content=true&tags__id__all=9",
|
"url": "http://localhost:8000/api/documents/?page=1&page_size=10&ordering=-created&truncate_content=true&include_selection_data=true&tags__id__all=9",
|
||||||
"httpVersion": "HTTP/1.1",
|
"httpVersion": "HTTP/1.1",
|
||||||
"cookies": [],
|
"cookies": [],
|
||||||
"headers": [
|
"headers": [
|
||||||
|
|||||||
@@ -883,7 +883,7 @@
|
|||||||
"time": 0.93,
|
"time": 0.93,
|
||||||
"request": {
|
"request": {
|
||||||
"method": "GET",
|
"method": "GET",
|
||||||
"url": "http://localhost:8000/api/documents/?page=1&page_size=50&ordering=-created&truncate_content=true&tags__id__all=4",
|
"url": "http://localhost:8000/api/documents/?page=1&page_size=50&ordering=-created&truncate_content=true&include_selection_data=true&tags__id__all=4",
|
||||||
"httpVersion": "HTTP/1.1",
|
"httpVersion": "HTTP/1.1",
|
||||||
"cookies": [],
|
"cookies": [],
|
||||||
"headers": [
|
"headers": [
|
||||||
@@ -961,7 +961,7 @@
|
|||||||
"time": -1,
|
"time": -1,
|
||||||
"request": {
|
"request": {
|
||||||
"method": "GET",
|
"method": "GET",
|
||||||
"url": "http://localhost:8000/api/documents/?page=1&page_size=50&ordering=-created&truncate_content=true&tags__id__all=4",
|
"url": "http://localhost:8000/api/documents/?page=1&page_size=50&ordering=-created&truncate_content=true&include_selection_data=true&tags__id__all=4",
|
||||||
"httpVersion": "HTTP/1.1",
|
"httpVersion": "HTTP/1.1",
|
||||||
"cookies": [],
|
"cookies": [],
|
||||||
"headers": [
|
"headers": [
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ test('basic filtering', async ({ page }) => {
|
|||||||
await expect(page).toHaveURL(/tags__id__all=9/)
|
await expect(page).toHaveURL(/tags__id__all=9/)
|
||||||
await expect(page.locator('pngx-document-list')).toHaveText(/8 documents/)
|
await expect(page.locator('pngx-document-list')).toHaveText(/8 documents/)
|
||||||
await page.getByRole('button', { name: 'Document type' }).click()
|
await page.getByRole('button', { name: 'Document type' }).click()
|
||||||
await page.getByRole('menuitem', { name: 'Invoice Test 3' }).click()
|
await page.getByRole('menuitem', { name: /^Invoice Test/ }).click()
|
||||||
await expect(page).toHaveURL(/document_type__id__in=1/)
|
await expect(page).toHaveURL(/document_type__id__in=1/)
|
||||||
await expect(page.locator('pngx-document-list')).toHaveText(/3 documents/)
|
await expect(page.locator('pngx-document-list')).toHaveText(/3 documents/)
|
||||||
await page.getByRole('button', { name: 'Reset filters' }).first().click()
|
await page.getByRole('button', { name: 'Reset filters' }).first().click()
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because it is too large
Load Diff
@@ -11,15 +11,15 @@
|
|||||||
},
|
},
|
||||||
"private": true,
|
"private": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@angular/cdk": "^21.2.2",
|
"@angular/cdk": "^21.2.4",
|
||||||
"@angular/common": "~21.2.4",
|
"@angular/common": "~21.2.6",
|
||||||
"@angular/compiler": "~21.2.4",
|
"@angular/compiler": "~21.2.6",
|
||||||
"@angular/core": "~21.2.4",
|
"@angular/core": "~21.2.6",
|
||||||
"@angular/forms": "~21.2.4",
|
"@angular/forms": "~21.2.6",
|
||||||
"@angular/localize": "~21.2.4",
|
"@angular/localize": "~21.2.6",
|
||||||
"@angular/platform-browser": "~21.2.4",
|
"@angular/platform-browser": "~21.2.6",
|
||||||
"@angular/platform-browser-dynamic": "~21.2.4",
|
"@angular/platform-browser-dynamic": "~21.2.6",
|
||||||
"@angular/router": "~21.2.4",
|
"@angular/router": "~21.2.6",
|
||||||
"@ng-bootstrap/ng-bootstrap": "^20.0.0",
|
"@ng-bootstrap/ng-bootstrap": "^20.0.0",
|
||||||
"@ng-select/ng-select": "^21.5.2",
|
"@ng-select/ng-select": "^21.5.2",
|
||||||
"@ngneat/dirty-check-forms": "^3.0.3",
|
"@ngneat/dirty-check-forms": "^3.0.3",
|
||||||
@@ -29,7 +29,7 @@
|
|||||||
"mime-names": "^1.0.0",
|
"mime-names": "^1.0.0",
|
||||||
"ngx-bootstrap-icons": "^1.9.3",
|
"ngx-bootstrap-icons": "^1.9.3",
|
||||||
"ngx-color": "^10.1.0",
|
"ngx-color": "^10.1.0",
|
||||||
"ngx-cookie-service": "^21.1.0",
|
"ngx-cookie-service": "^21.3.1",
|
||||||
"ngx-device-detector": "^11.0.0",
|
"ngx-device-detector": "^11.0.0",
|
||||||
"ngx-ui-tour-ng-bootstrap": "^18.0.0",
|
"ngx-ui-tour-ng-bootstrap": "^18.0.0",
|
||||||
"pdfjs-dist": "^5.4.624",
|
"pdfjs-dist": "^5.4.624",
|
||||||
@@ -42,24 +42,24 @@
|
|||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@angular-builders/custom-webpack": "^21.0.3",
|
"@angular-builders/custom-webpack": "^21.0.3",
|
||||||
"@angular-builders/jest": "^21.0.3",
|
"@angular-builders/jest": "^21.0.3",
|
||||||
"@angular-devkit/core": "^21.2.2",
|
"@angular-devkit/core": "^21.2.3",
|
||||||
"@angular-devkit/schematics": "^21.2.2",
|
"@angular-devkit/schematics": "^21.2.3",
|
||||||
"@angular-eslint/builder": "21.3.0",
|
"@angular-eslint/builder": "21.3.1",
|
||||||
"@angular-eslint/eslint-plugin": "21.3.0",
|
"@angular-eslint/eslint-plugin": "21.3.1",
|
||||||
"@angular-eslint/eslint-plugin-template": "21.3.0",
|
"@angular-eslint/eslint-plugin-template": "21.3.1",
|
||||||
"@angular-eslint/schematics": "21.3.0",
|
"@angular-eslint/schematics": "21.3.1",
|
||||||
"@angular-eslint/template-parser": "21.3.0",
|
"@angular-eslint/template-parser": "21.3.1",
|
||||||
"@angular/build": "^21.2.2",
|
"@angular/build": "^21.2.3",
|
||||||
"@angular/cli": "~21.2.2",
|
"@angular/cli": "~21.2.3",
|
||||||
"@angular/compiler-cli": "~21.2.4",
|
"@angular/compiler-cli": "~21.2.6",
|
||||||
"@codecov/webpack-plugin": "^1.9.1",
|
"@codecov/webpack-plugin": "^1.9.1",
|
||||||
"@playwright/test": "^1.58.2",
|
"@playwright/test": "^1.58.2",
|
||||||
"@types/jest": "^30.0.0",
|
"@types/jest": "^30.0.0",
|
||||||
"@types/node": "^25.4.0",
|
"@types/node": "^25.5.0",
|
||||||
"@typescript-eslint/eslint-plugin": "^8.57.0",
|
"@typescript-eslint/eslint-plugin": "^8.57.2",
|
||||||
"@typescript-eslint/parser": "^8.57.0",
|
"@typescript-eslint/parser": "^8.57.2",
|
||||||
"@typescript-eslint/utils": "^8.57.0",
|
"@typescript-eslint/utils": "^8.57.2",
|
||||||
"eslint": "^10.0.3",
|
"eslint": "^10.1.0",
|
||||||
"jest": "30.3.0",
|
"jest": "30.3.0",
|
||||||
"jest-environment-jsdom": "^30.3.0",
|
"jest-environment-jsdom": "^30.3.0",
|
||||||
"jest-junit": "^16.0.0",
|
"jest-junit": "^16.0.0",
|
||||||
|
|||||||
1723
src-ui/pnpm-lock.yaml
generated
1723
src-ui/pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@@ -20,9 +20,9 @@ import { Subject, filter, takeUntil } from 'rxjs'
|
|||||||
import { NEGATIVE_NULL_FILTER_VALUE } from 'src/app/data/filter-rule-type'
|
import { NEGATIVE_NULL_FILTER_VALUE } from 'src/app/data/filter-rule-type'
|
||||||
import { MatchingModel } from 'src/app/data/matching-model'
|
import { MatchingModel } from 'src/app/data/matching-model'
|
||||||
import { ObjectWithPermissions } from 'src/app/data/object-with-permissions'
|
import { ObjectWithPermissions } from 'src/app/data/object-with-permissions'
|
||||||
|
import { SelectionDataItem } from 'src/app/data/results'
|
||||||
import { FilterPipe } from 'src/app/pipes/filter.pipe'
|
import { FilterPipe } from 'src/app/pipes/filter.pipe'
|
||||||
import { HotKeyService } from 'src/app/services/hot-key.service'
|
import { HotKeyService } from 'src/app/services/hot-key.service'
|
||||||
import { SelectionDataItem } from 'src/app/services/rest/document.service'
|
|
||||||
import { pngxPopperOptions } from 'src/app/utils/popper-options'
|
import { pngxPopperOptions } from 'src/app/utils/popper-options'
|
||||||
import { LoadingComponentWithPermissions } from '../../loading-component/loading.component'
|
import { LoadingComponentWithPermissions } from '../../loading-component/loading.component'
|
||||||
import { ClearableBadgeComponent } from '../clearable-badge/clearable-badge.component'
|
import { ClearableBadgeComponent } from '../clearable-badge/clearable-badge.component'
|
||||||
|
|||||||
@@ -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(reprocessSpy).toHaveBeenCalledWith({ documents: [doc.id] })
|
||||||
expect(modalSpy).toHaveBeenCalled()
|
expect(modalSpy).toHaveBeenCalled()
|
||||||
expect(toastSpy).toHaveBeenCalled()
|
expect(toastSpy).toHaveBeenCalled()
|
||||||
expect(modalCloseSpy).toHaveBeenCalled()
|
expect(modalCloseSpy).toHaveBeenCalled()
|
||||||
|
|||||||
@@ -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: () => {
|
.reprocessDocuments({ documents: [this.document.id] })
|
||||||
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
|
||||||
})
|
)
|
||||||
|
},
|
||||||
|
})
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -92,7 +92,7 @@
|
|||||||
<button ngbDropdownItem (click)="rotateSelected()" [disabled]="!userOwnsAll && !userCanEditAll">
|
<button ngbDropdownItem (click)="rotateSelected()" [disabled]="!userOwnsAll && !userCanEditAll">
|
||||||
<i-bs name="arrow-clockwise" class="me-1"></i-bs><ng-container i18n>Rotate</ng-container>
|
<i-bs name="arrow-clockwise" class="me-1"></i-bs><ng-container i18n>Rotate</ng-container>
|
||||||
</button>
|
</button>
|
||||||
<button ngbDropdownItem (click)="mergeSelected()" [disabled]="!userCanAdd || list.selected.size < 2">
|
<button ngbDropdownItem (click)="mergeSelected()" [disabled]="!userCanAdd || list.allSelected || list.selectedCount < 2">
|
||||||
<i-bs name="journals" class="me-1"></i-bs><ng-container i18n>Merge</ng-container>
|
<i-bs name="journals" class="me-1"></i-bs><ng-container i18n>Merge</ng-container>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -103,13 +103,13 @@
|
|||||||
class="btn btn-sm btn-outline-primary"
|
class="btn btn-sm btn-outline-primary"
|
||||||
id="dropdownSend"
|
id="dropdownSend"
|
||||||
ngbDropdownToggle
|
ngbDropdownToggle
|
||||||
[disabled]="disabled || list.selected.size === 0"
|
[disabled]="disabled || !list.hasSelection || list.allSelected"
|
||||||
>
|
>
|
||||||
<i-bs name="send"></i-bs><div class="d-none d-sm-inline ms-1"><ng-container i18n>Send</ng-container>
|
<i-bs name="send"></i-bs><div class="d-none d-sm-inline ms-1"><ng-container i18n>Send</ng-container>
|
||||||
</div>
|
</div>
|
||||||
</button>
|
</button>
|
||||||
<div ngbDropdownMenu aria-labelledby="dropdownSend" class="shadow">
|
<div ngbDropdownMenu aria-labelledby="dropdownSend" class="shadow">
|
||||||
<button ngbDropdownItem (click)="createShareLinkBundle()">
|
<button ngbDropdownItem (click)="createShareLinkBundle()" [disabled]="list.allSelected">
|
||||||
<i-bs name="link" class="me-1"></i-bs><ng-container i18n>Create a share link bundle</ng-container>
|
<i-bs name="link" class="me-1"></i-bs><ng-container i18n>Create a share link bundle</ng-container>
|
||||||
</button>
|
</button>
|
||||||
<button ngbDropdownItem (click)="manageShareLinkBundles()">
|
<button ngbDropdownItem (click)="manageShareLinkBundles()">
|
||||||
@@ -117,7 +117,7 @@
|
|||||||
</button>
|
</button>
|
||||||
<div class="dropdown-divider"></div>
|
<div class="dropdown-divider"></div>
|
||||||
@if (emailEnabled) {
|
@if (emailEnabled) {
|
||||||
<button ngbDropdownItem (click)="emailSelected()">
|
<button ngbDropdownItem (click)="emailSelected()" [disabled]="list.allSelected">
|
||||||
<i-bs name="envelope" class="me-1"></i-bs><ng-container i18n>Email</ng-container>
|
<i-bs name="envelope" class="me-1"></i-bs><ng-container i18n>Email</ng-container>
|
||||||
</button>
|
</button>
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import { of, throwError } from 'rxjs'
|
|||||||
import { Correspondent } from 'src/app/data/correspondent'
|
import { Correspondent } from 'src/app/data/correspondent'
|
||||||
import { CustomField, CustomFieldDataType } from 'src/app/data/custom-field'
|
import { CustomField, CustomFieldDataType } from 'src/app/data/custom-field'
|
||||||
import { DocumentType } from 'src/app/data/document-type'
|
import { DocumentType } from 'src/app/data/document-type'
|
||||||
|
import { FILTER_TITLE } from 'src/app/data/filter-rule-type'
|
||||||
import { Results } from 'src/app/data/results'
|
import { Results } from 'src/app/data/results'
|
||||||
import { StoragePath } from 'src/app/data/storage-path'
|
import { StoragePath } from 'src/app/data/storage-path'
|
||||||
import { Tag } from 'src/app/data/tag'
|
import { Tag } from 'src/app/data/tag'
|
||||||
@@ -273,6 +274,92 @@ describe('BulkEditorComponent', () => {
|
|||||||
expect(component.customFieldsSelectionModel.selectionSize()).toEqual(1)
|
expect(component.customFieldsSelectionModel.selectionSize()).toEqual(1)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('should apply list selection data to tags menu when all filtered documents are selected', () => {
|
||||||
|
jest.spyOn(permissionsService, 'currentUserCan').mockReturnValue(true)
|
||||||
|
fixture.detectChanges()
|
||||||
|
jest
|
||||||
|
.spyOn(documentListViewService, 'allSelected', 'get')
|
||||||
|
.mockReturnValue(true)
|
||||||
|
jest
|
||||||
|
.spyOn(documentListViewService, 'selectedCount', 'get')
|
||||||
|
.mockReturnValue(3)
|
||||||
|
documentListViewService.selectionData = selectionData
|
||||||
|
const getSelectionDataSpy = jest.spyOn(documentService, 'getSelectionData')
|
||||||
|
|
||||||
|
component.openTagsDropdown()
|
||||||
|
|
||||||
|
expect(getSelectionDataSpy).not.toHaveBeenCalled()
|
||||||
|
expect(component.tagSelectionModel.selectionSize()).toEqual(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should apply list selection data to document types menu when all filtered documents are selected', () => {
|
||||||
|
jest.spyOn(permissionsService, 'currentUserCan').mockReturnValue(true)
|
||||||
|
fixture.detectChanges()
|
||||||
|
jest
|
||||||
|
.spyOn(documentListViewService, 'allSelected', 'get')
|
||||||
|
.mockReturnValue(true)
|
||||||
|
documentListViewService.selectionData = selectionData
|
||||||
|
const getSelectionDataSpy = jest.spyOn(documentService, 'getSelectionData')
|
||||||
|
|
||||||
|
component.openDocumentTypeDropdown()
|
||||||
|
|
||||||
|
expect(getSelectionDataSpy).not.toHaveBeenCalled()
|
||||||
|
expect(component.documentTypeDocumentCounts).toEqual(
|
||||||
|
selectionData.selected_document_types
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should apply list selection data to correspondents menu when all filtered documents are selected', () => {
|
||||||
|
jest.spyOn(permissionsService, 'currentUserCan').mockReturnValue(true)
|
||||||
|
fixture.detectChanges()
|
||||||
|
jest
|
||||||
|
.spyOn(documentListViewService, 'allSelected', 'get')
|
||||||
|
.mockReturnValue(true)
|
||||||
|
documentListViewService.selectionData = selectionData
|
||||||
|
const getSelectionDataSpy = jest.spyOn(documentService, 'getSelectionData')
|
||||||
|
|
||||||
|
component.openCorrespondentDropdown()
|
||||||
|
|
||||||
|
expect(getSelectionDataSpy).not.toHaveBeenCalled()
|
||||||
|
expect(component.correspondentDocumentCounts).toEqual(
|
||||||
|
selectionData.selected_correspondents
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should apply list selection data to storage paths menu when all filtered documents are selected', () => {
|
||||||
|
jest.spyOn(permissionsService, 'currentUserCan').mockReturnValue(true)
|
||||||
|
fixture.detectChanges()
|
||||||
|
jest
|
||||||
|
.spyOn(documentListViewService, 'allSelected', 'get')
|
||||||
|
.mockReturnValue(true)
|
||||||
|
documentListViewService.selectionData = selectionData
|
||||||
|
const getSelectionDataSpy = jest.spyOn(documentService, 'getSelectionData')
|
||||||
|
|
||||||
|
component.openStoragePathDropdown()
|
||||||
|
|
||||||
|
expect(getSelectionDataSpy).not.toHaveBeenCalled()
|
||||||
|
expect(component.storagePathDocumentCounts).toEqual(
|
||||||
|
selectionData.selected_storage_paths
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should apply list selection data to custom fields menu when all filtered documents are selected', () => {
|
||||||
|
jest.spyOn(permissionsService, 'currentUserCan').mockReturnValue(true)
|
||||||
|
fixture.detectChanges()
|
||||||
|
jest
|
||||||
|
.spyOn(documentListViewService, 'allSelected', 'get')
|
||||||
|
.mockReturnValue(true)
|
||||||
|
documentListViewService.selectionData = selectionData
|
||||||
|
const getSelectionDataSpy = jest.spyOn(documentService, 'getSelectionData')
|
||||||
|
|
||||||
|
component.openCustomFieldsDropdown()
|
||||||
|
|
||||||
|
expect(getSelectionDataSpy).not.toHaveBeenCalled()
|
||||||
|
expect(component.customFieldDocumentCounts).toEqual(
|
||||||
|
selectionData.selected_custom_fields
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
it('should execute modify tags bulk operation', () => {
|
it('should execute modify tags bulk operation', () => {
|
||||||
jest.spyOn(permissionsService, 'currentUserCan').mockReturnValue(true)
|
jest.spyOn(permissionsService, 'currentUserCan').mockReturnValue(true)
|
||||||
jest
|
jest
|
||||||
@@ -300,13 +387,56 @@ describe('BulkEditorComponent', () => {
|
|||||||
parameters: { add_tags: [101], remove_tags: [] },
|
parameters: { add_tags: [101], remove_tags: [] },
|
||||||
})
|
})
|
||||||
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&include_selection_data=true`
|
||||||
) // list reload
|
) // list reload
|
||||||
httpTestingController.match(
|
httpTestingController.match(
|
||||||
`${environment.apiBaseUrl}documents/?page=1&page_size=100000&fields=id`
|
`${environment.apiBaseUrl}documents/?page=1&page_size=100000&fields=id`
|
||||||
) // listAllFilteredIds
|
) // listAllFilteredIds
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('should execute modify tags bulk operation for all filtered documents', () => {
|
||||||
|
jest.spyOn(permissionsService, 'currentUserCan').mockReturnValue(true)
|
||||||
|
jest
|
||||||
|
.spyOn(documentListViewService, 'documents', 'get')
|
||||||
|
.mockReturnValue([{ id: 3 }, { id: 4 }])
|
||||||
|
jest
|
||||||
|
.spyOn(documentListViewService, 'selected', 'get')
|
||||||
|
.mockReturnValue(new Set([3, 4]))
|
||||||
|
jest
|
||||||
|
.spyOn(documentListViewService, 'allSelected', 'get')
|
||||||
|
.mockReturnValue(true)
|
||||||
|
jest
|
||||||
|
.spyOn(documentListViewService, 'filterRules', 'get')
|
||||||
|
.mockReturnValue([{ rule_type: FILTER_TITLE, value: 'apple' }])
|
||||||
|
jest
|
||||||
|
.spyOn(documentListViewService, 'selectedCount', 'get')
|
||||||
|
.mockReturnValue(25)
|
||||||
|
jest
|
||||||
|
.spyOn(permissionsService, 'currentUserHasObjectPermissions')
|
||||||
|
.mockReturnValue(true)
|
||||||
|
component.showConfirmationDialogs = false
|
||||||
|
fixture.detectChanges()
|
||||||
|
|
||||||
|
component.setTags({
|
||||||
|
itemsToAdd: [{ id: 101 }],
|
||||||
|
itemsToRemove: [],
|
||||||
|
})
|
||||||
|
|
||||||
|
const req = httpTestingController.expectOne(
|
||||||
|
`${environment.apiBaseUrl}documents/bulk_edit/`
|
||||||
|
)
|
||||||
|
req.flush(true)
|
||||||
|
expect(req.request.body).toEqual({
|
||||||
|
all: true,
|
||||||
|
filters: { title__icontains: 'apple' },
|
||||||
|
method: 'modify_tags',
|
||||||
|
parameters: { add_tags: [101], remove_tags: [] },
|
||||||
|
})
|
||||||
|
httpTestingController.match(
|
||||||
|
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true&include_selection_data=true`
|
||||||
|
) // list reload
|
||||||
|
})
|
||||||
|
|
||||||
it('should execute modify tags bulk operation with confirmation dialog if enabled', () => {
|
it('should execute modify tags bulk operation with confirmation dialog if enabled', () => {
|
||||||
let modal: NgbModalRef
|
let modal: NgbModalRef
|
||||||
modalService.activeInstances.subscribe((m) => (modal = m[0]))
|
modalService.activeInstances.subscribe((m) => (modal = m[0]))
|
||||||
@@ -332,7 +462,7 @@ describe('BulkEditorComponent', () => {
|
|||||||
.expectOne(`${environment.apiBaseUrl}documents/bulk_edit/`)
|
.expectOne(`${environment.apiBaseUrl}documents/bulk_edit/`)
|
||||||
.flush(true)
|
.flush(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&include_selection_data=true`
|
||||||
) // list reload
|
) // list reload
|
||||||
httpTestingController.match(
|
httpTestingController.match(
|
||||||
`${environment.apiBaseUrl}documents/?page=1&page_size=100000&fields=id`
|
`${environment.apiBaseUrl}documents/?page=1&page_size=100000&fields=id`
|
||||||
@@ -423,7 +553,7 @@ describe('BulkEditorComponent', () => {
|
|||||||
parameters: { correspondent: 101 },
|
parameters: { correspondent: 101 },
|
||||||
})
|
})
|
||||||
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&include_selection_data=true`
|
||||||
) // list reload
|
) // list reload
|
||||||
httpTestingController.match(
|
httpTestingController.match(
|
||||||
`${environment.apiBaseUrl}documents/?page=1&page_size=100000&fields=id`
|
`${environment.apiBaseUrl}documents/?page=1&page_size=100000&fields=id`
|
||||||
@@ -455,7 +585,7 @@ describe('BulkEditorComponent', () => {
|
|||||||
.expectOne(`${environment.apiBaseUrl}documents/bulk_edit/`)
|
.expectOne(`${environment.apiBaseUrl}documents/bulk_edit/`)
|
||||||
.flush(true)
|
.flush(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&include_selection_data=true`
|
||||||
) // list reload
|
) // list reload
|
||||||
httpTestingController.match(
|
httpTestingController.match(
|
||||||
`${environment.apiBaseUrl}documents/?page=1&page_size=100000&fields=id`
|
`${environment.apiBaseUrl}documents/?page=1&page_size=100000&fields=id`
|
||||||
@@ -521,7 +651,7 @@ describe('BulkEditorComponent', () => {
|
|||||||
parameters: { document_type: 101 },
|
parameters: { document_type: 101 },
|
||||||
})
|
})
|
||||||
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&include_selection_data=true`
|
||||||
) // list reload
|
) // list reload
|
||||||
httpTestingController.match(
|
httpTestingController.match(
|
||||||
`${environment.apiBaseUrl}documents/?page=1&page_size=100000&fields=id`
|
`${environment.apiBaseUrl}documents/?page=1&page_size=100000&fields=id`
|
||||||
@@ -553,7 +683,7 @@ describe('BulkEditorComponent', () => {
|
|||||||
.expectOne(`${environment.apiBaseUrl}documents/bulk_edit/`)
|
.expectOne(`${environment.apiBaseUrl}documents/bulk_edit/`)
|
||||||
.flush(true)
|
.flush(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&include_selection_data=true`
|
||||||
) // list reload
|
) // list reload
|
||||||
httpTestingController.match(
|
httpTestingController.match(
|
||||||
`${environment.apiBaseUrl}documents/?page=1&page_size=100000&fields=id`
|
`${environment.apiBaseUrl}documents/?page=1&page_size=100000&fields=id`
|
||||||
@@ -619,7 +749,7 @@ describe('BulkEditorComponent', () => {
|
|||||||
parameters: { storage_path: 101 },
|
parameters: { storage_path: 101 },
|
||||||
})
|
})
|
||||||
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&include_selection_data=true`
|
||||||
) // list reload
|
) // list reload
|
||||||
httpTestingController.match(
|
httpTestingController.match(
|
||||||
`${environment.apiBaseUrl}documents/?page=1&page_size=100000&fields=id`
|
`${environment.apiBaseUrl}documents/?page=1&page_size=100000&fields=id`
|
||||||
@@ -651,7 +781,7 @@ describe('BulkEditorComponent', () => {
|
|||||||
.expectOne(`${environment.apiBaseUrl}documents/bulk_edit/`)
|
.expectOne(`${environment.apiBaseUrl}documents/bulk_edit/`)
|
||||||
.flush(true)
|
.flush(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&include_selection_data=true`
|
||||||
) // list reload
|
) // list reload
|
||||||
httpTestingController.match(
|
httpTestingController.match(
|
||||||
`${environment.apiBaseUrl}documents/?page=1&page_size=100000&fields=id`
|
`${environment.apiBaseUrl}documents/?page=1&page_size=100000&fields=id`
|
||||||
@@ -717,7 +847,7 @@ describe('BulkEditorComponent', () => {
|
|||||||
parameters: { add_custom_fields: [101], remove_custom_fields: [102] },
|
parameters: { add_custom_fields: [101], remove_custom_fields: [102] },
|
||||||
})
|
})
|
||||||
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&include_selection_data=true`
|
||||||
) // list reload
|
) // list reload
|
||||||
httpTestingController.match(
|
httpTestingController.match(
|
||||||
`${environment.apiBaseUrl}documents/?page=1&page_size=100000&fields=id`
|
`${environment.apiBaseUrl}documents/?page=1&page_size=100000&fields=id`
|
||||||
@@ -749,7 +879,7 @@ describe('BulkEditorComponent', () => {
|
|||||||
.expectOne(`${environment.apiBaseUrl}documents/bulk_edit/`)
|
.expectOne(`${environment.apiBaseUrl}documents/bulk_edit/`)
|
||||||
.flush(true)
|
.flush(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&include_selection_data=true`
|
||||||
) // list reload
|
) // list reload
|
||||||
httpTestingController.match(
|
httpTestingController.match(
|
||||||
`${environment.apiBaseUrl}documents/?page=1&page_size=100000&fields=id`
|
`${environment.apiBaseUrl}documents/?page=1&page_size=100000&fields=id`
|
||||||
@@ -858,7 +988,7 @@ describe('BulkEditorComponent', () => {
|
|||||||
documents: [3, 4],
|
documents: [3, 4],
|
||||||
})
|
})
|
||||||
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&include_selection_data=true`
|
||||||
) // list reload
|
) // list reload
|
||||||
httpTestingController.match(
|
httpTestingController.match(
|
||||||
`${environment.apiBaseUrl}documents/?page=1&page_size=100000&fields=id`
|
`${environment.apiBaseUrl}documents/?page=1&page_size=100000&fields=id`
|
||||||
@@ -951,7 +1081,7 @@ describe('BulkEditorComponent', () => {
|
|||||||
documents: [3, 4],
|
documents: [3, 4],
|
||||||
})
|
})
|
||||||
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&include_selection_data=true`
|
||||||
) // list reload
|
) // list reload
|
||||||
httpTestingController.match(
|
httpTestingController.match(
|
||||||
`${environment.apiBaseUrl}documents/?page=1&page_size=100000&fields=id`
|
`${environment.apiBaseUrl}documents/?page=1&page_size=100000&fields=id`
|
||||||
@@ -986,7 +1116,7 @@ describe('BulkEditorComponent', () => {
|
|||||||
source_mode: 'latest_version',
|
source_mode: 'latest_version',
|
||||||
})
|
})
|
||||||
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&include_selection_data=true`
|
||||||
) // list reload
|
) // list reload
|
||||||
httpTestingController.match(
|
httpTestingController.match(
|
||||||
`${environment.apiBaseUrl}documents/?page=1&page_size=100000&fields=id`
|
`${environment.apiBaseUrl}documents/?page=1&page_size=100000&fields=id`
|
||||||
@@ -1027,7 +1157,7 @@ describe('BulkEditorComponent', () => {
|
|||||||
metadata_document_id: 3,
|
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&include_selection_data=true`
|
||||||
) // list reload
|
) // list reload
|
||||||
httpTestingController.match(
|
httpTestingController.match(
|
||||||
`${environment.apiBaseUrl}documents/?page=1&page_size=100000&fields=id`
|
`${environment.apiBaseUrl}documents/?page=1&page_size=100000&fields=id`
|
||||||
@@ -1046,7 +1176,7 @@ describe('BulkEditorComponent', () => {
|
|||||||
delete_originals: true,
|
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&include_selection_data=true`
|
||||||
) // list reload
|
) // list reload
|
||||||
httpTestingController.match(
|
httpTestingController.match(
|
||||||
`${environment.apiBaseUrl}documents/?page=1&page_size=100000&fields=id`
|
`${environment.apiBaseUrl}documents/?page=1&page_size=100000&fields=id`
|
||||||
@@ -1067,7 +1197,7 @@ describe('BulkEditorComponent', () => {
|
|||||||
archive_fallback: true,
|
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&include_selection_data=true`
|
||||||
) // list reload
|
) // list reload
|
||||||
httpTestingController.match(
|
httpTestingController.match(
|
||||||
`${environment.apiBaseUrl}documents/?page=1&page_size=100000&fields=id`
|
`${environment.apiBaseUrl}documents/?page=1&page_size=100000&fields=id`
|
||||||
@@ -1089,22 +1219,39 @@ describe('BulkEditorComponent', () => {
|
|||||||
component.downloadForm.get('downloadFileTypeArchive').patchValue(true)
|
component.downloadForm.get('downloadFileTypeArchive').patchValue(true)
|
||||||
fixture.detectChanges()
|
fixture.detectChanges()
|
||||||
let downloadSpy = jest.spyOn(documentService, 'bulkDownload')
|
let downloadSpy = jest.spyOn(documentService, 'bulkDownload')
|
||||||
|
downloadSpy.mockReturnValue(of(new Blob()))
|
||||||
//archive
|
//archive
|
||||||
component.downloadSelected()
|
component.downloadSelected()
|
||||||
expect(downloadSpy).toHaveBeenCalledWith([3, 4], 'archive', false)
|
expect(downloadSpy).toHaveBeenCalledWith(
|
||||||
|
{ documents: [3, 4] },
|
||||||
|
'archive',
|
||||||
|
false
|
||||||
|
)
|
||||||
//originals
|
//originals
|
||||||
component.downloadForm.get('downloadFileTypeArchive').patchValue(false)
|
component.downloadForm.get('downloadFileTypeArchive').patchValue(false)
|
||||||
component.downloadForm.get('downloadFileTypeOriginals').patchValue(true)
|
component.downloadForm.get('downloadFileTypeOriginals').patchValue(true)
|
||||||
component.downloadSelected()
|
component.downloadSelected()
|
||||||
expect(downloadSpy).toHaveBeenCalledWith([3, 4], 'originals', false)
|
expect(downloadSpy).toHaveBeenCalledWith(
|
||||||
|
{ documents: [3, 4] },
|
||||||
|
'originals',
|
||||||
|
false
|
||||||
|
)
|
||||||
//both
|
//both
|
||||||
component.downloadForm.get('downloadFileTypeArchive').patchValue(true)
|
component.downloadForm.get('downloadFileTypeArchive').patchValue(true)
|
||||||
component.downloadSelected()
|
component.downloadSelected()
|
||||||
expect(downloadSpy).toHaveBeenCalledWith([3, 4], 'both', false)
|
expect(downloadSpy).toHaveBeenCalledWith(
|
||||||
|
{ documents: [3, 4] },
|
||||||
|
'both',
|
||||||
|
false
|
||||||
|
)
|
||||||
//formatting
|
//formatting
|
||||||
component.downloadForm.get('downloadUseFormatting').patchValue(true)
|
component.downloadForm.get('downloadUseFormatting').patchValue(true)
|
||||||
component.downloadSelected()
|
component.downloadSelected()
|
||||||
expect(downloadSpy).toHaveBeenCalledWith([3, 4], 'both', true)
|
expect(downloadSpy).toHaveBeenCalledWith(
|
||||||
|
{ documents: [3, 4] },
|
||||||
|
'both',
|
||||||
|
true
|
||||||
|
)
|
||||||
|
|
||||||
httpTestingController.match(
|
httpTestingController.match(
|
||||||
`${environment.apiBaseUrl}documents/bulk_download/`
|
`${environment.apiBaseUrl}documents/bulk_download/`
|
||||||
@@ -1153,7 +1300,7 @@ describe('BulkEditorComponent', () => {
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
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&include_selection_data=true`
|
||||||
) // list reload
|
) // list reload
|
||||||
httpTestingController.match(
|
httpTestingController.match(
|
||||||
`${environment.apiBaseUrl}documents/?page=1&page_size=100000&fields=id`
|
`${environment.apiBaseUrl}documents/?page=1&page_size=100000&fields=id`
|
||||||
@@ -1450,6 +1597,7 @@ describe('BulkEditorComponent', () => {
|
|||||||
|
|
||||||
expect(modal.componentInstance.customFields.length).toEqual(2)
|
expect(modal.componentInstance.customFields.length).toEqual(2)
|
||||||
expect(modal.componentInstance.fieldsToAddIds).toEqual([1, 2])
|
expect(modal.componentInstance.fieldsToAddIds).toEqual([1, 2])
|
||||||
|
expect(modal.componentInstance.selection).toEqual({ documents: [3, 4] })
|
||||||
expect(modal.componentInstance.documents).toEqual([3, 4])
|
expect(modal.componentInstance.documents).toEqual([3, 4])
|
||||||
|
|
||||||
modal.componentInstance.failed.emit()
|
modal.componentInstance.failed.emit()
|
||||||
@@ -1460,7 +1608,7 @@ describe('BulkEditorComponent', () => {
|
|||||||
expect(toastServiceShowInfoSpy).toHaveBeenCalled()
|
expect(toastServiceShowInfoSpy).toHaveBeenCalled()
|
||||||
expect(listReloadSpy).toHaveBeenCalled()
|
expect(listReloadSpy).toHaveBeenCalled()
|
||||||
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&include_selection_data=true`
|
||||||
) // list reload
|
) // list reload
|
||||||
httpTestingController.match(
|
httpTestingController.match(
|
||||||
`${environment.apiBaseUrl}documents/?page=1&page_size=100000&fields=id`
|
`${environment.apiBaseUrl}documents/?page=1&page_size=100000&fields=id`
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import { first, map, Observable, 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'
|
||||||
|
import { SelectionDataItem } from 'src/app/data/results'
|
||||||
import { SETTINGS_KEYS } from 'src/app/data/ui-settings'
|
import { SETTINGS_KEYS } from 'src/app/data/ui-settings'
|
||||||
import { IfPermissionsDirective } from 'src/app/directives/if-permissions.directive'
|
import { IfPermissionsDirective } from 'src/app/directives/if-permissions.directive'
|
||||||
import { DocumentListViewService } from 'src/app/services/document-list-view.service'
|
import { DocumentListViewService } from 'src/app/services/document-list-view.service'
|
||||||
@@ -30,9 +31,9 @@ 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,
|
DocumentBulkEditMethod,
|
||||||
|
DocumentSelectionQuery,
|
||||||
DocumentService,
|
DocumentService,
|
||||||
MergeDocumentsRequest,
|
MergeDocumentsRequest,
|
||||||
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'
|
||||||
import { ShareLinkBundleService } from 'src/app/services/rest/share-link-bundle.service'
|
import { ShareLinkBundleService } from 'src/app/services/rest/share-link-bundle.service'
|
||||||
@@ -41,6 +42,7 @@ import { TagService } from 'src/app/services/rest/tag.service'
|
|||||||
import { SettingsService } from 'src/app/services/settings.service'
|
import { SettingsService } from 'src/app/services/settings.service'
|
||||||
import { ToastService } from 'src/app/services/toast.service'
|
import { ToastService } from 'src/app/services/toast.service'
|
||||||
import { flattenTags } from 'src/app/utils/flatten-tags'
|
import { flattenTags } from 'src/app/utils/flatten-tags'
|
||||||
|
import { queryParamsFromFilterRules } from 'src/app/utils/query-params'
|
||||||
import { MergeConfirmDialogComponent } from '../../common/confirm-dialog/merge-confirm-dialog/merge-confirm-dialog.component'
|
import { MergeConfirmDialogComponent } from '../../common/confirm-dialog/merge-confirm-dialog/merge-confirm-dialog.component'
|
||||||
import { RotateConfirmDialogComponent } from '../../common/confirm-dialog/rotate-confirm-dialog/rotate-confirm-dialog.component'
|
import { RotateConfirmDialogComponent } from '../../common/confirm-dialog/rotate-confirm-dialog/rotate-confirm-dialog.component'
|
||||||
import { CorrespondentEditDialogComponent } from '../../common/edit-dialog/correspondent-edit-dialog/correspondent-edit-dialog.component'
|
import { CorrespondentEditDialogComponent } from '../../common/edit-dialog/correspondent-edit-dialog/correspondent-edit-dialog.component'
|
||||||
@@ -261,17 +263,13 @@ export class BulkEditorComponent
|
|||||||
modal: NgbModalRef,
|
modal: NgbModalRef,
|
||||||
method: DocumentBulkEditMethod,
|
method: DocumentBulkEditMethod,
|
||||||
args: any,
|
args: any,
|
||||||
overrideDocumentIDs?: number[]
|
overrideSelection?: DocumentSelectionQuery
|
||||||
) {
|
) {
|
||||||
if (modal) {
|
if (modal) {
|
||||||
modal.componentInstance.buttonsEnabled = false
|
modal.componentInstance.buttonsEnabled = false
|
||||||
}
|
}
|
||||||
this.documentService
|
this.documentService
|
||||||
.bulkEdit(
|
.bulkEdit(overrideSelection ?? this.getSelectionQuery(), method, args)
|
||||||
overrideDocumentIDs ?? Array.from(this.list.selected),
|
|
||||||
method,
|
|
||||||
args
|
|
||||||
)
|
|
||||||
.pipe(first())
|
.pipe(first())
|
||||||
.subscribe({
|
.subscribe({
|
||||||
next: () => this.handleOperationSuccess(modal),
|
next: () => this.handleOperationSuccess(modal),
|
||||||
@@ -329,7 +327,7 @@ export class BulkEditorComponent
|
|||||||
) {
|
) {
|
||||||
let selectionData = new Map<number, ToggleableItemState>()
|
let selectionData = new Map<number, ToggleableItemState>()
|
||||||
items.forEach((i) => {
|
items.forEach((i) => {
|
||||||
if (i.document_count == this.list.selected.size) {
|
if (i.document_count == this.list.selectedCount) {
|
||||||
selectionData.set(i.id, ToggleableItemState.Selected)
|
selectionData.set(i.id, ToggleableItemState.Selected)
|
||||||
} else if (i.document_count > 0) {
|
} else if (i.document_count > 0) {
|
||||||
selectionData.set(i.id, ToggleableItemState.PartiallySelected)
|
selectionData.set(i.id, ToggleableItemState.PartiallySelected)
|
||||||
@@ -338,7 +336,31 @@ export class BulkEditorComponent
|
|||||||
selectionModel.init(selectionData)
|
selectionModel.init(selectionData)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private getSelectionQuery(): DocumentSelectionQuery {
|
||||||
|
if (this.list.allSelected) {
|
||||||
|
return {
|
||||||
|
all: true,
|
||||||
|
filters: queryParamsFromFilterRules(this.list.filterRules),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
documents: Array.from(this.list.selected),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private getSelectionSize(): number {
|
||||||
|
return this.list.selectedCount
|
||||||
|
}
|
||||||
|
|
||||||
openTagsDropdown() {
|
openTagsDropdown() {
|
||||||
|
if (this.list.allSelected) {
|
||||||
|
const selectionData = this.list.selectionData
|
||||||
|
this.tagDocumentCounts = selectionData?.selected_tags ?? []
|
||||||
|
this.applySelectionData(this.tagDocumentCounts, this.tagSelectionModel)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
this.documentService
|
this.documentService
|
||||||
.getSelectionData(Array.from(this.list.selected))
|
.getSelectionData(Array.from(this.list.selected))
|
||||||
.pipe(first())
|
.pipe(first())
|
||||||
@@ -349,6 +371,17 @@ export class BulkEditorComponent
|
|||||||
}
|
}
|
||||||
|
|
||||||
openDocumentTypeDropdown() {
|
openDocumentTypeDropdown() {
|
||||||
|
if (this.list.allSelected) {
|
||||||
|
const selectionData = this.list.selectionData
|
||||||
|
this.documentTypeDocumentCounts =
|
||||||
|
selectionData?.selected_document_types ?? []
|
||||||
|
this.applySelectionData(
|
||||||
|
this.documentTypeDocumentCounts,
|
||||||
|
this.documentTypeSelectionModel
|
||||||
|
)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
this.documentService
|
this.documentService
|
||||||
.getSelectionData(Array.from(this.list.selected))
|
.getSelectionData(Array.from(this.list.selected))
|
||||||
.pipe(first())
|
.pipe(first())
|
||||||
@@ -362,6 +395,17 @@ export class BulkEditorComponent
|
|||||||
}
|
}
|
||||||
|
|
||||||
openCorrespondentDropdown() {
|
openCorrespondentDropdown() {
|
||||||
|
if (this.list.allSelected) {
|
||||||
|
const selectionData = this.list.selectionData
|
||||||
|
this.correspondentDocumentCounts =
|
||||||
|
selectionData?.selected_correspondents ?? []
|
||||||
|
this.applySelectionData(
|
||||||
|
this.correspondentDocumentCounts,
|
||||||
|
this.correspondentSelectionModel
|
||||||
|
)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
this.documentService
|
this.documentService
|
||||||
.getSelectionData(Array.from(this.list.selected))
|
.getSelectionData(Array.from(this.list.selected))
|
||||||
.pipe(first())
|
.pipe(first())
|
||||||
@@ -375,6 +419,17 @@ export class BulkEditorComponent
|
|||||||
}
|
}
|
||||||
|
|
||||||
openStoragePathDropdown() {
|
openStoragePathDropdown() {
|
||||||
|
if (this.list.allSelected) {
|
||||||
|
const selectionData = this.list.selectionData
|
||||||
|
this.storagePathDocumentCounts =
|
||||||
|
selectionData?.selected_storage_paths ?? []
|
||||||
|
this.applySelectionData(
|
||||||
|
this.storagePathDocumentCounts,
|
||||||
|
this.storagePathsSelectionModel
|
||||||
|
)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
this.documentService
|
this.documentService
|
||||||
.getSelectionData(Array.from(this.list.selected))
|
.getSelectionData(Array.from(this.list.selected))
|
||||||
.pipe(first())
|
.pipe(first())
|
||||||
@@ -388,6 +443,17 @@ export class BulkEditorComponent
|
|||||||
}
|
}
|
||||||
|
|
||||||
openCustomFieldsDropdown() {
|
openCustomFieldsDropdown() {
|
||||||
|
if (this.list.allSelected) {
|
||||||
|
const selectionData = this.list.selectionData
|
||||||
|
this.customFieldDocumentCounts =
|
||||||
|
selectionData?.selected_custom_fields ?? []
|
||||||
|
this.applySelectionData(
|
||||||
|
this.customFieldDocumentCounts,
|
||||||
|
this.customFieldsSelectionModel
|
||||||
|
)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
this.documentService
|
this.documentService
|
||||||
.getSelectionData(Array.from(this.list.selected))
|
.getSelectionData(Array.from(this.list.selected))
|
||||||
.pipe(first())
|
.pipe(first())
|
||||||
@@ -437,33 +503,33 @@ export class BulkEditorComponent
|
|||||||
changedTags.itemsToRemove.length == 0
|
changedTags.itemsToRemove.length == 0
|
||||||
) {
|
) {
|
||||||
let tag = changedTags.itemsToAdd[0]
|
let tag = changedTags.itemsToAdd[0]
|
||||||
modal.componentInstance.message = $localize`This operation will add the tag "${tag.name}" to ${this.list.selected.size} selected document(s).`
|
modal.componentInstance.message = $localize`This operation will add the tag "${tag.name}" to ${this.getSelectionSize()} selected document(s).`
|
||||||
} else if (
|
} else if (
|
||||||
changedTags.itemsToAdd.length > 1 &&
|
changedTags.itemsToAdd.length > 1 &&
|
||||||
changedTags.itemsToRemove.length == 0
|
changedTags.itemsToRemove.length == 0
|
||||||
) {
|
) {
|
||||||
modal.componentInstance.message = $localize`This operation will add the tags ${this._localizeList(
|
modal.componentInstance.message = $localize`This operation will add the tags ${this._localizeList(
|
||||||
changedTags.itemsToAdd
|
changedTags.itemsToAdd
|
||||||
)} to ${this.list.selected.size} selected document(s).`
|
)} to ${this.getSelectionSize()} selected document(s).`
|
||||||
} else if (
|
} else if (
|
||||||
changedTags.itemsToAdd.length == 0 &&
|
changedTags.itemsToAdd.length == 0 &&
|
||||||
changedTags.itemsToRemove.length == 1
|
changedTags.itemsToRemove.length == 1
|
||||||
) {
|
) {
|
||||||
let tag = changedTags.itemsToRemove[0]
|
let tag = changedTags.itemsToRemove[0]
|
||||||
modal.componentInstance.message = $localize`This operation will remove the tag "${tag.name}" from ${this.list.selected.size} selected document(s).`
|
modal.componentInstance.message = $localize`This operation will remove the tag "${tag.name}" from ${this.getSelectionSize()} selected document(s).`
|
||||||
} else if (
|
} else if (
|
||||||
changedTags.itemsToAdd.length == 0 &&
|
changedTags.itemsToAdd.length == 0 &&
|
||||||
changedTags.itemsToRemove.length > 1
|
changedTags.itemsToRemove.length > 1
|
||||||
) {
|
) {
|
||||||
modal.componentInstance.message = $localize`This operation will remove the tags ${this._localizeList(
|
modal.componentInstance.message = $localize`This operation will remove the tags ${this._localizeList(
|
||||||
changedTags.itemsToRemove
|
changedTags.itemsToRemove
|
||||||
)} from ${this.list.selected.size} selected document(s).`
|
)} from ${this.getSelectionSize()} selected document(s).`
|
||||||
} else {
|
} else {
|
||||||
modal.componentInstance.message = $localize`This operation will add the tags ${this._localizeList(
|
modal.componentInstance.message = $localize`This operation will add the tags ${this._localizeList(
|
||||||
changedTags.itemsToAdd
|
changedTags.itemsToAdd
|
||||||
)} and remove the tags ${this._localizeList(
|
)} and remove the tags ${this._localizeList(
|
||||||
changedTags.itemsToRemove
|
changedTags.itemsToRemove
|
||||||
)} on ${this.list.selected.size} selected document(s).`
|
)} on ${this.getSelectionSize()} selected document(s).`
|
||||||
}
|
}
|
||||||
|
|
||||||
modal.componentInstance.btnClass = 'btn-warning'
|
modal.componentInstance.btnClass = 'btn-warning'
|
||||||
@@ -502,9 +568,9 @@ export class BulkEditorComponent
|
|||||||
})
|
})
|
||||||
modal.componentInstance.title = $localize`Confirm correspondent assignment`
|
modal.componentInstance.title = $localize`Confirm correspondent assignment`
|
||||||
if (correspondent) {
|
if (correspondent) {
|
||||||
modal.componentInstance.message = $localize`This operation will assign the correspondent "${correspondent.name}" to ${this.list.selected.size} selected document(s).`
|
modal.componentInstance.message = $localize`This operation will assign the correspondent "${correspondent.name}" to ${this.getSelectionSize()} selected document(s).`
|
||||||
} else {
|
} else {
|
||||||
modal.componentInstance.message = $localize`This operation will remove the correspondent from ${this.list.selected.size} selected document(s).`
|
modal.componentInstance.message = $localize`This operation will remove the correspondent from ${this.getSelectionSize()} selected document(s).`
|
||||||
}
|
}
|
||||||
modal.componentInstance.btnClass = 'btn-warning'
|
modal.componentInstance.btnClass = 'btn-warning'
|
||||||
modal.componentInstance.btnCaption = $localize`Confirm`
|
modal.componentInstance.btnCaption = $localize`Confirm`
|
||||||
@@ -540,9 +606,9 @@ export class BulkEditorComponent
|
|||||||
})
|
})
|
||||||
modal.componentInstance.title = $localize`Confirm document type assignment`
|
modal.componentInstance.title = $localize`Confirm document type assignment`
|
||||||
if (documentType) {
|
if (documentType) {
|
||||||
modal.componentInstance.message = $localize`This operation will assign the document type "${documentType.name}" to ${this.list.selected.size} selected document(s).`
|
modal.componentInstance.message = $localize`This operation will assign the document type "${documentType.name}" to ${this.getSelectionSize()} selected document(s).`
|
||||||
} else {
|
} else {
|
||||||
modal.componentInstance.message = $localize`This operation will remove the document type from ${this.list.selected.size} selected document(s).`
|
modal.componentInstance.message = $localize`This operation will remove the document type from ${this.getSelectionSize()} selected document(s).`
|
||||||
}
|
}
|
||||||
modal.componentInstance.btnClass = 'btn-warning'
|
modal.componentInstance.btnClass = 'btn-warning'
|
||||||
modal.componentInstance.btnCaption = $localize`Confirm`
|
modal.componentInstance.btnCaption = $localize`Confirm`
|
||||||
@@ -578,9 +644,9 @@ export class BulkEditorComponent
|
|||||||
})
|
})
|
||||||
modal.componentInstance.title = $localize`Confirm storage path assignment`
|
modal.componentInstance.title = $localize`Confirm storage path assignment`
|
||||||
if (storagePath) {
|
if (storagePath) {
|
||||||
modal.componentInstance.message = $localize`This operation will assign the storage path "${storagePath.name}" to ${this.list.selected.size} selected document(s).`
|
modal.componentInstance.message = $localize`This operation will assign the storage path "${storagePath.name}" to ${this.getSelectionSize()} selected document(s).`
|
||||||
} else {
|
} else {
|
||||||
modal.componentInstance.message = $localize`This operation will remove the storage path from ${this.list.selected.size} selected document(s).`
|
modal.componentInstance.message = $localize`This operation will remove the storage path from ${this.getSelectionSize()} selected document(s).`
|
||||||
}
|
}
|
||||||
modal.componentInstance.btnClass = 'btn-warning'
|
modal.componentInstance.btnClass = 'btn-warning'
|
||||||
modal.componentInstance.btnCaption = $localize`Confirm`
|
modal.componentInstance.btnCaption = $localize`Confirm`
|
||||||
@@ -615,33 +681,33 @@ export class BulkEditorComponent
|
|||||||
changedCustomFields.itemsToRemove.length == 0
|
changedCustomFields.itemsToRemove.length == 0
|
||||||
) {
|
) {
|
||||||
let customField = changedCustomFields.itemsToAdd[0]
|
let customField = changedCustomFields.itemsToAdd[0]
|
||||||
modal.componentInstance.message = $localize`This operation will assign the custom field "${customField.name}" to ${this.list.selected.size} selected document(s).`
|
modal.componentInstance.message = $localize`This operation will assign the custom field "${customField.name}" to ${this.getSelectionSize()} selected document(s).`
|
||||||
} else if (
|
} else if (
|
||||||
changedCustomFields.itemsToAdd.length > 1 &&
|
changedCustomFields.itemsToAdd.length > 1 &&
|
||||||
changedCustomFields.itemsToRemove.length == 0
|
changedCustomFields.itemsToRemove.length == 0
|
||||||
) {
|
) {
|
||||||
modal.componentInstance.message = $localize`This operation will assign the custom fields ${this._localizeList(
|
modal.componentInstance.message = $localize`This operation will assign the custom fields ${this._localizeList(
|
||||||
changedCustomFields.itemsToAdd
|
changedCustomFields.itemsToAdd
|
||||||
)} to ${this.list.selected.size} selected document(s).`
|
)} to ${this.getSelectionSize()} selected document(s).`
|
||||||
} else if (
|
} else if (
|
||||||
changedCustomFields.itemsToAdd.length == 0 &&
|
changedCustomFields.itemsToAdd.length == 0 &&
|
||||||
changedCustomFields.itemsToRemove.length == 1
|
changedCustomFields.itemsToRemove.length == 1
|
||||||
) {
|
) {
|
||||||
let customField = changedCustomFields.itemsToRemove[0]
|
let customField = changedCustomFields.itemsToRemove[0]
|
||||||
modal.componentInstance.message = $localize`This operation will remove the custom field "${customField.name}" from ${this.list.selected.size} selected document(s).`
|
modal.componentInstance.message = $localize`This operation will remove the custom field "${customField.name}" from ${this.getSelectionSize()} selected document(s).`
|
||||||
} else if (
|
} else if (
|
||||||
changedCustomFields.itemsToAdd.length == 0 &&
|
changedCustomFields.itemsToAdd.length == 0 &&
|
||||||
changedCustomFields.itemsToRemove.length > 1
|
changedCustomFields.itemsToRemove.length > 1
|
||||||
) {
|
) {
|
||||||
modal.componentInstance.message = $localize`This operation will remove the custom fields ${this._localizeList(
|
modal.componentInstance.message = $localize`This operation will remove the custom fields ${this._localizeList(
|
||||||
changedCustomFields.itemsToRemove
|
changedCustomFields.itemsToRemove
|
||||||
)} from ${this.list.selected.size} selected document(s).`
|
)} from ${this.getSelectionSize()} selected document(s).`
|
||||||
} else {
|
} else {
|
||||||
modal.componentInstance.message = $localize`This operation will assign the custom fields ${this._localizeList(
|
modal.componentInstance.message = $localize`This operation will assign the custom fields ${this._localizeList(
|
||||||
changedCustomFields.itemsToAdd
|
changedCustomFields.itemsToAdd
|
||||||
)} and remove the custom fields ${this._localizeList(
|
)} and remove the custom fields ${this._localizeList(
|
||||||
changedCustomFields.itemsToRemove
|
changedCustomFields.itemsToRemove
|
||||||
)} on ${this.list.selected.size} selected document(s).`
|
)} on ${this.getSelectionSize()} selected document(s).`
|
||||||
}
|
}
|
||||||
|
|
||||||
modal.componentInstance.btnClass = 'btn-warning'
|
modal.componentInstance.btnClass = 'btn-warning'
|
||||||
@@ -779,7 +845,7 @@ export class BulkEditorComponent
|
|||||||
backdrop: 'static',
|
backdrop: 'static',
|
||||||
})
|
})
|
||||||
modal.componentInstance.title = $localize`Confirm`
|
modal.componentInstance.title = $localize`Confirm`
|
||||||
modal.componentInstance.messageBold = $localize`Move ${this.list.selected.size} selected document(s) to the trash?`
|
modal.componentInstance.messageBold = $localize`Move ${this.getSelectionSize()} selected document(s) to the trash?`
|
||||||
modal.componentInstance.message = $localize`Documents can be restored prior to permanent deletion.`
|
modal.componentInstance.message = $localize`Documents can be restored prior to permanent deletion.`
|
||||||
modal.componentInstance.btnClass = 'btn-danger'
|
modal.componentInstance.btnClass = 'btn-danger'
|
||||||
modal.componentInstance.btnCaption = $localize`Move to trash`
|
modal.componentInstance.btnCaption = $localize`Move to trash`
|
||||||
@@ -789,13 +855,13 @@ export class BulkEditorComponent
|
|||||||
modal.componentInstance.buttonsEnabled = false
|
modal.componentInstance.buttonsEnabled = false
|
||||||
this.executeDocumentAction(
|
this.executeDocumentAction(
|
||||||
modal,
|
modal,
|
||||||
this.documentService.deleteDocuments(Array.from(this.list.selected))
|
this.documentService.deleteDocuments(this.getSelectionQuery())
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
} else {
|
} else {
|
||||||
this.executeDocumentAction(
|
this.executeDocumentAction(
|
||||||
null,
|
null,
|
||||||
this.documentService.deleteDocuments(Array.from(this.list.selected))
|
this.documentService.deleteDocuments(this.getSelectionQuery())
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -811,7 +877,7 @@ export class BulkEditorComponent
|
|||||||
: 'originals'
|
: 'originals'
|
||||||
this.documentService
|
this.documentService
|
||||||
.bulkDownload(
|
.bulkDownload(
|
||||||
Array.from(this.list.selected),
|
this.getSelectionQuery(),
|
||||||
downloadFileType,
|
downloadFileType,
|
||||||
this.downloadForm.get('downloadUseFormatting').value
|
this.downloadForm.get('downloadUseFormatting').value
|
||||||
)
|
)
|
||||||
@@ -827,7 +893,7 @@ export class BulkEditorComponent
|
|||||||
backdrop: 'static',
|
backdrop: 'static',
|
||||||
})
|
})
|
||||||
modal.componentInstance.title = $localize`Reprocess confirm`
|
modal.componentInstance.title = $localize`Reprocess confirm`
|
||||||
modal.componentInstance.messageBold = $localize`This operation will permanently recreate the archive files for ${this.list.selected.size} selected document(s).`
|
modal.componentInstance.messageBold = $localize`This operation will permanently recreate the archive files for ${this.getSelectionSize()} selected document(s).`
|
||||||
modal.componentInstance.message = $localize`The archive files will be re-generated with the current settings.`
|
modal.componentInstance.message = $localize`The archive files will be re-generated with the current settings.`
|
||||||
modal.componentInstance.btnClass = 'btn-danger'
|
modal.componentInstance.btnClass = 'btn-danger'
|
||||||
modal.componentInstance.btnCaption = $localize`Proceed`
|
modal.componentInstance.btnCaption = $localize`Proceed`
|
||||||
@@ -837,9 +903,7 @@ export class BulkEditorComponent
|
|||||||
modal.componentInstance.buttonsEnabled = false
|
modal.componentInstance.buttonsEnabled = false
|
||||||
this.executeDocumentAction(
|
this.executeDocumentAction(
|
||||||
modal,
|
modal,
|
||||||
this.documentService.reprocessDocuments(
|
this.documentService.reprocessDocuments(this.getSelectionQuery())
|
||||||
Array.from(this.list.selected)
|
|
||||||
)
|
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -866,7 +930,7 @@ export class BulkEditorComponent
|
|||||||
})
|
})
|
||||||
const rotateDialog = modal.componentInstance as RotateConfirmDialogComponent
|
const rotateDialog = modal.componentInstance as RotateConfirmDialogComponent
|
||||||
rotateDialog.title = $localize`Rotate confirm`
|
rotateDialog.title = $localize`Rotate confirm`
|
||||||
rotateDialog.messageBold = $localize`This operation will add rotated versions of the ${this.list.selected.size} document(s).`
|
rotateDialog.messageBold = $localize`This operation will add rotated versions of the ${this.getSelectionSize()} document(s).`
|
||||||
rotateDialog.btnClass = 'btn-danger'
|
rotateDialog.btnClass = 'btn-danger'
|
||||||
rotateDialog.btnCaption = $localize`Proceed`
|
rotateDialog.btnCaption = $localize`Proceed`
|
||||||
rotateDialog.documentID = Array.from(this.list.selected)[0]
|
rotateDialog.documentID = Array.from(this.list.selected)[0]
|
||||||
@@ -877,7 +941,7 @@ export class BulkEditorComponent
|
|||||||
this.executeDocumentAction(
|
this.executeDocumentAction(
|
||||||
modal,
|
modal,
|
||||||
this.documentService.rotateDocuments(
|
this.documentService.rotateDocuments(
|
||||||
Array.from(this.list.selected),
|
this.getSelectionQuery(),
|
||||||
rotateDialog.degrees
|
rotateDialog.degrees
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
@@ -890,7 +954,7 @@ export class BulkEditorComponent
|
|||||||
})
|
})
|
||||||
const mergeDialog = modal.componentInstance as MergeConfirmDialogComponent
|
const mergeDialog = modal.componentInstance as MergeConfirmDialogComponent
|
||||||
mergeDialog.title = $localize`Merge confirm`
|
mergeDialog.title = $localize`Merge confirm`
|
||||||
mergeDialog.messageBold = $localize`This operation will merge ${this.list.selected.size} selected documents into a new document.`
|
mergeDialog.messageBold = $localize`This operation will merge ${this.getSelectionSize()} selected documents into a new document.`
|
||||||
mergeDialog.btnCaption = $localize`Proceed`
|
mergeDialog.btnCaption = $localize`Proceed`
|
||||||
mergeDialog.documentIDs = Array.from(this.list.selected)
|
mergeDialog.documentIDs = Array.from(this.list.selected)
|
||||||
mergeDialog.confirmClicked
|
mergeDialog.confirmClicked
|
||||||
@@ -935,7 +999,7 @@ export class BulkEditorComponent
|
|||||||
(item) => item.id
|
(item) => item.id
|
||||||
)
|
)
|
||||||
|
|
||||||
dialog.documents = Array.from(this.list.selected)
|
dialog.selection = this.getSelectionQuery()
|
||||||
dialog.succeeded.subscribe((result) => {
|
dialog.succeeded.subscribe((result) => {
|
||||||
this.toastService.showInfo($localize`Custom fields updated.`)
|
this.toastService.showInfo($localize`Custom fields updated.`)
|
||||||
this.list.reload()
|
this.list.reload()
|
||||||
|
|||||||
@@ -48,7 +48,7 @@ describe('CustomFieldsBulkEditDialogComponent', () => {
|
|||||||
.mockReturnValue(of('Success'))
|
.mockReturnValue(of('Success'))
|
||||||
const successSpy = jest.spyOn(component.succeeded, 'emit')
|
const successSpy = jest.spyOn(component.succeeded, 'emit')
|
||||||
|
|
||||||
component.documents = [1, 2]
|
component.selection = [1, 2]
|
||||||
component.fieldsToAddIds = [1]
|
component.fieldsToAddIds = [1]
|
||||||
component.form.controls['1'].setValue('Value 1')
|
component.form.controls['1'].setValue('Value 1')
|
||||||
component.save()
|
component.save()
|
||||||
@@ -63,7 +63,7 @@ describe('CustomFieldsBulkEditDialogComponent', () => {
|
|||||||
.mockReturnValue(throwError(new Error('Error')))
|
.mockReturnValue(throwError(new Error('Error')))
|
||||||
const failSpy = jest.spyOn(component.failed, 'emit')
|
const failSpy = jest.spyOn(component.failed, 'emit')
|
||||||
|
|
||||||
component.documents = [1, 2]
|
component.selection = [1, 2]
|
||||||
component.fieldsToAddIds = [1]
|
component.fieldsToAddIds = [1]
|
||||||
component.form.controls['1'].setValue('Value 1')
|
component.form.controls['1'].setValue('Value 1')
|
||||||
component.save()
|
component.save()
|
||||||
|
|||||||
@@ -17,7 +17,10 @@ import { SelectComponent } from 'src/app/components/common/input/select/select.c
|
|||||||
import { TextComponent } from 'src/app/components/common/input/text/text.component'
|
import { TextComponent } from 'src/app/components/common/input/text/text.component'
|
||||||
import { UrlComponent } from 'src/app/components/common/input/url/url.component'
|
import { UrlComponent } from 'src/app/components/common/input/url/url.component'
|
||||||
import { CustomField, CustomFieldDataType } from 'src/app/data/custom-field'
|
import { CustomField, CustomFieldDataType } from 'src/app/data/custom-field'
|
||||||
import { DocumentService } from 'src/app/services/rest/document.service'
|
import {
|
||||||
|
DocumentSelectionQuery,
|
||||||
|
DocumentService,
|
||||||
|
} from 'src/app/services/rest/document.service'
|
||||||
import { TextAreaComponent } from '../../../common/input/textarea/textarea.component'
|
import { TextAreaComponent } from '../../../common/input/textarea/textarea.component'
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
@@ -76,7 +79,11 @@ export class CustomFieldsBulkEditDialogComponent {
|
|||||||
|
|
||||||
public form: FormGroup = new FormGroup({})
|
public form: FormGroup = new FormGroup({})
|
||||||
|
|
||||||
public documents: number[] = []
|
public selection: DocumentSelectionQuery = { documents: [] }
|
||||||
|
|
||||||
|
public get documents(): number[] {
|
||||||
|
return this.selection.documents
|
||||||
|
}
|
||||||
|
|
||||||
initForm() {
|
initForm() {
|
||||||
Object.keys(this.form.controls).forEach((key) => {
|
Object.keys(this.form.controls).forEach((key) => {
|
||||||
@@ -91,7 +98,7 @@ export class CustomFieldsBulkEditDialogComponent {
|
|||||||
|
|
||||||
public save() {
|
public save() {
|
||||||
this.documentService
|
this.documentService
|
||||||
.bulkEdit(this.documents, 'modify_custom_fields', {
|
.bulkEdit(this.selection, 'modify_custom_fields', {
|
||||||
add_custom_fields: this.form.value,
|
add_custom_fields: this.form.value,
|
||||||
remove_custom_fields: this.fieldsToRemoveIds,
|
remove_custom_fields: this.fieldsToRemoveIds,
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -2,8 +2,8 @@
|
|||||||
<div ngbDropdown class="btn-group flex-fill d-sm-none">
|
<div ngbDropdown class="btn-group flex-fill d-sm-none">
|
||||||
<button class="btn btn-sm btn-outline-primary" id="dropdownSelectMobile" ngbDropdownToggle>
|
<button class="btn btn-sm btn-outline-primary" id="dropdownSelectMobile" ngbDropdownToggle>
|
||||||
<i-bs name="text-indent-left"></i-bs><div class="d-none d-sm-inline ms-1"><ng-container i18n>Select</ng-container></div>
|
<i-bs name="text-indent-left"></i-bs><div class="d-none d-sm-inline ms-1"><ng-container i18n>Select</ng-container></div>
|
||||||
@if (list.selected.size > 0) {
|
@if (list.hasSelection) {
|
||||||
<pngx-clearable-badge [selected]="list.selected.size > 0" [number]="list.selected.size" (cleared)="list.selectNone()"></pngx-clearable-badge><span class="visually-hidden">selected</span>
|
<pngx-clearable-badge [selected]="list.hasSelection" [number]="list.selectedCount" (cleared)="list.selectNone()"></pngx-clearable-badge><span class="visually-hidden">selected</span>
|
||||||
}
|
}
|
||||||
</button>
|
</button>
|
||||||
<div ngbDropdownMenu aria-labelledby="dropdownSelectMobile" class="shadow">
|
<div ngbDropdownMenu aria-labelledby="dropdownSelectMobile" class="shadow">
|
||||||
@@ -17,7 +17,7 @@
|
|||||||
<span class="input-group-text border-0" i18n>Select:</span>
|
<span class="input-group-text border-0" i18n>Select:</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="btn-group btn-group-sm flex-nowrap">
|
<div class="btn-group btn-group-sm flex-nowrap">
|
||||||
@if (list.selected.size > 0) {
|
@if (list.hasSelection) {
|
||||||
<button class="btn btn-sm btn-outline-secondary" (click)="list.selectNone()">
|
<button class="btn btn-sm btn-outline-secondary" (click)="list.selectNone()">
|
||||||
<i-bs name="slash-circle" class="me-1"></i-bs><ng-container i18n>None</ng-container>
|
<i-bs name="slash-circle" class="me-1"></i-bs><ng-container i18n>None</ng-container>
|
||||||
</button>
|
</button>
|
||||||
@@ -127,11 +127,11 @@
|
|||||||
<div class="spinner-border spinner-border-sm me-2" role="status"></div>
|
<div class="spinner-border spinner-border-sm me-2" role="status"></div>
|
||||||
<ng-container i18n>Loading...</ng-container>
|
<ng-container i18n>Loading...</ng-container>
|
||||||
}
|
}
|
||||||
@if (list.selected.size > 0) {
|
@if (list.hasSelection) {
|
||||||
<span i18n>{list.collectionSize, plural, =1 {Selected {{list.selected.size}} of one document} other {Selected {{list.selected.size}} of {{list.collectionSize || 0}} documents}}</span>
|
<span i18n>{list.collectionSize, plural, =1 {Selected {{list.selectedCount}} of one document} other {Selected {{list.selectedCount}} of {{list.collectionSize || 0}} documents}}</span>
|
||||||
}
|
}
|
||||||
@if (!list.isReloading) {
|
@if (!list.isReloading) {
|
||||||
@if (list.selected.size === 0) {
|
@if (!list.hasSelection) {
|
||||||
<span i18n>{list.collectionSize, plural, =1 {One document} other {{{list.collectionSize || 0}} documents}}</span>
|
<span i18n>{list.collectionSize, plural, =1 {One document} other {{{list.collectionSize || 0}} documents}}</span>
|
||||||
} @if (isFiltered) {
|
} @if (isFiltered) {
|
||||||
<span i18n>(filtered)</span>
|
<span i18n>(filtered)</span>
|
||||||
@@ -142,7 +142,7 @@
|
|||||||
<i-bs width="1em" height="1em" name="x"></i-bs><small i18n>Reset filters</small>
|
<i-bs width="1em" height="1em" name="x"></i-bs><small i18n>Reset filters</small>
|
||||||
</button>
|
</button>
|
||||||
}
|
}
|
||||||
@if (!list.isReloading && list.selected.size > 0) {
|
@if (!list.isReloading && list.hasSelection) {
|
||||||
<button class="btn btn-link py-0" (click)="list.selectNone()">
|
<button class="btn btn-link py-0" (click)="list.selectNone()">
|
||||||
<i-bs width="1em" height="1em" name="slash-circle" class="me-1"></i-bs><small i18n>Clear selection</small>
|
<i-bs width="1em" height="1em" name="slash-circle" class="me-1"></i-bs><small i18n>Clear selection</small>
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -388,8 +388,8 @@ describe('DocumentListComponent', () => {
|
|||||||
it('should support select all, none, page & range', () => {
|
it('should support select all, none, page & range', () => {
|
||||||
jest.spyOn(documentListService, 'documents', 'get').mockReturnValue(docs)
|
jest.spyOn(documentListService, 'documents', 'get').mockReturnValue(docs)
|
||||||
jest
|
jest
|
||||||
.spyOn(documentService, 'listAllFilteredIds')
|
.spyOn(documentListService, 'collectionSize', 'get')
|
||||||
.mockReturnValue(of(docs.map((d) => d.id)))
|
.mockReturnValue(docs.length)
|
||||||
fixture.detectChanges()
|
fixture.detectChanges()
|
||||||
expect(documentListService.selected.size).toEqual(0)
|
expect(documentListService.selected.size).toEqual(0)
|
||||||
const docCards = fixture.debugElement.queryAll(
|
const docCards = fixture.debugElement.queryAll(
|
||||||
@@ -403,7 +403,8 @@ describe('DocumentListComponent', () => {
|
|||||||
displayModeButtons[2].triggerEventHandler('click')
|
displayModeButtons[2].triggerEventHandler('click')
|
||||||
expect(selectAllSpy).toHaveBeenCalled()
|
expect(selectAllSpy).toHaveBeenCalled()
|
||||||
fixture.detectChanges()
|
fixture.detectChanges()
|
||||||
expect(documentListService.selected.size).toEqual(3)
|
expect(documentListService.allSelected).toBeTruthy()
|
||||||
|
expect(documentListService.selectedCount).toEqual(3)
|
||||||
docCards.forEach((card) => {
|
docCards.forEach((card) => {
|
||||||
expect(card.context.selected).toBeTruthy()
|
expect(card.context.selected).toBeTruthy()
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -240,7 +240,7 @@ export class DocumentListComponent
|
|||||||
}
|
}
|
||||||
|
|
||||||
get isBulkEditing(): boolean {
|
get isBulkEditing(): boolean {
|
||||||
return this.list.selected.size > 0
|
return this.list.hasSelection
|
||||||
}
|
}
|
||||||
|
|
||||||
toggleDisplayField(field: DisplayField) {
|
toggleDisplayField(field: DisplayField) {
|
||||||
@@ -327,7 +327,7 @@ export class DocumentListComponent
|
|||||||
})
|
})
|
||||||
.pipe(takeUntil(this.unsubscribeNotifier))
|
.pipe(takeUntil(this.unsubscribeNotifier))
|
||||||
.subscribe(() => {
|
.subscribe(() => {
|
||||||
if (this.list.selected.size > 0) {
|
if (this.list.hasSelection) {
|
||||||
this.list.selectNone()
|
this.list.selectNone()
|
||||||
} else if (this.isFiltered) {
|
} else if (this.isFiltered) {
|
||||||
this.resetFilters()
|
this.resetFilters()
|
||||||
@@ -356,7 +356,7 @@ export class DocumentListComponent
|
|||||||
.pipe(takeUntil(this.unsubscribeNotifier))
|
.pipe(takeUntil(this.unsubscribeNotifier))
|
||||||
.subscribe(() => {
|
.subscribe(() => {
|
||||||
if (this.list.documents.length > 0) {
|
if (this.list.documents.length > 0) {
|
||||||
if (this.list.selected.size > 0) {
|
if (this.list.hasSelection) {
|
||||||
this.openDocumentDetail(Array.from(this.list.selected)[0])
|
this.openDocumentDetail(Array.from(this.list.selected)[0])
|
||||||
} else {
|
} else {
|
||||||
this.openDocumentDetail(this.list.documents[0])
|
this.openDocumentDetail(this.list.documents[0])
|
||||||
|
|||||||
@@ -76,6 +76,7 @@ import {
|
|||||||
FILTER_TITLE_CONTENT,
|
FILTER_TITLE_CONTENT,
|
||||||
NEGATIVE_NULL_FILTER_VALUE,
|
NEGATIVE_NULL_FILTER_VALUE,
|
||||||
} from 'src/app/data/filter-rule-type'
|
} from 'src/app/data/filter-rule-type'
|
||||||
|
import { SelectionData, SelectionDataItem } from 'src/app/data/results'
|
||||||
import {
|
import {
|
||||||
PermissionAction,
|
PermissionAction,
|
||||||
PermissionType,
|
PermissionType,
|
||||||
@@ -84,11 +85,7 @@ import {
|
|||||||
import { CorrespondentService } from 'src/app/services/rest/correspondent.service'
|
import { CorrespondentService } from 'src/app/services/rest/correspondent.service'
|
||||||
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 { DocumentService } from 'src/app/services/rest/document.service'
|
||||||
DocumentService,
|
|
||||||
SelectionData,
|
|
||||||
SelectionDataItem,
|
|
||||||
} from 'src/app/services/rest/document.service'
|
|
||||||
import { SearchService } from 'src/app/services/rest/search.service'
|
import { SearchService } from 'src/app/services/rest/search.service'
|
||||||
import { StoragePathService } from 'src/app/services/rest/storage-path.service'
|
import { StoragePathService } from 'src/app/services/rest/storage-path.service'
|
||||||
import { TagService } from 'src/app/services/rest/tag.service'
|
import { TagService } from 'src/app/services/rest/tag.service'
|
||||||
|
|||||||
@@ -9,8 +9,8 @@
|
|||||||
<div ngbDropdown class="btn-group flex-fill d-sm-none">
|
<div ngbDropdown class="btn-group flex-fill d-sm-none">
|
||||||
<button class="btn btn-sm btn-outline-primary" id="dropdownSelectMobile" ngbDropdownToggle>
|
<button class="btn btn-sm btn-outline-primary" id="dropdownSelectMobile" ngbDropdownToggle>
|
||||||
<i-bs name="text-indent-left"></i-bs><div class="d-none d-sm-inline ms-1"><ng-container i18n>Select</ng-container></div>
|
<i-bs name="text-indent-left"></i-bs><div class="d-none d-sm-inline ms-1"><ng-container i18n>Select</ng-container></div>
|
||||||
@if (activeManagementList.selectedObjects.size > 0) {
|
@if (activeManagementList.hasSelection) {
|
||||||
<pngx-clearable-badge [selected]="activeManagementList.selectedObjects.size > 0" [number]="activeManagementList.selectedObjects.size" (cleared)="activeManagementList.selectNone()"></pngx-clearable-badge><span class="visually-hidden">selected</span>
|
<pngx-clearable-badge [selected]="activeManagementList.hasSelection" [number]="activeManagementList.selectedCount" (cleared)="activeManagementList.selectNone()"></pngx-clearable-badge><span class="visually-hidden">selected</span>
|
||||||
}
|
}
|
||||||
</button>
|
</button>
|
||||||
<div ngbDropdownMenu aria-labelledby="dropdownSelectMobile" class="shadow">
|
<div ngbDropdownMenu aria-labelledby="dropdownSelectMobile" class="shadow">
|
||||||
@@ -25,7 +25,7 @@
|
|||||||
<span class="input-group-text border-0" i18n>Select:</span>
|
<span class="input-group-text border-0" i18n>Select:</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="btn-group btn-group-sm flex-nowrap">
|
<div class="btn-group btn-group-sm flex-nowrap">
|
||||||
@if (activeManagementList.selectedObjects.size > 0) {
|
@if (activeManagementList.hasSelection) {
|
||||||
<button class="btn btn-sm btn-outline-secondary" (click)="activeManagementList.selectNone()">
|
<button class="btn btn-sm btn-outline-secondary" (click)="activeManagementList.selectNone()">
|
||||||
<i-bs name="slash-circle" class="me-1"></i-bs><ng-container i18n>None</ng-container>
|
<i-bs name="slash-circle" class="me-1"></i-bs><ng-container i18n>None</ng-container>
|
||||||
</button>
|
</button>
|
||||||
@@ -40,11 +40,11 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<button type="button" class="btn btn-sm btn-outline-primary" (click)="activeManagementList.setPermissions()"
|
<button type="button" class="btn btn-sm btn-outline-primary" (click)="activeManagementList.setPermissions()"
|
||||||
[disabled]="!activeManagementList.userCanBulkEdit(PermissionAction.Change) || activeManagementList.selectedObjects.size === 0">
|
[disabled]="!activeManagementList.userCanBulkEdit(PermissionAction.Change) || !activeManagementList.hasSelection">
|
||||||
<i-bs name="person-fill-lock" class="me-1"></i-bs><ng-container i18n>Permissions</ng-container>
|
<i-bs name="person-fill-lock" class="me-1"></i-bs><ng-container i18n>Permissions</ng-container>
|
||||||
</button>
|
</button>
|
||||||
<button type="button" class="btn btn-sm btn-outline-danger" (click)="activeManagementList.delete()"
|
<button type="button" class="btn btn-sm btn-outline-danger" (click)="activeManagementList.delete()"
|
||||||
[disabled]="!activeManagementList.userCanBulkEdit(PermissionAction.Delete) || activeManagementList.selectedObjects.size === 0">
|
[disabled]="!activeManagementList.userCanBulkEdit(PermissionAction.Delete) || !activeManagementList.hasSelection">
|
||||||
<i-bs name="trash" class="me-1"></i-bs><ng-container i18n>Delete</ng-container>
|
<i-bs name="trash" class="me-1"></i-bs><ng-container i18n>Delete</ng-container>
|
||||||
</button>
|
</button>
|
||||||
<button type="button" class="btn btn-sm btn-outline-primary ms-md-5" (click)="activeManagementList.openCreateDialog()"
|
<button type="button" class="btn btn-sm btn-outline-primary ms-md-5" (click)="activeManagementList.openCreateDialog()"
|
||||||
|
|||||||
@@ -65,8 +65,8 @@
|
|||||||
@if (displayCollectionSize > 0) {
|
@if (displayCollectionSize > 0) {
|
||||||
<div>
|
<div>
|
||||||
<ng-container i18n>{displayCollectionSize, plural, =1 {One {{typeName}}} other {{{displayCollectionSize || 0}} total {{typeNamePlural}}}}</ng-container>
|
<ng-container i18n>{displayCollectionSize, plural, =1 {One {{typeName}}} other {{{displayCollectionSize || 0}} total {{typeNamePlural}}}}</ng-container>
|
||||||
@if (selectedObjects.size > 0) {
|
@if (hasSelection) {
|
||||||
({{selectedObjects.size}} selected)
|
({{selectedCount}} selected)
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -117,7 +117,6 @@ describe('ManagementListComponent', () => {
|
|||||||
: tags
|
: tags
|
||||||
return of({
|
return of({
|
||||||
count: results.length,
|
count: results.length,
|
||||||
all: results.map((o) => o.id),
|
|
||||||
results,
|
results,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -231,11 +230,11 @@ describe('ManagementListComponent', () => {
|
|||||||
expect(reloadSpy).toHaveBeenCalled()
|
expect(reloadSpy).toHaveBeenCalled()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should use API count for pagination and all ids for displayed total', fakeAsync(() => {
|
it('should use API count for pagination and nested ids for displayed total', fakeAsync(() => {
|
||||||
jest.spyOn(tagService, 'listFiltered').mockReturnValueOnce(
|
jest.spyOn(tagService, 'listFiltered').mockReturnValueOnce(
|
||||||
of({
|
of({
|
||||||
count: 1,
|
count: 1,
|
||||||
all: [1, 2, 3],
|
display_count: 3,
|
||||||
results: tags.slice(0, 1),
|
results: tags.slice(0, 1),
|
||||||
})
|
})
|
||||||
)
|
)
|
||||||
@@ -315,13 +314,17 @@ describe('ManagementListComponent', () => {
|
|||||||
expect(component.togggleAll).toBe(false)
|
expect(component.togggleAll).toBe(false)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('selectAll should use all IDs when collection size exists', () => {
|
it('selectAll should activate all-selection mode', () => {
|
||||||
;(component as any).allIDs = [1, 2, 3, 4]
|
;(tagService.listFiltered as jest.Mock).mockClear()
|
||||||
component.collectionSize = 4
|
component.collectionSize = tags.length
|
||||||
|
|
||||||
component.selectAll()
|
component.selectAll()
|
||||||
|
|
||||||
expect(component.selectedObjects).toEqual(new Set([1, 2, 3, 4]))
|
expect(tagService.listFiltered).not.toHaveBeenCalled()
|
||||||
|
expect(component.selectedObjects).toEqual(new Set(tags.map((t) => t.id)))
|
||||||
|
expect((component as any).allSelectionActive).toBe(true)
|
||||||
|
expect(component.hasSelection).toBe(true)
|
||||||
|
expect(component.selectedCount).toBe(tags.length)
|
||||||
expect(component.togggleAll).toBe(true)
|
expect(component.togggleAll).toBe(true)
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -395,6 +398,33 @@ describe('ManagementListComponent', () => {
|
|||||||
expect(successToastSpy).toHaveBeenCalled()
|
expect(successToastSpy).toHaveBeenCalled()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('should support bulk edit permissions for all filtered items', () => {
|
||||||
|
const bulkEditPermsSpy = jest
|
||||||
|
.spyOn(tagService, 'bulk_edit_objects')
|
||||||
|
.mockReturnValue(of('OK'))
|
||||||
|
component.selectAll()
|
||||||
|
|
||||||
|
let modal: NgbModalRef
|
||||||
|
modalService.activeInstances.subscribe((m) => (modal = m[m.length - 1]))
|
||||||
|
fixture.detectChanges()
|
||||||
|
component.setPermissions()
|
||||||
|
expect(modal).not.toBeUndefined()
|
||||||
|
|
||||||
|
modal.componentInstance.confirmClicked.emit({
|
||||||
|
permissions: {},
|
||||||
|
merge: true,
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(bulkEditPermsSpy).toHaveBeenCalledWith(
|
||||||
|
[],
|
||||||
|
BulkEditObjectOperation.SetPermissions,
|
||||||
|
{},
|
||||||
|
true,
|
||||||
|
true,
|
||||||
|
{ is_root: true }
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
it('should support bulk delete objects', () => {
|
it('should support bulk delete objects', () => {
|
||||||
const bulkEditSpy = jest.spyOn(tagService, 'bulk_edit_objects')
|
const bulkEditSpy = jest.spyOn(tagService, 'bulk_edit_objects')
|
||||||
component.toggleSelected(tags[0])
|
component.toggleSelected(tags[0])
|
||||||
@@ -415,7 +445,11 @@ describe('ManagementListComponent', () => {
|
|||||||
modal.componentInstance.confirmClicked.emit(null)
|
modal.componentInstance.confirmClicked.emit(null)
|
||||||
expect(bulkEditSpy).toHaveBeenCalledWith(
|
expect(bulkEditSpy).toHaveBeenCalledWith(
|
||||||
Array.from(selected),
|
Array.from(selected),
|
||||||
BulkEditObjectOperation.Delete
|
BulkEditObjectOperation.Delete,
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
false,
|
||||||
|
null
|
||||||
)
|
)
|
||||||
expect(errorToastSpy).toHaveBeenCalled()
|
expect(errorToastSpy).toHaveBeenCalled()
|
||||||
|
|
||||||
@@ -426,6 +460,29 @@ describe('ManagementListComponent', () => {
|
|||||||
expect(successToastSpy).toHaveBeenCalled()
|
expect(successToastSpy).toHaveBeenCalled()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('should support bulk delete for all filtered items', () => {
|
||||||
|
const bulkEditSpy = jest
|
||||||
|
.spyOn(tagService, 'bulk_edit_objects')
|
||||||
|
.mockReturnValue(of('OK'))
|
||||||
|
|
||||||
|
component.selectAll()
|
||||||
|
let modal: NgbModalRef
|
||||||
|
modalService.activeInstances.subscribe((m) => (modal = m[m.length - 1]))
|
||||||
|
fixture.detectChanges()
|
||||||
|
component.delete()
|
||||||
|
expect(modal).not.toBeUndefined()
|
||||||
|
|
||||||
|
modal.componentInstance.confirmClicked.emit(null)
|
||||||
|
expect(bulkEditSpy).toHaveBeenCalledWith(
|
||||||
|
[],
|
||||||
|
BulkEditObjectOperation.Delete,
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
true,
|
||||||
|
{ is_root: true }
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
it('should disallow bulk permissions or delete objects if no global perms', () => {
|
it('should disallow bulk permissions or delete objects if no global perms', () => {
|
||||||
jest.spyOn(permissionsService, 'currentUserCan').mockReturnValue(false)
|
jest.spyOn(permissionsService, 'currentUserCan').mockReturnValue(false)
|
||||||
expect(component.userCanBulkEdit(PermissionAction.Delete)).toBeFalsy()
|
expect(component.userCanBulkEdit(PermissionAction.Delete)).toBeFalsy()
|
||||||
|
|||||||
@@ -90,7 +90,8 @@ export abstract class ManagementListComponent<T extends MatchingModel>
|
|||||||
|
|
||||||
public data: T[] = []
|
public data: T[] = []
|
||||||
private unfilteredData: T[] = []
|
private unfilteredData: T[] = []
|
||||||
private allIDs: number[] = []
|
private currentExtraParams: { [key: string]: any } = null
|
||||||
|
private allSelectionActive = false
|
||||||
|
|
||||||
public page = 1
|
public page = 1
|
||||||
|
|
||||||
@@ -107,6 +108,16 @@ export abstract class ManagementListComponent<T extends MatchingModel>
|
|||||||
public selectedObjects: Set<number> = new Set()
|
public selectedObjects: Set<number> = new Set()
|
||||||
public togggleAll: boolean = false
|
public togggleAll: boolean = false
|
||||||
|
|
||||||
|
public get hasSelection(): boolean {
|
||||||
|
return this.selectedObjects.size > 0 || this.allSelectionActive
|
||||||
|
}
|
||||||
|
|
||||||
|
public get selectedCount(): number {
|
||||||
|
return this.allSelectionActive
|
||||||
|
? this.displayCollectionSize
|
||||||
|
: this.selectedObjects.size
|
||||||
|
}
|
||||||
|
|
||||||
ngOnInit(): void {
|
ngOnInit(): void {
|
||||||
this.reloadData()
|
this.reloadData()
|
||||||
|
|
||||||
@@ -150,11 +161,11 @@ export abstract class ManagementListComponent<T extends MatchingModel>
|
|||||||
}
|
}
|
||||||
|
|
||||||
protected getCollectionSize(results: Results<T>): number {
|
protected getCollectionSize(results: Results<T>): number {
|
||||||
return results.all?.length ?? results.count
|
return results.count
|
||||||
}
|
}
|
||||||
|
|
||||||
protected getDisplayCollectionSize(results: Results<T>): number {
|
protected getDisplayCollectionSize(results: Results<T>): number {
|
||||||
return this.getCollectionSize(results)
|
return results.display_count ?? this.getCollectionSize(results)
|
||||||
}
|
}
|
||||||
|
|
||||||
getDocumentCount(object: MatchingModel): number {
|
getDocumentCount(object: MatchingModel): number {
|
||||||
@@ -171,6 +182,7 @@ export abstract class ManagementListComponent<T extends MatchingModel>
|
|||||||
|
|
||||||
reloadData(extraParams: { [key: string]: any } = null) {
|
reloadData(extraParams: { [key: string]: any } = null) {
|
||||||
this.loading = true
|
this.loading = true
|
||||||
|
this.currentExtraParams = extraParams
|
||||||
this.clearSelection()
|
this.clearSelection()
|
||||||
this.service
|
this.service
|
||||||
.listFiltered(
|
.listFiltered(
|
||||||
@@ -189,7 +201,6 @@ export abstract class ManagementListComponent<T extends MatchingModel>
|
|||||||
this.data = this.filterData(c.results)
|
this.data = this.filterData(c.results)
|
||||||
this.collectionSize = this.getCollectionSize(c)
|
this.collectionSize = this.getCollectionSize(c)
|
||||||
this.displayCollectionSize = this.getDisplayCollectionSize(c)
|
this.displayCollectionSize = this.getDisplayCollectionSize(c)
|
||||||
this.allIDs = c.all
|
|
||||||
}),
|
}),
|
||||||
delay(100)
|
delay(100)
|
||||||
)
|
)
|
||||||
@@ -346,7 +357,16 @@ export abstract class ManagementListComponent<T extends MatchingModel>
|
|||||||
return objects.map((o) => o.id)
|
return objects.map((o) => o.id)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private getBulkEditFilters(): { [key: string]: any } {
|
||||||
|
const filters = { ...this.currentExtraParams }
|
||||||
|
if (this._nameFilter?.length) {
|
||||||
|
filters['name__icontains'] = this._nameFilter
|
||||||
|
}
|
||||||
|
return filters
|
||||||
|
}
|
||||||
|
|
||||||
clearSelection() {
|
clearSelection() {
|
||||||
|
this.allSelectionActive = false
|
||||||
this.togggleAll = false
|
this.togggleAll = false
|
||||||
this.selectedObjects.clear()
|
this.selectedObjects.clear()
|
||||||
}
|
}
|
||||||
@@ -356,6 +376,7 @@ export abstract class ManagementListComponent<T extends MatchingModel>
|
|||||||
}
|
}
|
||||||
|
|
||||||
selectPage() {
|
selectPage() {
|
||||||
|
this.allSelectionActive = false
|
||||||
this.selectedObjects = new Set(this.getSelectableIDs(this.data))
|
this.selectedObjects = new Set(this.getSelectableIDs(this.data))
|
||||||
this.togggleAll = this.areAllPageItemsSelected()
|
this.togggleAll = this.areAllPageItemsSelected()
|
||||||
}
|
}
|
||||||
@@ -365,11 +386,16 @@ export abstract class ManagementListComponent<T extends MatchingModel>
|
|||||||
this.clearSelection()
|
this.clearSelection()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
this.selectedObjects = new Set(this.allIDs)
|
|
||||||
|
this.allSelectionActive = true
|
||||||
|
this.selectedObjects = new Set(this.getSelectableIDs(this.data))
|
||||||
this.togggleAll = this.areAllPageItemsSelected()
|
this.togggleAll = this.areAllPageItemsSelected()
|
||||||
}
|
}
|
||||||
|
|
||||||
toggleSelected(object) {
|
toggleSelected(object) {
|
||||||
|
if (this.allSelectionActive) {
|
||||||
|
this.allSelectionActive = false
|
||||||
|
}
|
||||||
this.selectedObjects.has(object.id)
|
this.selectedObjects.has(object.id)
|
||||||
? this.selectedObjects.delete(object.id)
|
? this.selectedObjects.delete(object.id)
|
||||||
: this.selectedObjects.add(object.id)
|
: this.selectedObjects.add(object.id)
|
||||||
@@ -377,6 +403,9 @@ export abstract class ManagementListComponent<T extends MatchingModel>
|
|||||||
}
|
}
|
||||||
|
|
||||||
protected areAllPageItemsSelected(): boolean {
|
protected areAllPageItemsSelected(): boolean {
|
||||||
|
if (this.allSelectionActive) {
|
||||||
|
return this.data.length > 0
|
||||||
|
}
|
||||||
const ids = this.getSelectableIDs(this.data)
|
const ids = this.getSelectableIDs(this.data)
|
||||||
return ids.length > 0 && ids.every((id) => this.selectedObjects.has(id))
|
return ids.length > 0 && ids.every((id) => this.selectedObjects.has(id))
|
||||||
}
|
}
|
||||||
@@ -390,10 +419,12 @@ export abstract class ManagementListComponent<T extends MatchingModel>
|
|||||||
modal.componentInstance.buttonsEnabled = false
|
modal.componentInstance.buttonsEnabled = false
|
||||||
this.service
|
this.service
|
||||||
.bulk_edit_objects(
|
.bulk_edit_objects(
|
||||||
Array.from(this.selectedObjects),
|
this.allSelectionActive ? [] : Array.from(this.selectedObjects),
|
||||||
BulkEditObjectOperation.SetPermissions,
|
BulkEditObjectOperation.SetPermissions,
|
||||||
permissions,
|
permissions,
|
||||||
merge
|
merge,
|
||||||
|
this.allSelectionActive,
|
||||||
|
this.allSelectionActive ? this.getBulkEditFilters() : null
|
||||||
)
|
)
|
||||||
.subscribe({
|
.subscribe({
|
||||||
next: () => {
|
next: () => {
|
||||||
@@ -428,8 +459,12 @@ export abstract class ManagementListComponent<T extends MatchingModel>
|
|||||||
modal.componentInstance.buttonsEnabled = false
|
modal.componentInstance.buttonsEnabled = false
|
||||||
this.service
|
this.service
|
||||||
.bulk_edit_objects(
|
.bulk_edit_objects(
|
||||||
Array.from(this.selectedObjects),
|
this.allSelectionActive ? [] : Array.from(this.selectedObjects),
|
||||||
BulkEditObjectOperation.Delete
|
BulkEditObjectOperation.Delete,
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
this.allSelectionActive,
|
||||||
|
this.allSelectionActive ? this.getBulkEditFilters() : null
|
||||||
)
|
)
|
||||||
.subscribe({
|
.subscribe({
|
||||||
next: () => {
|
next: () => {
|
||||||
|
|||||||
@@ -41,7 +41,6 @@ describe('TagListComponent', () => {
|
|||||||
listFilteredSpy = jest.spyOn(tagService, 'listFiltered').mockReturnValue(
|
listFilteredSpy = jest.spyOn(tagService, 'listFiltered').mockReturnValue(
|
||||||
of({
|
of({
|
||||||
count: 3,
|
count: 3,
|
||||||
all: [1, 2, 3],
|
|
||||||
results: [
|
results: [
|
||||||
{
|
{
|
||||||
id: 1,
|
id: 1,
|
||||||
|
|||||||
@@ -9,7 +9,6 @@ import {
|
|||||||
import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons'
|
import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons'
|
||||||
import { TagEditDialogComponent } from 'src/app/components/common/edit-dialog/tag-edit-dialog/tag-edit-dialog.component'
|
import { TagEditDialogComponent } from 'src/app/components/common/edit-dialog/tag-edit-dialog/tag-edit-dialog.component'
|
||||||
import { FILTER_HAS_TAGS_ALL } from 'src/app/data/filter-rule-type'
|
import { FILTER_HAS_TAGS_ALL } from 'src/app/data/filter-rule-type'
|
||||||
import { Results } from 'src/app/data/results'
|
|
||||||
import { Tag } from 'src/app/data/tag'
|
import { Tag } from 'src/app/data/tag'
|
||||||
import { IfPermissionsDirective } from 'src/app/directives/if-permissions.directive'
|
import { IfPermissionsDirective } from 'src/app/directives/if-permissions.directive'
|
||||||
import { SortableDirective } from 'src/app/directives/sortable.directive'
|
import { SortableDirective } from 'src/app/directives/sortable.directive'
|
||||||
@@ -77,16 +76,6 @@ export class TagListComponent extends ManagementListComponent<Tag> {
|
|||||||
return data.filter((tag) => !tag.parent || !availableIds.has(tag.parent))
|
return data.filter((tag) => !tag.parent || !availableIds.has(tag.parent))
|
||||||
}
|
}
|
||||||
|
|
||||||
protected override getCollectionSize(results: Results<Tag>): number {
|
|
||||||
// Tag list pages are requested with is_root=true (when unfiltered), so
|
|
||||||
// pagination must follow root count even though `all` includes descendants
|
|
||||||
return results.count
|
|
||||||
}
|
|
||||||
|
|
||||||
protected override getDisplayCollectionSize(results: Results<Tag>): number {
|
|
||||||
return super.getCollectionSize(results)
|
|
||||||
}
|
|
||||||
|
|
||||||
protected override getSelectableIDs(tags: Tag[]): number[] {
|
protected override getSelectableIDs(tags: Tag[]): number[] {
|
||||||
const ids: number[] = []
|
const ids: number[] = []
|
||||||
for (const tag of tags.filter(Boolean)) {
|
for (const tag of tags.filter(Boolean)) {
|
||||||
|
|||||||
@@ -1,7 +1,26 @@
|
|||||||
|
import { Document } from './document'
|
||||||
|
|
||||||
export interface Results<T> {
|
export interface Results<T> {
|
||||||
count: number
|
count: number
|
||||||
|
|
||||||
results: T[]
|
display_count?: number
|
||||||
|
|
||||||
all: number[]
|
results: T[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SelectionDataItem {
|
||||||
|
id: number
|
||||||
|
document_count: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SelectionData {
|
||||||
|
selected_storage_paths: SelectionDataItem[]
|
||||||
|
selected_correspondents: SelectionDataItem[]
|
||||||
|
selected_tags: SelectionDataItem[]
|
||||||
|
selected_document_types: SelectionDataItem[]
|
||||||
|
selected_custom_fields: SelectionDataItem[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DocumentResults extends Results<Document> {
|
||||||
|
selection_data?: SelectionData
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -127,13 +127,10 @@ describe('DocumentListViewService', () => {
|
|||||||
expect(documentListViewService.currentPage).toEqual(1)
|
expect(documentListViewService.currentPage).toEqual(1)
|
||||||
documentListViewService.reload()
|
documentListViewService.reload()
|
||||||
const req = httpTestingController.expectOne(
|
const req = httpTestingController.expectOne(
|
||||||
`${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&include_selection_data=true`
|
||||||
)
|
)
|
||||||
expect(req.request.method).toEqual('GET')
|
expect(req.request.method).toEqual('GET')
|
||||||
req.flush(full_results)
|
req.flush(full_results)
|
||||||
httpTestingController.expectOne(
|
|
||||||
`${environment.apiBaseUrl}documents/selection_data/`
|
|
||||||
)
|
|
||||||
expect(req.request.method).toEqual('GET')
|
expect(req.request.method).toEqual('GET')
|
||||||
expect(documentListViewService.isReloading).toBeFalsy()
|
expect(documentListViewService.isReloading).toBeFalsy()
|
||||||
expect(documentListViewService.activeSavedViewId).toBeNull()
|
expect(documentListViewService.activeSavedViewId).toBeNull()
|
||||||
@@ -145,12 +142,12 @@ describe('DocumentListViewService', () => {
|
|||||||
it('should handle error on page request out of range', () => {
|
it('should handle error on page request out of range', () => {
|
||||||
documentListViewService.currentPage = 50
|
documentListViewService.currentPage = 50
|
||||||
let req = httpTestingController.expectOne(
|
let req = httpTestingController.expectOne(
|
||||||
`${environment.apiBaseUrl}documents/?page=50&page_size=50&ordering=-created&truncate_content=true`
|
`${environment.apiBaseUrl}documents/?page=50&page_size=50&ordering=-created&truncate_content=true&include_selection_data=true`
|
||||||
)
|
)
|
||||||
expect(req.request.method).toEqual('GET')
|
expect(req.request.method).toEqual('GET')
|
||||||
req.flush([], { status: 404, statusText: 'Unexpected error' })
|
req.flush([], { status: 404, statusText: 'Unexpected error' })
|
||||||
req = httpTestingController.expectOne(
|
req = httpTestingController.expectOne(
|
||||||
`${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&include_selection_data=true`
|
||||||
)
|
)
|
||||||
expect(req.request.method).toEqual('GET')
|
expect(req.request.method).toEqual('GET')
|
||||||
expect(documentListViewService.currentPage).toEqual(1)
|
expect(documentListViewService.currentPage).toEqual(1)
|
||||||
@@ -167,7 +164,7 @@ describe('DocumentListViewService', () => {
|
|||||||
]
|
]
|
||||||
documentListViewService.setFilterRules(filterRulesAny)
|
documentListViewService.setFilterRules(filterRulesAny)
|
||||||
let req = httpTestingController.expectOne(
|
let req = httpTestingController.expectOne(
|
||||||
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true&tags__id__in=${tags__id__in}`
|
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true&include_selection_data=true&tags__id__in=${tags__id__in}`
|
||||||
)
|
)
|
||||||
expect(req.request.method).toEqual('GET')
|
expect(req.request.method).toEqual('GET')
|
||||||
req.flush(
|
req.flush(
|
||||||
@@ -175,13 +172,13 @@ describe('DocumentListViewService', () => {
|
|||||||
{ status: 404, statusText: 'Unexpected error' }
|
{ status: 404, statusText: 'Unexpected error' }
|
||||||
)
|
)
|
||||||
req = httpTestingController.expectOne(
|
req = httpTestingController.expectOne(
|
||||||
`${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&include_selection_data=true`
|
||||||
)
|
)
|
||||||
expect(req.request.method).toEqual('GET')
|
expect(req.request.method).toEqual('GET')
|
||||||
// reset the list
|
// reset the list
|
||||||
documentListViewService.setFilterRules([])
|
documentListViewService.setFilterRules([])
|
||||||
req = httpTestingController.expectOne(
|
req = httpTestingController.expectOne(
|
||||||
`${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&include_selection_data=true`
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -189,7 +186,7 @@ describe('DocumentListViewService', () => {
|
|||||||
documentListViewService.currentPage = 1
|
documentListViewService.currentPage = 1
|
||||||
documentListViewService.sortField = 'custom_field_999'
|
documentListViewService.sortField = 'custom_field_999'
|
||||||
let req = httpTestingController.expectOne(
|
let req = httpTestingController.expectOne(
|
||||||
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-custom_field_999&truncate_content=true`
|
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-custom_field_999&truncate_content=true&include_selection_data=true`
|
||||||
)
|
)
|
||||||
expect(req.request.method).toEqual('GET')
|
expect(req.request.method).toEqual('GET')
|
||||||
req.flush(
|
req.flush(
|
||||||
@@ -198,7 +195,7 @@ describe('DocumentListViewService', () => {
|
|||||||
)
|
)
|
||||||
// resets itself
|
// resets itself
|
||||||
req = httpTestingController.expectOne(
|
req = httpTestingController.expectOne(
|
||||||
`${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&include_selection_data=true`
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -213,7 +210,7 @@ describe('DocumentListViewService', () => {
|
|||||||
]
|
]
|
||||||
documentListViewService.setFilterRules(filterRulesAny)
|
documentListViewService.setFilterRules(filterRulesAny)
|
||||||
let req = httpTestingController.expectOne(
|
let req = httpTestingController.expectOne(
|
||||||
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true&tags__id__in=${tags__id__in}`
|
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true&include_selection_data=true&tags__id__in=${tags__id__in}`
|
||||||
)
|
)
|
||||||
expect(req.request.method).toEqual('GET')
|
expect(req.request.method).toEqual('GET')
|
||||||
req.flush('Generic error', { status: 404, statusText: 'Unexpected error' })
|
req.flush('Generic error', { status: 404, statusText: 'Unexpected error' })
|
||||||
@@ -221,7 +218,7 @@ describe('DocumentListViewService', () => {
|
|||||||
// reset the list
|
// reset the list
|
||||||
documentListViewService.setFilterRules([])
|
documentListViewService.setFilterRules([])
|
||||||
req = httpTestingController.expectOne(
|
req = httpTestingController.expectOne(
|
||||||
`${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&include_selection_data=true`
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -230,7 +227,7 @@ describe('DocumentListViewService', () => {
|
|||||||
expect(documentListViewService.sortReverse).toBeTruthy()
|
expect(documentListViewService.sortReverse).toBeTruthy()
|
||||||
documentListViewService.setSort('added', false)
|
documentListViewService.setSort('added', false)
|
||||||
let req = httpTestingController.expectOne(
|
let req = httpTestingController.expectOne(
|
||||||
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=added&truncate_content=true`
|
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=added&truncate_content=true&include_selection_data=true`
|
||||||
)
|
)
|
||||||
expect(req.request.method).toEqual('GET')
|
expect(req.request.method).toEqual('GET')
|
||||||
expect(documentListViewService.sortField).toEqual('added')
|
expect(documentListViewService.sortField).toEqual('added')
|
||||||
@@ -238,12 +235,12 @@ describe('DocumentListViewService', () => {
|
|||||||
|
|
||||||
documentListViewService.sortField = 'created'
|
documentListViewService.sortField = 'created'
|
||||||
req = httpTestingController.expectOne(
|
req = httpTestingController.expectOne(
|
||||||
`${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&include_selection_data=true`
|
||||||
)
|
)
|
||||||
expect(documentListViewService.sortField).toEqual('created')
|
expect(documentListViewService.sortField).toEqual('created')
|
||||||
documentListViewService.sortReverse = true
|
documentListViewService.sortReverse = true
|
||||||
req = httpTestingController.expectOne(
|
req = httpTestingController.expectOne(
|
||||||
`${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&include_selection_data=true`
|
||||||
)
|
)
|
||||||
expect(req.request.method).toEqual('GET')
|
expect(req.request.method).toEqual('GET')
|
||||||
expect(documentListViewService.sortReverse).toBeTruthy()
|
expect(documentListViewService.sortReverse).toBeTruthy()
|
||||||
@@ -286,7 +283,7 @@ describe('DocumentListViewService', () => {
|
|||||||
const req = httpTestingController.expectOne(
|
const req = httpTestingController.expectOne(
|
||||||
`${environment.apiBaseUrl}documents/?page=${page}&page_size=${
|
`${environment.apiBaseUrl}documents/?page=${page}&page_size=${
|
||||||
documentListViewService.pageSize
|
documentListViewService.pageSize
|
||||||
}&ordering=${reverse ? '-' : ''}${sort}&truncate_content=true`
|
}&ordering=${reverse ? '-' : ''}${sort}&truncate_content=true&include_selection_data=true`
|
||||||
)
|
)
|
||||||
expect(req.request.method).toEqual('GET')
|
expect(req.request.method).toEqual('GET')
|
||||||
expect(documentListViewService.currentPage).toEqual(page)
|
expect(documentListViewService.currentPage).toEqual(page)
|
||||||
@@ -303,7 +300,7 @@ describe('DocumentListViewService', () => {
|
|||||||
}
|
}
|
||||||
documentListViewService.loadFromQueryParams(convertToParamMap(params))
|
documentListViewService.loadFromQueryParams(convertToParamMap(params))
|
||||||
let req = httpTestingController.expectOne(
|
let req = httpTestingController.expectOne(
|
||||||
`${environment.apiBaseUrl}documents/?page=${documentListViewService.currentPage}&page_size=${documentListViewService.pageSize}&ordering=-added&truncate_content=true&tags__id__all=${tags__id__all}`
|
`${environment.apiBaseUrl}documents/?page=${documentListViewService.currentPage}&page_size=${documentListViewService.pageSize}&ordering=-added&truncate_content=true&include_selection_data=true&tags__id__all=${tags__id__all}`
|
||||||
)
|
)
|
||||||
expect(req.request.method).toEqual('GET')
|
expect(req.request.method).toEqual('GET')
|
||||||
expect(documentListViewService.filterRules).toEqual([
|
expect(documentListViewService.filterRules).toEqual([
|
||||||
@@ -313,15 +310,12 @@ describe('DocumentListViewService', () => {
|
|||||||
},
|
},
|
||||||
])
|
])
|
||||||
req.flush(full_results)
|
req.flush(full_results)
|
||||||
httpTestingController.expectOne(
|
|
||||||
`${environment.apiBaseUrl}documents/selection_data/`
|
|
||||||
)
|
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should use filter rules to update query params', () => {
|
it('should use filter rules to update query params', () => {
|
||||||
documentListViewService.setFilterRules(filterRules)
|
documentListViewService.setFilterRules(filterRules)
|
||||||
const req = httpTestingController.expectOne(
|
const req = httpTestingController.expectOne(
|
||||||
`${environment.apiBaseUrl}documents/?page=${documentListViewService.currentPage}&page_size=${documentListViewService.pageSize}&ordering=-created&truncate_content=true&tags__id__all=${tags__id__all}`
|
`${environment.apiBaseUrl}documents/?page=${documentListViewService.currentPage}&page_size=${documentListViewService.pageSize}&ordering=-created&truncate_content=true&include_selection_data=true&tags__id__all=${tags__id__all}`
|
||||||
)
|
)
|
||||||
expect(req.request.method).toEqual('GET')
|
expect(req.request.method).toEqual('GET')
|
||||||
})
|
})
|
||||||
@@ -330,34 +324,26 @@ describe('DocumentListViewService', () => {
|
|||||||
documentListViewService.currentPage = 2
|
documentListViewService.currentPage = 2
|
||||||
let req = httpTestingController.expectOne((request) =>
|
let req = httpTestingController.expectOne((request) =>
|
||||||
request.urlWithParams.startsWith(
|
request.urlWithParams.startsWith(
|
||||||
`${environment.apiBaseUrl}documents/?page=2&page_size=50&ordering=-created&truncate_content=true`
|
`${environment.apiBaseUrl}documents/?page=2&page_size=50&ordering=-created&truncate_content=true&include_selection_data=true`
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
expect(req.request.method).toEqual('GET')
|
expect(req.request.method).toEqual('GET')
|
||||||
req.flush(full_results)
|
req.flush(full_results)
|
||||||
req = httpTestingController.expectOne(
|
|
||||||
`${environment.apiBaseUrl}documents/selection_data/`
|
|
||||||
)
|
|
||||||
req.flush([])
|
|
||||||
|
|
||||||
documentListViewService.setFilterRules(filterRules, true)
|
documentListViewService.setFilterRules(filterRules, true)
|
||||||
|
|
||||||
const filteredReqs = httpTestingController.match(
|
const filteredReqs = httpTestingController.match(
|
||||||
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true&tags__id__all=${tags__id__all}`
|
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true&include_selection_data=true&tags__id__all=${tags__id__all}`
|
||||||
)
|
)
|
||||||
expect(filteredReqs).toHaveLength(1)
|
expect(filteredReqs).toHaveLength(1)
|
||||||
filteredReqs[0].flush(full_results)
|
filteredReqs[0].flush(full_results)
|
||||||
req = httpTestingController.expectOne(
|
|
||||||
`${environment.apiBaseUrl}documents/selection_data/`
|
|
||||||
)
|
|
||||||
req.flush([])
|
|
||||||
expect(documentListViewService.currentPage).toEqual(1)
|
expect(documentListViewService.currentPage).toEqual(1)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should support quick filter', () => {
|
it('should support quick filter', () => {
|
||||||
documentListViewService.quickFilter(filterRules)
|
documentListViewService.quickFilter(filterRules)
|
||||||
const req = httpTestingController.expectOne(
|
const req = httpTestingController.expectOne(
|
||||||
`${environment.apiBaseUrl}documents/?page=${documentListViewService.currentPage}&page_size=${documentListViewService.pageSize}&ordering=-created&truncate_content=true&tags__id__all=${tags__id__all}`
|
`${environment.apiBaseUrl}documents/?page=${documentListViewService.currentPage}&page_size=${documentListViewService.pageSize}&ordering=-created&truncate_content=true&include_selection_data=true&tags__id__all=${tags__id__all}`
|
||||||
)
|
)
|
||||||
expect(req.request.method).toEqual('GET')
|
expect(req.request.method).toEqual('GET')
|
||||||
})
|
})
|
||||||
@@ -380,21 +366,21 @@ describe('DocumentListViewService', () => {
|
|||||||
convertToParamMap(params)
|
convertToParamMap(params)
|
||||||
)
|
)
|
||||||
let req = httpTestingController.expectOne(
|
let req = httpTestingController.expectOne(
|
||||||
`${environment.apiBaseUrl}documents/?page=${page}&page_size=${documentListViewService.pageSize}&ordering=-added&truncate_content=true&tags__id__all=${tags__id__all}`
|
`${environment.apiBaseUrl}documents/?page=${page}&page_size=${documentListViewService.pageSize}&ordering=-added&truncate_content=true&include_selection_data=true&tags__id__all=${tags__id__all}`
|
||||||
)
|
)
|
||||||
expect(req.request.method).toEqual('GET')
|
expect(req.request.method).toEqual('GET')
|
||||||
// reset the list
|
// reset the list
|
||||||
documentListViewService.currentPage = 1
|
documentListViewService.currentPage = 1
|
||||||
req = httpTestingController.expectOne(
|
req = httpTestingController.expectOne(
|
||||||
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-added&truncate_content=true&tags__id__all=9`
|
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-added&truncate_content=true&include_selection_data=true&tags__id__all=9`
|
||||||
)
|
)
|
||||||
documentListViewService.setFilterRules([])
|
documentListViewService.setFilterRules([])
|
||||||
req = httpTestingController.expectOne(
|
req = httpTestingController.expectOne(
|
||||||
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-added&truncate_content=true`
|
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-added&truncate_content=true&include_selection_data=true`
|
||||||
)
|
)
|
||||||
documentListViewService.sortField = 'created'
|
documentListViewService.sortField = 'created'
|
||||||
req = httpTestingController.expectOne(
|
req = httpTestingController.expectOne(
|
||||||
`${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&include_selection_data=true`
|
||||||
)
|
)
|
||||||
documentListViewService.activateSavedView(null)
|
documentListViewService.activateSavedView(null)
|
||||||
})
|
})
|
||||||
@@ -402,21 +388,18 @@ describe('DocumentListViewService', () => {
|
|||||||
it('should support navigating next / previous', () => {
|
it('should support navigating next / previous', () => {
|
||||||
documentListViewService.setFilterRules([])
|
documentListViewService.setFilterRules([])
|
||||||
let req = httpTestingController.expectOne(
|
let req = httpTestingController.expectOne(
|
||||||
`${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&include_selection_data=true`
|
||||||
)
|
)
|
||||||
expect(documentListViewService.currentPage).toEqual(1)
|
expect(documentListViewService.currentPage).toEqual(1)
|
||||||
documentListViewService.pageSize = 3
|
documentListViewService.pageSize = 3
|
||||||
req = httpTestingController.expectOne(
|
req = httpTestingController.expectOne(
|
||||||
`${environment.apiBaseUrl}documents/?page=1&page_size=3&ordering=-created&truncate_content=true`
|
`${environment.apiBaseUrl}documents/?page=1&page_size=3&ordering=-created&truncate_content=true&include_selection_data=true`
|
||||||
)
|
)
|
||||||
expect(req.request.method).toEqual('GET')
|
expect(req.request.method).toEqual('GET')
|
||||||
req.flush({
|
req.flush({
|
||||||
count: 3,
|
count: 3,
|
||||||
results: documents.slice(0, 3),
|
results: documents.slice(0, 3),
|
||||||
})
|
})
|
||||||
httpTestingController
|
|
||||||
.expectOne(`${environment.apiBaseUrl}documents/selection_data/`)
|
|
||||||
.flush([])
|
|
||||||
expect(documentListViewService.hasNext(documents[0].id)).toBeTruthy()
|
expect(documentListViewService.hasNext(documents[0].id)).toBeTruthy()
|
||||||
expect(documentListViewService.hasPrevious(documents[0].id)).toBeFalsy()
|
expect(documentListViewService.hasPrevious(documents[0].id)).toBeFalsy()
|
||||||
documentListViewService.getNext(documents[0].id).subscribe((docId) => {
|
documentListViewService.getNext(documents[0].id).subscribe((docId) => {
|
||||||
@@ -463,7 +446,7 @@ describe('DocumentListViewService', () => {
|
|||||||
expect(documentListViewService.currentPage).toEqual(1)
|
expect(documentListViewService.currentPage).toEqual(1)
|
||||||
documentListViewService.pageSize = 3
|
documentListViewService.pageSize = 3
|
||||||
httpTestingController.match(
|
httpTestingController.match(
|
||||||
`${environment.apiBaseUrl}documents/?page=1&page_size=3&ordering=-created&truncate_content=true`
|
`${environment.apiBaseUrl}documents/?page=1&page_size=3&ordering=-created&truncate_content=true&include_selection_data=true`
|
||||||
)
|
)
|
||||||
jest
|
jest
|
||||||
.spyOn(documentListViewService, 'getLastPage')
|
.spyOn(documentListViewService, 'getLastPage')
|
||||||
@@ -478,7 +461,7 @@ describe('DocumentListViewService', () => {
|
|||||||
expect(reloadSpy).toHaveBeenCalled()
|
expect(reloadSpy).toHaveBeenCalled()
|
||||||
expect(documentListViewService.currentPage).toEqual(2)
|
expect(documentListViewService.currentPage).toEqual(2)
|
||||||
const reqs = httpTestingController.match(
|
const reqs = httpTestingController.match(
|
||||||
`${environment.apiBaseUrl}documents/?page=2&page_size=3&ordering=-created&truncate_content=true`
|
`${environment.apiBaseUrl}documents/?page=2&page_size=3&ordering=-created&truncate_content=true&include_selection_data=true`
|
||||||
)
|
)
|
||||||
expect(reqs.length).toBeGreaterThan(0)
|
expect(reqs.length).toBeGreaterThan(0)
|
||||||
})
|
})
|
||||||
@@ -513,11 +496,11 @@ describe('DocumentListViewService', () => {
|
|||||||
.mockReturnValue(documents)
|
.mockReturnValue(documents)
|
||||||
documentListViewService.currentPage = 2
|
documentListViewService.currentPage = 2
|
||||||
httpTestingController.match(
|
httpTestingController.match(
|
||||||
`${environment.apiBaseUrl}documents/?page=2&page_size=50&ordering=-created&truncate_content=true`
|
`${environment.apiBaseUrl}documents/?page=2&page_size=50&ordering=-created&truncate_content=true&include_selection_data=true`
|
||||||
)
|
)
|
||||||
documentListViewService.pageSize = 3
|
documentListViewService.pageSize = 3
|
||||||
httpTestingController.match(
|
httpTestingController.match(
|
||||||
`${environment.apiBaseUrl}documents/?page=2&page_size=3&ordering=-created&truncate_content=true`
|
`${environment.apiBaseUrl}documents/?page=2&page_size=3&ordering=-created&truncate_content=true&include_selection_data=true`
|
||||||
)
|
)
|
||||||
const reloadSpy = jest.spyOn(documentListViewService, 'reload')
|
const reloadSpy = jest.spyOn(documentListViewService, 'reload')
|
||||||
documentListViewService.getPrevious(1).subscribe({
|
documentListViewService.getPrevious(1).subscribe({
|
||||||
@@ -527,7 +510,7 @@ describe('DocumentListViewService', () => {
|
|||||||
expect(reloadSpy).toHaveBeenCalled()
|
expect(reloadSpy).toHaveBeenCalled()
|
||||||
expect(documentListViewService.currentPage).toEqual(1)
|
expect(documentListViewService.currentPage).toEqual(1)
|
||||||
const reqs = httpTestingController.match(
|
const reqs = httpTestingController.match(
|
||||||
`${environment.apiBaseUrl}documents/?page=1&page_size=3&ordering=-created&truncate_content=true`
|
`${environment.apiBaseUrl}documents/?page=1&page_size=3&ordering=-created&truncate_content=true&include_selection_data=true`
|
||||||
)
|
)
|
||||||
expect(reqs.length).toBeGreaterThan(0)
|
expect(reqs.length).toBeGreaterThan(0)
|
||||||
})
|
})
|
||||||
@@ -540,13 +523,10 @@ describe('DocumentListViewService', () => {
|
|||||||
it('should support select a document', () => {
|
it('should support select a document', () => {
|
||||||
documentListViewService.reload()
|
documentListViewService.reload()
|
||||||
const req = httpTestingController.expectOne(
|
const req = httpTestingController.expectOne(
|
||||||
`${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&include_selection_data=true`
|
||||||
)
|
)
|
||||||
expect(req.request.method).toEqual('GET')
|
expect(req.request.method).toEqual('GET')
|
||||||
req.flush(full_results)
|
req.flush(full_results)
|
||||||
httpTestingController.expectOne(
|
|
||||||
`${environment.apiBaseUrl}documents/selection_data/`
|
|
||||||
)
|
|
||||||
documentListViewService.toggleSelected(documents[0])
|
documentListViewService.toggleSelected(documents[0])
|
||||||
expect(documentListViewService.isSelected(documents[0])).toBeTruthy()
|
expect(documentListViewService.isSelected(documents[0])).toBeTruthy()
|
||||||
documentListViewService.toggleSelected(documents[0])
|
documentListViewService.toggleSelected(documents[0])
|
||||||
@@ -554,12 +534,16 @@ describe('DocumentListViewService', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
it('should support select all', () => {
|
it('should support select all', () => {
|
||||||
documentListViewService.selectAll()
|
documentListViewService.reload()
|
||||||
const req = httpTestingController.expectOne(
|
const reloadReq = httpTestingController.expectOne(
|
||||||
`${environment.apiBaseUrl}documents/?page=1&page_size=100000&fields=id`
|
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true&include_selection_data=true`
|
||||||
)
|
)
|
||||||
expect(req.request.method).toEqual('GET')
|
expect(reloadReq.request.method).toEqual('GET')
|
||||||
req.flush(full_results)
|
reloadReq.flush(full_results)
|
||||||
|
|
||||||
|
documentListViewService.selectAll()
|
||||||
|
expect(documentListViewService.allSelected).toBeTruthy()
|
||||||
|
expect(documentListViewService.selectedCount).toEqual(documents.length)
|
||||||
expect(documentListViewService.selected.size).toEqual(documents.length)
|
expect(documentListViewService.selected.size).toEqual(documents.length)
|
||||||
expect(documentListViewService.isSelected(documents[0])).toBeTruthy()
|
expect(documentListViewService.isSelected(documents[0])).toBeTruthy()
|
||||||
documentListViewService.selectNone()
|
documentListViewService.selectNone()
|
||||||
@@ -568,16 +552,13 @@ describe('DocumentListViewService', () => {
|
|||||||
it('should support select page', () => {
|
it('should support select page', () => {
|
||||||
documentListViewService.pageSize = 3
|
documentListViewService.pageSize = 3
|
||||||
const req = httpTestingController.expectOne(
|
const req = httpTestingController.expectOne(
|
||||||
`${environment.apiBaseUrl}documents/?page=1&page_size=3&ordering=-created&truncate_content=true`
|
`${environment.apiBaseUrl}documents/?page=1&page_size=3&ordering=-created&truncate_content=true&include_selection_data=true`
|
||||||
)
|
)
|
||||||
expect(req.request.method).toEqual('GET')
|
expect(req.request.method).toEqual('GET')
|
||||||
req.flush({
|
req.flush({
|
||||||
count: 3,
|
count: 3,
|
||||||
results: documents.slice(0, 3),
|
results: documents.slice(0, 3),
|
||||||
})
|
})
|
||||||
httpTestingController.expectOne(
|
|
||||||
`${environment.apiBaseUrl}documents/selection_data/`
|
|
||||||
)
|
|
||||||
documentListViewService.selectPage()
|
documentListViewService.selectPage()
|
||||||
expect(documentListViewService.selected.size).toEqual(3)
|
expect(documentListViewService.selected.size).toEqual(3)
|
||||||
expect(documentListViewService.isSelected(documents[5])).toBeFalsy()
|
expect(documentListViewService.isSelected(documents[5])).toBeFalsy()
|
||||||
@@ -586,13 +567,10 @@ describe('DocumentListViewService', () => {
|
|||||||
it('should support select range', () => {
|
it('should support select range', () => {
|
||||||
documentListViewService.reload()
|
documentListViewService.reload()
|
||||||
const req = httpTestingController.expectOne(
|
const req = httpTestingController.expectOne(
|
||||||
`${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&include_selection_data=true`
|
||||||
)
|
)
|
||||||
expect(req.request.method).toEqual('GET')
|
expect(req.request.method).toEqual('GET')
|
||||||
req.flush(full_results)
|
req.flush(full_results)
|
||||||
httpTestingController.expectOne(
|
|
||||||
`${environment.apiBaseUrl}documents/selection_data/`
|
|
||||||
)
|
|
||||||
documentListViewService.toggleSelected(documents[0])
|
documentListViewService.toggleSelected(documents[0])
|
||||||
expect(documentListViewService.isSelected(documents[0])).toBeTruthy()
|
expect(documentListViewService.isSelected(documents[0])).toBeTruthy()
|
||||||
documentListViewService.selectRangeTo(documents[2])
|
documentListViewService.selectRangeTo(documents[2])
|
||||||
@@ -601,26 +579,62 @@ describe('DocumentListViewService', () => {
|
|||||||
expect(documentListViewService.isSelected(documents[3])).toBeTruthy()
|
expect(documentListViewService.isSelected(documents[3])).toBeTruthy()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should support selection range reduction', () => {
|
it('should clear all-selected mode when toggling a single document', () => {
|
||||||
|
documentListViewService.reload()
|
||||||
|
const req = httpTestingController.expectOne(
|
||||||
|
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true&include_selection_data=true`
|
||||||
|
)
|
||||||
|
req.flush(full_results)
|
||||||
|
|
||||||
documentListViewService.selectAll()
|
documentListViewService.selectAll()
|
||||||
|
expect(documentListViewService.allSelected).toBeTruthy()
|
||||||
|
|
||||||
|
documentListViewService.toggleSelected(documents[0])
|
||||||
|
|
||||||
|
expect(documentListViewService.allSelected).toBeFalsy()
|
||||||
|
expect(documentListViewService.isSelected(documents[0])).toBeFalsy()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should clear all-selected mode when selecting a range', () => {
|
||||||
|
documentListViewService.reload()
|
||||||
|
const req = httpTestingController.expectOne(
|
||||||
|
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true&include_selection_data=true`
|
||||||
|
)
|
||||||
|
req.flush(full_results)
|
||||||
|
|
||||||
|
documentListViewService.selectAll()
|
||||||
|
documentListViewService.toggleSelected(documents[1])
|
||||||
|
documentListViewService.selectAll()
|
||||||
|
expect(documentListViewService.allSelected).toBeTruthy()
|
||||||
|
|
||||||
|
documentListViewService.selectRangeTo(documents[3])
|
||||||
|
|
||||||
|
expect(documentListViewService.allSelected).toBeFalsy()
|
||||||
|
expect(documentListViewService.isSelected(documents[1])).toBeTruthy()
|
||||||
|
expect(documentListViewService.isSelected(documents[2])).toBeTruthy()
|
||||||
|
expect(documentListViewService.isSelected(documents[3])).toBeTruthy()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should support selection range reduction', () => {
|
||||||
|
documentListViewService.reload()
|
||||||
let req = httpTestingController.expectOne(
|
let req = httpTestingController.expectOne(
|
||||||
`${environment.apiBaseUrl}documents/?page=1&page_size=100000&fields=id`
|
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true&include_selection_data=true`
|
||||||
)
|
)
|
||||||
expect(req.request.method).toEqual('GET')
|
expect(req.request.method).toEqual('GET')
|
||||||
req.flush(full_results)
|
req.flush(full_results)
|
||||||
|
|
||||||
|
documentListViewService.selectAll()
|
||||||
expect(documentListViewService.selected.size).toEqual(6)
|
expect(documentListViewService.selected.size).toEqual(6)
|
||||||
|
|
||||||
documentListViewService.setFilterRules(filterRules)
|
documentListViewService.setFilterRules(filterRules)
|
||||||
httpTestingController.expectOne(
|
req = httpTestingController.expectOne(
|
||||||
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true&tags__id__all=9`
|
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true&include_selection_data=true&tags__id__all=9`
|
||||||
)
|
)
|
||||||
const reqs = httpTestingController.match(
|
req.flush({
|
||||||
`${environment.apiBaseUrl}documents/?page=1&page_size=100000&fields=id&tags__id__all=9`
|
|
||||||
)
|
|
||||||
reqs[0].flush({
|
|
||||||
count: 3,
|
count: 3,
|
||||||
results: documents.slice(0, 3),
|
results: documents.slice(0, 3),
|
||||||
})
|
})
|
||||||
|
expect(documentListViewService.allSelected).toBeTruthy()
|
||||||
expect(documentListViewService.selected.size).toEqual(3)
|
expect(documentListViewService.selected.size).toEqual(3)
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -628,7 +642,7 @@ describe('DocumentListViewService', () => {
|
|||||||
const cancelSpy = jest.spyOn(documentListViewService, 'cancelPending')
|
const cancelSpy = jest.spyOn(documentListViewService, 'cancelPending')
|
||||||
documentListViewService.reload()
|
documentListViewService.reload()
|
||||||
httpTestingController.expectOne(
|
httpTestingController.expectOne(
|
||||||
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true&tags__id__all=9`
|
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true&include_selection_data=true&tags__id__all=9`
|
||||||
)
|
)
|
||||||
expect(cancelSpy).toHaveBeenCalled()
|
expect(cancelSpy).toHaveBeenCalled()
|
||||||
})
|
})
|
||||||
@@ -647,7 +661,7 @@ describe('DocumentListViewService', () => {
|
|||||||
documentListViewService.setFilterRules([])
|
documentListViewService.setFilterRules([])
|
||||||
expect(documentListViewService.sortField).toEqual('created')
|
expect(documentListViewService.sortField).toEqual('created')
|
||||||
httpTestingController.expectOne(
|
httpTestingController.expectOne(
|
||||||
`${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&include_selection_data=true`
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -674,11 +688,11 @@ describe('DocumentListViewService', () => {
|
|||||||
expect(localStorageSpy).toHaveBeenCalled()
|
expect(localStorageSpy).toHaveBeenCalled()
|
||||||
// reload triggered
|
// reload triggered
|
||||||
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&include_selection_data=true`
|
||||||
)
|
)
|
||||||
documentListViewService.displayFields = null
|
documentListViewService.displayFields = null
|
||||||
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&include_selection_data=true`
|
||||||
)
|
)
|
||||||
expect(documentListViewService.displayFields).toEqual(
|
expect(documentListViewService.displayFields).toEqual(
|
||||||
DEFAULT_DISPLAY_FIELDS.filter((f) => f.id !== DisplayField.ADDED).map(
|
DEFAULT_DISPLAY_FIELDS.filter((f) => f.id !== DisplayField.ADDED).map(
|
||||||
@@ -718,7 +732,7 @@ describe('DocumentListViewService', () => {
|
|||||||
it('should generate quick filter URL preserving default state', () => {
|
it('should generate quick filter URL preserving default state', () => {
|
||||||
documentListViewService.reload()
|
documentListViewService.reload()
|
||||||
httpTestingController.expectOne(
|
httpTestingController.expectOne(
|
||||||
`${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&include_selection_data=true`
|
||||||
)
|
)
|
||||||
const urlTree = documentListViewService.getQuickFilterUrl(filterRules)
|
const urlTree = documentListViewService.getQuickFilterUrl(filterRules)
|
||||||
expect(urlTree).toBeDefined()
|
expect(urlTree).toBeDefined()
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { Injectable, inject } from '@angular/core'
|
import { Injectable, inject } from '@angular/core'
|
||||||
import { ParamMap, Router, UrlTree } from '@angular/router'
|
import { ParamMap, Router, UrlTree } from '@angular/router'
|
||||||
import { Observable, Subject, first, takeUntil } from 'rxjs'
|
import { Observable, Subject, takeUntil } from 'rxjs'
|
||||||
import {
|
import {
|
||||||
DEFAULT_DISPLAY_FIELDS,
|
DEFAULT_DISPLAY_FIELDS,
|
||||||
DisplayField,
|
DisplayField,
|
||||||
@@ -8,6 +8,7 @@ import {
|
|||||||
Document,
|
Document,
|
||||||
} from '../data/document'
|
} from '../data/document'
|
||||||
import { FilterRule } from '../data/filter-rule'
|
import { FilterRule } from '../data/filter-rule'
|
||||||
|
import { DocumentResults, SelectionData } from '../data/results'
|
||||||
import { SavedView } from '../data/saved-view'
|
import { SavedView } from '../data/saved-view'
|
||||||
import { DOCUMENT_LIST_SERVICE } from '../data/storage-keys'
|
import { DOCUMENT_LIST_SERVICE } from '../data/storage-keys'
|
||||||
import { SETTINGS_KEYS } from '../data/ui-settings'
|
import { SETTINGS_KEYS } from '../data/ui-settings'
|
||||||
@@ -17,7 +18,7 @@ import {
|
|||||||
isFullTextFilterRule,
|
isFullTextFilterRule,
|
||||||
} from '../utils/filter-rules'
|
} from '../utils/filter-rules'
|
||||||
import { paramsFromViewState, paramsToViewState } from '../utils/query-params'
|
import { paramsFromViewState, paramsToViewState } from '../utils/query-params'
|
||||||
import { DocumentService, SelectionData } from './rest/document.service'
|
import { DocumentService } from './rest/document.service'
|
||||||
import { SettingsService } from './settings.service'
|
import { SettingsService } from './settings.service'
|
||||||
|
|
||||||
const LIST_DEFAULT_DISPLAY_FIELDS: DisplayField[] = DEFAULT_DISPLAY_FIELDS.map(
|
const LIST_DEFAULT_DISPLAY_FIELDS: DisplayField[] = DEFAULT_DISPLAY_FIELDS.map(
|
||||||
@@ -79,6 +80,11 @@ export interface ListViewState {
|
|||||||
*/
|
*/
|
||||||
selected?: Set<number>
|
selected?: Set<number>
|
||||||
|
|
||||||
|
/**
|
||||||
|
* True if the full filtered result set is selected.
|
||||||
|
*/
|
||||||
|
allSelected?: boolean
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The page size of the list view.
|
* The page size of the list view.
|
||||||
*/
|
*/
|
||||||
@@ -198,6 +204,20 @@ export class DocumentListViewService {
|
|||||||
sortReverse: true,
|
sortReverse: true,
|
||||||
filterRules: [],
|
filterRules: [],
|
||||||
selected: new Set<number>(),
|
selected: new Set<number>(),
|
||||||
|
allSelected: false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private syncSelectedToCurrentPage() {
|
||||||
|
if (!this.allSelected) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
this.selected.clear()
|
||||||
|
this.documents?.forEach((doc) => this.selected.add(doc.id))
|
||||||
|
|
||||||
|
if (!this.collectionSize) {
|
||||||
|
this.selectNone()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -293,27 +313,18 @@ export class DocumentListViewService {
|
|||||||
activeListViewState.sortField,
|
activeListViewState.sortField,
|
||||||
activeListViewState.sortReverse,
|
activeListViewState.sortReverse,
|
||||||
activeListViewState.filterRules,
|
activeListViewState.filterRules,
|
||||||
{ truncate_content: true }
|
{ truncate_content: true, include_selection_data: true }
|
||||||
)
|
)
|
||||||
.pipe(takeUntil(this.unsubscribeNotifier))
|
.pipe(takeUntil(this.unsubscribeNotifier))
|
||||||
.subscribe({
|
.subscribe({
|
||||||
next: (result) => {
|
next: (result) => {
|
||||||
|
const resultWithSelectionData = result as DocumentResults
|
||||||
this.initialized = true
|
this.initialized = true
|
||||||
this.isReloading = false
|
this.isReloading = false
|
||||||
activeListViewState.collectionSize = result.count
|
activeListViewState.collectionSize = result.count
|
||||||
activeListViewState.documents = result.results
|
activeListViewState.documents = result.results
|
||||||
|
this.selectionData = resultWithSelectionData.selection_data ?? null
|
||||||
this.documentService
|
this.syncSelectedToCurrentPage()
|
||||||
.getSelectionData(result.all)
|
|
||||||
.pipe(first())
|
|
||||||
.subscribe({
|
|
||||||
next: (selectionData) => {
|
|
||||||
this.selectionData = selectionData
|
|
||||||
},
|
|
||||||
error: () => {
|
|
||||||
this.selectionData = null
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
if (updateQueryParams && !this._activeSavedViewId) {
|
if (updateQueryParams && !this._activeSavedViewId) {
|
||||||
let base = ['/documents']
|
let base = ['/documents']
|
||||||
@@ -446,6 +457,20 @@ export class DocumentListViewService {
|
|||||||
return this.activeListViewState.selected
|
return this.activeListViewState.selected
|
||||||
}
|
}
|
||||||
|
|
||||||
|
get allSelected(): boolean {
|
||||||
|
return this.activeListViewState.allSelected ?? false
|
||||||
|
}
|
||||||
|
|
||||||
|
get selectedCount(): number {
|
||||||
|
return this.allSelected
|
||||||
|
? (this.collectionSize ?? this.selected.size)
|
||||||
|
: this.selected.size
|
||||||
|
}
|
||||||
|
|
||||||
|
get hasSelection(): boolean {
|
||||||
|
return this.allSelected || this.selected.size > 0
|
||||||
|
}
|
||||||
|
|
||||||
setSort(field: string, reverse: boolean) {
|
setSort(field: string, reverse: boolean) {
|
||||||
this.activeListViewState.sortField = field
|
this.activeListViewState.sortField = field
|
||||||
this.activeListViewState.sortReverse = reverse
|
this.activeListViewState.sortReverse = reverse
|
||||||
@@ -600,11 +625,16 @@ export class DocumentListViewService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
selectNone() {
|
selectNone() {
|
||||||
|
this.activeListViewState.allSelected = false
|
||||||
this.selected.clear()
|
this.selected.clear()
|
||||||
this.rangeSelectionAnchorIndex = this.lastRangeSelectionToIndex = null
|
this.rangeSelectionAnchorIndex = this.lastRangeSelectionToIndex = null
|
||||||
}
|
}
|
||||||
|
|
||||||
reduceSelectionToFilter() {
|
reduceSelectionToFilter() {
|
||||||
|
if (this.allSelected) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
if (this.selected.size > 0) {
|
if (this.selected.size > 0) {
|
||||||
this.documentService
|
this.documentService
|
||||||
.listAllFilteredIds(this.filterRules)
|
.listAllFilteredIds(this.filterRules)
|
||||||
@@ -619,12 +649,12 @@ export class DocumentListViewService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
selectAll() {
|
selectAll() {
|
||||||
this.documentService
|
this.activeListViewState.allSelected = true
|
||||||
.listAllFilteredIds(this.filterRules)
|
this.syncSelectedToCurrentPage()
|
||||||
.subscribe((ids) => ids.forEach((id) => this.selected.add(id)))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
selectPage() {
|
selectPage() {
|
||||||
|
this.activeListViewState.allSelected = false
|
||||||
this.selected.clear()
|
this.selected.clear()
|
||||||
this.documents.forEach((doc) => {
|
this.documents.forEach((doc) => {
|
||||||
this.selected.add(doc.id)
|
this.selected.add(doc.id)
|
||||||
@@ -632,10 +662,13 @@ export class DocumentListViewService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
isSelected(d: Document) {
|
isSelected(d: Document) {
|
||||||
return this.selected.has(d.id)
|
return this.allSelected || this.selected.has(d.id)
|
||||||
}
|
}
|
||||||
|
|
||||||
toggleSelected(d: Document): void {
|
toggleSelected(d: Document): void {
|
||||||
|
if (this.allSelected) {
|
||||||
|
this.activeListViewState.allSelected = false
|
||||||
|
}
|
||||||
if (this.selected.has(d.id)) this.selected.delete(d.id)
|
if (this.selected.has(d.id)) this.selected.delete(d.id)
|
||||||
else this.selected.add(d.id)
|
else this.selected.add(d.id)
|
||||||
this.rangeSelectionAnchorIndex = this.documentIndexInCurrentView(d.id)
|
this.rangeSelectionAnchorIndex = this.documentIndexInCurrentView(d.id)
|
||||||
@@ -643,6 +676,10 @@ export class DocumentListViewService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
selectRangeTo(d: Document) {
|
selectRangeTo(d: Document) {
|
||||||
|
if (this.allSelected) {
|
||||||
|
this.activeListViewState.allSelected = false
|
||||||
|
}
|
||||||
|
|
||||||
if (this.rangeSelectionAnchorIndex !== null) {
|
if (this.rangeSelectionAnchorIndex !== null) {
|
||||||
const documentToIndex = this.documentIndexInCurrentView(d.id)
|
const documentToIndex = this.documentIndexInCurrentView(d.id)
|
||||||
const fromIndex = Math.min(
|
const fromIndex = Math.min(
|
||||||
|
|||||||
@@ -96,6 +96,30 @@ export const commonAbstractNameFilterPaperlessServiceTests = (
|
|||||||
})
|
})
|
||||||
req.flush([])
|
req.flush([])
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test('should call appropriate api endpoint for bulk delete on all filtered objects', () => {
|
||||||
|
subscription = service
|
||||||
|
.bulk_edit_objects(
|
||||||
|
[],
|
||||||
|
BulkEditObjectOperation.Delete,
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
true,
|
||||||
|
{ name__icontains: 'hello' }
|
||||||
|
)
|
||||||
|
.subscribe()
|
||||||
|
const req = httpTestingController.expectOne(
|
||||||
|
`${environment.apiBaseUrl}bulk_edit_objects/`
|
||||||
|
)
|
||||||
|
expect(req.request.method).toEqual('POST')
|
||||||
|
expect(req.request.body).toEqual({
|
||||||
|
object_type: endpoint,
|
||||||
|
operation: BulkEditObjectOperation.Delete,
|
||||||
|
all: true,
|
||||||
|
filters: { name__icontains: 'hello' },
|
||||||
|
})
|
||||||
|
req.flush([])
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
|
|||||||
@@ -37,13 +37,22 @@ export abstract class AbstractNameFilterService<
|
|||||||
objects: Array<number>,
|
objects: Array<number>,
|
||||||
operation: BulkEditObjectOperation,
|
operation: BulkEditObjectOperation,
|
||||||
permissions: { owner: number; set_permissions: PermissionsObject } = null,
|
permissions: { owner: number; set_permissions: PermissionsObject } = null,
|
||||||
merge: boolean = null
|
merge: boolean = null,
|
||||||
|
all: boolean = false,
|
||||||
|
filters: { [key: string]: any } = null
|
||||||
): Observable<string> {
|
): Observable<string> {
|
||||||
const params = {
|
const params: any = {
|
||||||
objects,
|
|
||||||
object_type: this.resourceName,
|
object_type: this.resourceName,
|
||||||
operation,
|
operation,
|
||||||
}
|
}
|
||||||
|
if (all) {
|
||||||
|
params['all'] = true
|
||||||
|
if (filters) {
|
||||||
|
params['filters'] = filters
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
params['objects'] = objects
|
||||||
|
}
|
||||||
if (operation === BulkEditObjectOperation.SetPermissions) {
|
if (operation === BulkEditObjectOperation.SetPermissions) {
|
||||||
params['owner'] = permissions?.owner
|
params['owner'] = permissions?.owner
|
||||||
params['permissions'] = permissions?.set_permissions
|
params['permissions'] = permissions?.set_permissions
|
||||||
|
|||||||
@@ -198,7 +198,7 @@ describe(`DocumentService`, () => {
|
|||||||
const content = 'both'
|
const content = 'both'
|
||||||
const useFilenameFormatting = false
|
const useFilenameFormatting = false
|
||||||
subscription = service
|
subscription = service
|
||||||
.bulkDownload(ids, content, useFilenameFormatting)
|
.bulkDownload({ documents: ids }, content, useFilenameFormatting)
|
||||||
.subscribe()
|
.subscribe()
|
||||||
const req = httpTestingController.expectOne(
|
const req = httpTestingController.expectOne(
|
||||||
`${environment.apiBaseUrl}${endpoint}/bulk_download/`
|
`${environment.apiBaseUrl}${endpoint}/bulk_download/`
|
||||||
@@ -218,7 +218,9 @@ describe(`DocumentService`, () => {
|
|||||||
add_tags: [15],
|
add_tags: [15],
|
||||||
remove_tags: [6],
|
remove_tags: [6],
|
||||||
}
|
}
|
||||||
subscription = service.bulkEdit(ids, method, parameters).subscribe()
|
subscription = service
|
||||||
|
.bulkEdit({ documents: ids }, method, parameters)
|
||||||
|
.subscribe()
|
||||||
const req = httpTestingController.expectOne(
|
const req = httpTestingController.expectOne(
|
||||||
`${environment.apiBaseUrl}${endpoint}/bulk_edit/`
|
`${environment.apiBaseUrl}${endpoint}/bulk_edit/`
|
||||||
)
|
)
|
||||||
@@ -230,9 +232,32 @@ describe(`DocumentService`, () => {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('should call appropriate api endpoint for bulk edit with all and filters', () => {
|
||||||
|
const method = 'modify_tags'
|
||||||
|
const parameters = {
|
||||||
|
add_tags: [15],
|
||||||
|
remove_tags: [6],
|
||||||
|
}
|
||||||
|
const selection = {
|
||||||
|
all: true,
|
||||||
|
filters: { title__icontains: 'apple' },
|
||||||
|
}
|
||||||
|
subscription = service.bulkEdit(selection, method, parameters).subscribe()
|
||||||
|
const req = httpTestingController.expectOne(
|
||||||
|
`${environment.apiBaseUrl}${endpoint}/bulk_edit/`
|
||||||
|
)
|
||||||
|
expect(req.request.method).toEqual('POST')
|
||||||
|
expect(req.request.body).toEqual({
|
||||||
|
all: true,
|
||||||
|
filters: { title__icontains: 'apple' },
|
||||||
|
method,
|
||||||
|
parameters,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
it('should call appropriate api endpoint for delete documents', () => {
|
it('should call appropriate api endpoint for delete documents', () => {
|
||||||
const ids = [1, 2, 3]
|
const ids = [1, 2, 3]
|
||||||
subscription = service.deleteDocuments(ids).subscribe()
|
subscription = service.deleteDocuments({ documents: ids }).subscribe()
|
||||||
const req = httpTestingController.expectOne(
|
const req = httpTestingController.expectOne(
|
||||||
`${environment.apiBaseUrl}${endpoint}/delete/`
|
`${environment.apiBaseUrl}${endpoint}/delete/`
|
||||||
)
|
)
|
||||||
@@ -244,7 +269,7 @@ describe(`DocumentService`, () => {
|
|||||||
|
|
||||||
it('should call appropriate api endpoint for reprocess documents', () => {
|
it('should call appropriate api endpoint for reprocess documents', () => {
|
||||||
const ids = [1, 2, 3]
|
const ids = [1, 2, 3]
|
||||||
subscription = service.reprocessDocuments(ids).subscribe()
|
subscription = service.reprocessDocuments({ documents: ids }).subscribe()
|
||||||
const req = httpTestingController.expectOne(
|
const req = httpTestingController.expectOne(
|
||||||
`${environment.apiBaseUrl}${endpoint}/reprocess/`
|
`${environment.apiBaseUrl}${endpoint}/reprocess/`
|
||||||
)
|
)
|
||||||
@@ -256,7 +281,7 @@ describe(`DocumentService`, () => {
|
|||||||
|
|
||||||
it('should call appropriate api endpoint for rotate documents', () => {
|
it('should call appropriate api endpoint for rotate documents', () => {
|
||||||
const ids = [1, 2, 3]
|
const ids = [1, 2, 3]
|
||||||
subscription = service.rotateDocuments(ids, 90).subscribe()
|
subscription = service.rotateDocuments({ documents: ids }, 90).subscribe()
|
||||||
const req = httpTestingController.expectOne(
|
const req = httpTestingController.expectOne(
|
||||||
`${environment.apiBaseUrl}${endpoint}/rotate/`
|
`${environment.apiBaseUrl}${endpoint}/rotate/`
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ import {
|
|||||||
import { DocumentMetadata } from 'src/app/data/document-metadata'
|
import { DocumentMetadata } from 'src/app/data/document-metadata'
|
||||||
import { DocumentSuggestions } from 'src/app/data/document-suggestions'
|
import { DocumentSuggestions } from 'src/app/data/document-suggestions'
|
||||||
import { FilterRule } from 'src/app/data/filter-rule'
|
import { FilterRule } from 'src/app/data/filter-rule'
|
||||||
import { Results } from 'src/app/data/results'
|
import { Results, SelectionData } from 'src/app/data/results'
|
||||||
import { SETTINGS_KEYS } from 'src/app/data/ui-settings'
|
import { SETTINGS_KEYS } from 'src/app/data/ui-settings'
|
||||||
import { queryParamsFromFilterRules } from '../../utils/query-params'
|
import { queryParamsFromFilterRules } from '../../utils/query-params'
|
||||||
import {
|
import {
|
||||||
@@ -24,19 +24,6 @@ import { SettingsService } from '../settings.service'
|
|||||||
import { AbstractPaperlessService } from './abstract-paperless-service'
|
import { AbstractPaperlessService } from './abstract-paperless-service'
|
||||||
import { CustomFieldsService } from './custom-fields.service'
|
import { CustomFieldsService } from './custom-fields.service'
|
||||||
|
|
||||||
export interface SelectionDataItem {
|
|
||||||
id: number
|
|
||||||
document_count: number
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface SelectionData {
|
|
||||||
selected_storage_paths: SelectionDataItem[]
|
|
||||||
selected_correspondents: SelectionDataItem[]
|
|
||||||
selected_tags: SelectionDataItem[]
|
|
||||||
selected_document_types: SelectionDataItem[]
|
|
||||||
selected_custom_fields: SelectionDataItem[]
|
|
||||||
}
|
|
||||||
|
|
||||||
export enum BulkEditSourceMode {
|
export enum BulkEditSourceMode {
|
||||||
LATEST_VERSION = 'latest_version',
|
LATEST_VERSION = 'latest_version',
|
||||||
EXPLICIT_SELECTION = 'explicit_selection',
|
EXPLICIT_SELECTION = 'explicit_selection',
|
||||||
@@ -81,6 +68,12 @@ export interface RemovePasswordDocumentsRequest {
|
|||||||
source_mode?: BulkEditSourceMode
|
source_mode?: BulkEditSourceMode
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface DocumentSelectionQuery {
|
||||||
|
documents?: number[]
|
||||||
|
all?: boolean
|
||||||
|
filters?: { [key: string]: any }
|
||||||
|
}
|
||||||
|
|
||||||
@Injectable({
|
@Injectable({
|
||||||
providedIn: 'root',
|
providedIn: 'root',
|
||||||
})
|
})
|
||||||
@@ -338,33 +331,37 @@ 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(
|
||||||
|
selection: DocumentSelectionQuery,
|
||||||
|
method: DocumentBulkEditMethod,
|
||||||
|
args: any
|
||||||
|
) {
|
||||||
return this.http.post(this.getResourceUrl(null, 'bulk_edit'), {
|
return this.http.post(this.getResourceUrl(null, 'bulk_edit'), {
|
||||||
documents: ids,
|
...selection,
|
||||||
method: method,
|
method: method,
|
||||||
parameters: args,
|
parameters: args,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
deleteDocuments(ids: number[]) {
|
deleteDocuments(selection: DocumentSelectionQuery) {
|
||||||
return this.http.post(this.getResourceUrl(null, 'delete'), {
|
return this.http.post(this.getResourceUrl(null, 'delete'), {
|
||||||
documents: ids,
|
...selection,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
reprocessDocuments(ids: number[]) {
|
reprocessDocuments(selection: DocumentSelectionQuery) {
|
||||||
return this.http.post(this.getResourceUrl(null, 'reprocess'), {
|
return this.http.post(this.getResourceUrl(null, 'reprocess'), {
|
||||||
documents: ids,
|
...selection,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
rotateDocuments(
|
rotateDocuments(
|
||||||
ids: number[],
|
selection: DocumentSelectionQuery,
|
||||||
degrees: number,
|
degrees: number,
|
||||||
sourceMode: BulkEditSourceMode = BulkEditSourceMode.LATEST_VERSION
|
sourceMode: BulkEditSourceMode = BulkEditSourceMode.LATEST_VERSION
|
||||||
) {
|
) {
|
||||||
return this.http.post(this.getResourceUrl(null, 'rotate'), {
|
return this.http.post(this.getResourceUrl(null, 'rotate'), {
|
||||||
documents: ids,
|
...selection,
|
||||||
degrees,
|
degrees,
|
||||||
source_mode: sourceMode,
|
source_mode: sourceMode,
|
||||||
})
|
})
|
||||||
@@ -412,14 +409,14 @@ export class DocumentService extends AbstractPaperlessService<Document> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
bulkDownload(
|
bulkDownload(
|
||||||
ids: number[],
|
selection: DocumentSelectionQuery,
|
||||||
content = 'both',
|
content = 'both',
|
||||||
useFilenameFormatting: boolean = false
|
useFilenameFormatting: boolean = false
|
||||||
) {
|
) {
|
||||||
return this.http.post(
|
return this.http.post(
|
||||||
this.getResourceUrl(null, 'bulk_download'),
|
this.getResourceUrl(null, 'bulk_download'),
|
||||||
{
|
{
|
||||||
documents: ids,
|
...selection,
|
||||||
content: content,
|
content: content,
|
||||||
follow_formatting: useFilenameFormatting,
|
follow_formatting: useFilenameFormatting,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -100,24 +100,23 @@ class DocumentAdmin(GuardedModelAdmin):
|
|||||||
return Document.global_objects.all()
|
return Document.global_objects.all()
|
||||||
|
|
||||||
def delete_queryset(self, request, queryset):
|
def delete_queryset(self, request, queryset):
|
||||||
from documents import index
|
from documents.search import get_backend
|
||||||
|
|
||||||
with index.open_index_writer() as writer:
|
with get_backend().batch_update() as batch:
|
||||||
for o in queryset:
|
for o in queryset:
|
||||||
index.remove_document(writer, o)
|
batch.remove(o.pk)
|
||||||
|
|
||||||
super().delete_queryset(request, queryset)
|
super().delete_queryset(request, queryset)
|
||||||
|
|
||||||
def delete_model(self, request, obj):
|
def delete_model(self, request, obj):
|
||||||
from documents import index
|
from documents.search import get_backend
|
||||||
|
|
||||||
index.remove_document_from_index(obj)
|
get_backend().remove(obj.pk)
|
||||||
super().delete_model(request, obj)
|
super().delete_model(request, obj)
|
||||||
|
|
||||||
def save_model(self, request, obj, form, change):
|
def save_model(self, request, obj, form, change):
|
||||||
from documents import index
|
from documents.search import get_backend
|
||||||
|
|
||||||
index.add_or_update_document(obj)
|
get_backend().add_or_update(obj)
|
||||||
super().save_model(request, obj, form, change)
|
super().save_model(request, obj, form, change)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -349,11 +349,11 @@ def delete(doc_ids: list[int]) -> Literal["OK"]:
|
|||||||
|
|
||||||
Document.objects.filter(id__in=delete_ids).delete()
|
Document.objects.filter(id__in=delete_ids).delete()
|
||||||
|
|
||||||
from documents import index
|
from documents.search import get_backend
|
||||||
|
|
||||||
with index.open_index_writer() as writer:
|
with get_backend().batch_update() as batch:
|
||||||
for id in delete_ids:
|
for id in delete_ids:
|
||||||
index.remove_document_by_id(writer, id)
|
batch.remove(id)
|
||||||
|
|
||||||
status_mgr = DocumentsStatusManager()
|
status_mgr = DocumentsStatusManager()
|
||||||
status_mgr.send_documents_deleted(delete_ids)
|
status_mgr.send_documents_deleted(delete_ids)
|
||||||
|
|||||||
@@ -1,655 +0,0 @@
|
|||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import logging
|
|
||||||
import math
|
|
||||||
import re
|
|
||||||
from collections import Counter
|
|
||||||
from contextlib import contextmanager
|
|
||||||
from datetime import UTC
|
|
||||||
from datetime import datetime
|
|
||||||
from datetime import time
|
|
||||||
from datetime import timedelta
|
|
||||||
from shutil import rmtree
|
|
||||||
from time import sleep
|
|
||||||
from typing import TYPE_CHECKING
|
|
||||||
from typing import Literal
|
|
||||||
|
|
||||||
from dateutil.relativedelta import relativedelta
|
|
||||||
from django.conf import settings
|
|
||||||
from django.utils import timezone as django_timezone
|
|
||||||
from django.utils.timezone import get_current_timezone
|
|
||||||
from django.utils.timezone import now
|
|
||||||
from guardian.shortcuts import get_users_with_perms
|
|
||||||
from whoosh import classify
|
|
||||||
from whoosh import highlight
|
|
||||||
from whoosh import query
|
|
||||||
from whoosh.fields import BOOLEAN
|
|
||||||
from whoosh.fields import DATETIME
|
|
||||||
from whoosh.fields import KEYWORD
|
|
||||||
from whoosh.fields import NUMERIC
|
|
||||||
from whoosh.fields import TEXT
|
|
||||||
from whoosh.fields import Schema
|
|
||||||
from whoosh.highlight import HtmlFormatter
|
|
||||||
from whoosh.idsets import BitSet
|
|
||||||
from whoosh.idsets import DocIdSet
|
|
||||||
from whoosh.index import FileIndex
|
|
||||||
from whoosh.index import LockError
|
|
||||||
from whoosh.index import create_in
|
|
||||||
from whoosh.index import exists_in
|
|
||||||
from whoosh.index import open_dir
|
|
||||||
from whoosh.qparser import MultifieldParser
|
|
||||||
from whoosh.qparser import QueryParser
|
|
||||||
from whoosh.qparser.dateparse import DateParserPlugin
|
|
||||||
from whoosh.qparser.dateparse import English
|
|
||||||
from whoosh.qparser.plugins import FieldsPlugin
|
|
||||||
from whoosh.scoring import TF_IDF
|
|
||||||
from whoosh.util.times import timespan
|
|
||||||
from whoosh.writing import AsyncWriter
|
|
||||||
|
|
||||||
from documents.models import CustomFieldInstance
|
|
||||||
from documents.models import Document
|
|
||||||
from documents.models import Note
|
|
||||||
from documents.models import User
|
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
|
||||||
from django.db.models import QuerySet
|
|
||||||
from whoosh.reading import IndexReader
|
|
||||||
from whoosh.searching import ResultsPage
|
|
||||||
from whoosh.searching import Searcher
|
|
||||||
|
|
||||||
logger = logging.getLogger("paperless.index")
|
|
||||||
|
|
||||||
|
|
||||||
def get_schema() -> Schema:
|
|
||||||
return Schema(
|
|
||||||
id=NUMERIC(stored=True, unique=True),
|
|
||||||
title=TEXT(sortable=True),
|
|
||||||
content=TEXT(),
|
|
||||||
asn=NUMERIC(sortable=True, signed=False),
|
|
||||||
correspondent=TEXT(sortable=True),
|
|
||||||
correspondent_id=NUMERIC(),
|
|
||||||
has_correspondent=BOOLEAN(),
|
|
||||||
tag=KEYWORD(commas=True, scorable=True, lowercase=True),
|
|
||||||
tag_id=KEYWORD(commas=True, scorable=True),
|
|
||||||
has_tag=BOOLEAN(),
|
|
||||||
type=TEXT(sortable=True),
|
|
||||||
type_id=NUMERIC(),
|
|
||||||
has_type=BOOLEAN(),
|
|
||||||
created=DATETIME(sortable=True),
|
|
||||||
modified=DATETIME(sortable=True),
|
|
||||||
added=DATETIME(sortable=True),
|
|
||||||
path=TEXT(sortable=True),
|
|
||||||
path_id=NUMERIC(),
|
|
||||||
has_path=BOOLEAN(),
|
|
||||||
notes=TEXT(),
|
|
||||||
num_notes=NUMERIC(sortable=True, signed=False),
|
|
||||||
custom_fields=TEXT(),
|
|
||||||
custom_field_count=NUMERIC(sortable=True, signed=False),
|
|
||||||
has_custom_fields=BOOLEAN(),
|
|
||||||
custom_fields_id=KEYWORD(commas=True),
|
|
||||||
owner=TEXT(),
|
|
||||||
owner_id=NUMERIC(),
|
|
||||||
has_owner=BOOLEAN(),
|
|
||||||
viewer_id=KEYWORD(commas=True),
|
|
||||||
checksum=TEXT(),
|
|
||||||
page_count=NUMERIC(sortable=True),
|
|
||||||
original_filename=TEXT(sortable=True),
|
|
||||||
is_shared=BOOLEAN(),
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def open_index(*, recreate=False) -> FileIndex:
|
|
||||||
transient_exceptions = (FileNotFoundError, LockError)
|
|
||||||
max_retries = 3
|
|
||||||
retry_delay = 0.1
|
|
||||||
|
|
||||||
for attempt in range(max_retries + 1):
|
|
||||||
try:
|
|
||||||
if exists_in(settings.INDEX_DIR) and not recreate:
|
|
||||||
return open_dir(settings.INDEX_DIR, schema=get_schema())
|
|
||||||
break
|
|
||||||
except transient_exceptions as exc:
|
|
||||||
is_last_attempt = attempt == max_retries or recreate
|
|
||||||
if is_last_attempt:
|
|
||||||
logger.exception(
|
|
||||||
"Error while opening the index after retries, recreating.",
|
|
||||||
)
|
|
||||||
break
|
|
||||||
|
|
||||||
logger.warning(
|
|
||||||
"Transient error while opening the index (attempt %s/%s): %s. Retrying.",
|
|
||||||
attempt + 1,
|
|
||||||
max_retries + 1,
|
|
||||||
exc,
|
|
||||||
)
|
|
||||||
sleep(retry_delay)
|
|
||||||
except Exception:
|
|
||||||
logger.exception("Error while opening the index, recreating.")
|
|
||||||
break
|
|
||||||
|
|
||||||
# create_in doesn't handle corrupted indexes very well, remove the directory entirely first
|
|
||||||
if settings.INDEX_DIR.is_dir():
|
|
||||||
rmtree(settings.INDEX_DIR)
|
|
||||||
settings.INDEX_DIR.mkdir(parents=True, exist_ok=True)
|
|
||||||
|
|
||||||
return create_in(settings.INDEX_DIR, get_schema())
|
|
||||||
|
|
||||||
|
|
||||||
@contextmanager
|
|
||||||
def open_index_writer(*, optimize=False) -> AsyncWriter:
|
|
||||||
writer = AsyncWriter(open_index())
|
|
||||||
|
|
||||||
try:
|
|
||||||
yield writer
|
|
||||||
except Exception as e:
|
|
||||||
logger.exception(str(e))
|
|
||||||
writer.cancel()
|
|
||||||
finally:
|
|
||||||
writer.commit(optimize=optimize)
|
|
||||||
|
|
||||||
|
|
||||||
@contextmanager
|
|
||||||
def open_index_searcher() -> Searcher:
|
|
||||||
searcher = open_index().searcher()
|
|
||||||
|
|
||||||
try:
|
|
||||||
yield searcher
|
|
||||||
finally:
|
|
||||||
searcher.close()
|
|
||||||
|
|
||||||
|
|
||||||
def update_document(
|
|
||||||
writer: AsyncWriter,
|
|
||||||
doc: Document,
|
|
||||||
effective_content: str | None = None,
|
|
||||||
) -> None:
|
|
||||||
tags = ",".join([t.name for t in doc.tags.all()])
|
|
||||||
tags_ids = ",".join([str(t.id) for t in doc.tags.all()])
|
|
||||||
notes = ",".join([str(c.note) for c in Note.objects.filter(document=doc)])
|
|
||||||
custom_fields = ",".join(
|
|
||||||
[str(c) for c in CustomFieldInstance.objects.filter(document=doc)],
|
|
||||||
)
|
|
||||||
custom_fields_ids = ",".join(
|
|
||||||
[str(f.field.id) for f in CustomFieldInstance.objects.filter(document=doc)],
|
|
||||||
)
|
|
||||||
asn: int | None = doc.archive_serial_number
|
|
||||||
if asn is not None and (
|
|
||||||
asn < Document.ARCHIVE_SERIAL_NUMBER_MIN
|
|
||||||
or asn > Document.ARCHIVE_SERIAL_NUMBER_MAX
|
|
||||||
):
|
|
||||||
logger.error(
|
|
||||||
f"Not indexing Archive Serial Number {asn} of document {doc.pk}. "
|
|
||||||
f"ASN is out of range "
|
|
||||||
f"[{Document.ARCHIVE_SERIAL_NUMBER_MIN:,}, "
|
|
||||||
f"{Document.ARCHIVE_SERIAL_NUMBER_MAX:,}.",
|
|
||||||
)
|
|
||||||
asn = 0
|
|
||||||
users_with_perms = get_users_with_perms(
|
|
||||||
doc,
|
|
||||||
only_with_perms_in=["view_document"],
|
|
||||||
)
|
|
||||||
viewer_ids: str = ",".join([str(u.id) for u in users_with_perms])
|
|
||||||
writer.update_document(
|
|
||||||
id=doc.pk,
|
|
||||||
title=doc.title,
|
|
||||||
content=effective_content or doc.content,
|
|
||||||
correspondent=doc.correspondent.name if doc.correspondent else None,
|
|
||||||
correspondent_id=doc.correspondent.id if doc.correspondent else None,
|
|
||||||
has_correspondent=doc.correspondent is not None,
|
|
||||||
tag=tags if tags else None,
|
|
||||||
tag_id=tags_ids if tags_ids else None,
|
|
||||||
has_tag=len(tags) > 0,
|
|
||||||
type=doc.document_type.name if doc.document_type else None,
|
|
||||||
type_id=doc.document_type.id if doc.document_type else None,
|
|
||||||
has_type=doc.document_type is not None,
|
|
||||||
created=datetime.combine(doc.created, time.min),
|
|
||||||
added=doc.added,
|
|
||||||
asn=asn,
|
|
||||||
modified=doc.modified,
|
|
||||||
path=doc.storage_path.name if doc.storage_path else None,
|
|
||||||
path_id=doc.storage_path.id if doc.storage_path else None,
|
|
||||||
has_path=doc.storage_path is not None,
|
|
||||||
notes=notes,
|
|
||||||
num_notes=len(notes),
|
|
||||||
custom_fields=custom_fields,
|
|
||||||
custom_field_count=len(doc.custom_fields.all()),
|
|
||||||
has_custom_fields=len(custom_fields) > 0,
|
|
||||||
custom_fields_id=custom_fields_ids if custom_fields_ids else None,
|
|
||||||
owner=doc.owner.username if doc.owner else None,
|
|
||||||
owner_id=doc.owner.id if doc.owner else None,
|
|
||||||
has_owner=doc.owner is not None,
|
|
||||||
viewer_id=viewer_ids if viewer_ids else None,
|
|
||||||
checksum=doc.checksum,
|
|
||||||
page_count=doc.page_count,
|
|
||||||
original_filename=doc.original_filename,
|
|
||||||
is_shared=len(viewer_ids) > 0,
|
|
||||||
)
|
|
||||||
logger.debug(f"Index updated for document {doc.pk}.")
|
|
||||||
|
|
||||||
|
|
||||||
def remove_document(writer: AsyncWriter, doc: Document) -> None:
|
|
||||||
remove_document_by_id(writer, doc.pk)
|
|
||||||
|
|
||||||
|
|
||||||
def remove_document_by_id(writer: AsyncWriter, doc_id) -> None:
|
|
||||||
writer.delete_by_term("id", doc_id)
|
|
||||||
|
|
||||||
|
|
||||||
def add_or_update_document(
|
|
||||||
document: Document,
|
|
||||||
effective_content: str | None = None,
|
|
||||||
) -> None:
|
|
||||||
with open_index_writer() as writer:
|
|
||||||
update_document(writer, document, effective_content=effective_content)
|
|
||||||
|
|
||||||
|
|
||||||
def remove_document_from_index(document: Document) -> None:
|
|
||||||
with open_index_writer() as writer:
|
|
||||||
remove_document(writer, document)
|
|
||||||
|
|
||||||
|
|
||||||
class MappedDocIdSet(DocIdSet):
|
|
||||||
"""
|
|
||||||
A DocIdSet backed by a set of `Document` IDs.
|
|
||||||
Supports efficiently looking up if a whoosh docnum is in the provided `filter_queryset`.
|
|
||||||
"""
|
|
||||||
|
|
||||||
def __init__(self, filter_queryset: QuerySet, ixreader: IndexReader) -> None:
|
|
||||||
super().__init__()
|
|
||||||
document_ids = filter_queryset.order_by("id").values_list("id", flat=True)
|
|
||||||
max_id = document_ids.last() or 0
|
|
||||||
self.document_ids = BitSet(document_ids, size=max_id)
|
|
||||||
self.ixreader = ixreader
|
|
||||||
|
|
||||||
def __contains__(self, docnum) -> bool:
|
|
||||||
document_id = self.ixreader.stored_fields(docnum)["id"]
|
|
||||||
return document_id in self.document_ids
|
|
||||||
|
|
||||||
def __bool__(self) -> Literal[True]:
|
|
||||||
# searcher.search ignores a filter if it's "falsy".
|
|
||||||
# We use this hack so this DocIdSet, when used as a filter, is never ignored.
|
|
||||||
return True
|
|
||||||
|
|
||||||
|
|
||||||
class DelayedQuery:
|
|
||||||
def _get_query(self):
|
|
||||||
raise NotImplementedError # pragma: no cover
|
|
||||||
|
|
||||||
def _get_query_sortedby(self) -> tuple[None, Literal[False]] | tuple[str, bool]:
|
|
||||||
if "ordering" not in self.query_params:
|
|
||||||
return None, False
|
|
||||||
|
|
||||||
field: str = self.query_params["ordering"]
|
|
||||||
|
|
||||||
sort_fields_map: dict[str, str] = {
|
|
||||||
"created": "created",
|
|
||||||
"modified": "modified",
|
|
||||||
"added": "added",
|
|
||||||
"title": "title",
|
|
||||||
"correspondent__name": "correspondent",
|
|
||||||
"document_type__name": "type",
|
|
||||||
"archive_serial_number": "asn",
|
|
||||||
"num_notes": "num_notes",
|
|
||||||
"owner": "owner",
|
|
||||||
"page_count": "page_count",
|
|
||||||
}
|
|
||||||
|
|
||||||
if field.startswith("-"):
|
|
||||||
field = field[1:]
|
|
||||||
reverse = True
|
|
||||||
else:
|
|
||||||
reverse = False
|
|
||||||
|
|
||||||
if field not in sort_fields_map:
|
|
||||||
return None, False
|
|
||||||
else:
|
|
||||||
return sort_fields_map[field], reverse
|
|
||||||
|
|
||||||
def __init__(
|
|
||||||
self,
|
|
||||||
searcher: Searcher,
|
|
||||||
query_params,
|
|
||||||
page_size,
|
|
||||||
filter_queryset: QuerySet,
|
|
||||||
) -> None:
|
|
||||||
self.searcher = searcher
|
|
||||||
self.query_params = query_params
|
|
||||||
self.page_size = page_size
|
|
||||||
self.saved_results = dict()
|
|
||||||
self.first_score = None
|
|
||||||
self.filter_queryset = filter_queryset
|
|
||||||
self.suggested_correction = None
|
|
||||||
self._manual_hits_cache: list | None = None
|
|
||||||
|
|
||||||
def __len__(self) -> int:
|
|
||||||
if self._manual_sort_requested():
|
|
||||||
manual_hits = self._manual_hits()
|
|
||||||
return len(manual_hits)
|
|
||||||
|
|
||||||
page = self[0:1]
|
|
||||||
return len(page)
|
|
||||||
|
|
||||||
def _manual_sort_requested(self):
|
|
||||||
ordering = self.query_params.get("ordering", "")
|
|
||||||
return ordering.lstrip("-").startswith("custom_field_")
|
|
||||||
|
|
||||||
def _manual_hits(self):
|
|
||||||
if self._manual_hits_cache is None:
|
|
||||||
q, mask, suggested_correction = self._get_query()
|
|
||||||
self.suggested_correction = suggested_correction
|
|
||||||
|
|
||||||
results = self.searcher.search(
|
|
||||||
q,
|
|
||||||
mask=mask,
|
|
||||||
filter=MappedDocIdSet(self.filter_queryset, self.searcher.ixreader),
|
|
||||||
limit=None,
|
|
||||||
)
|
|
||||||
results.fragmenter = highlight.ContextFragmenter(surround=50)
|
|
||||||
results.formatter = HtmlFormatter(tagname="span", between=" ... ")
|
|
||||||
|
|
||||||
if not self.first_score and len(results) > 0:
|
|
||||||
self.first_score = results[0].score
|
|
||||||
|
|
||||||
if self.first_score:
|
|
||||||
results.top_n = [
|
|
||||||
(
|
|
||||||
(hit[0] / self.first_score) if self.first_score else None,
|
|
||||||
hit[1],
|
|
||||||
)
|
|
||||||
for hit in results.top_n
|
|
||||||
]
|
|
||||||
|
|
||||||
hits_by_id = {hit["id"]: hit for hit in results}
|
|
||||||
matching_ids = list(hits_by_id.keys())
|
|
||||||
|
|
||||||
ordered_ids = list(
|
|
||||||
self.filter_queryset.filter(id__in=matching_ids).values_list(
|
|
||||||
"id",
|
|
||||||
flat=True,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
ordered_ids = list(dict.fromkeys(ordered_ids))
|
|
||||||
|
|
||||||
self._manual_hits_cache = [
|
|
||||||
hits_by_id[_id] for _id in ordered_ids if _id in hits_by_id
|
|
||||||
]
|
|
||||||
return self._manual_hits_cache
|
|
||||||
|
|
||||||
def __getitem__(self, item):
|
|
||||||
if item.start in self.saved_results:
|
|
||||||
return self.saved_results[item.start]
|
|
||||||
|
|
||||||
if self._manual_sort_requested():
|
|
||||||
manual_hits = self._manual_hits()
|
|
||||||
start = 0 if item.start is None else item.start
|
|
||||||
stop = item.stop
|
|
||||||
hits = manual_hits[start:stop] if stop is not None else manual_hits[start:]
|
|
||||||
page = ManualResultsPage(hits)
|
|
||||||
self.saved_results[start] = page
|
|
||||||
return page
|
|
||||||
|
|
||||||
q, mask, suggested_correction = self._get_query()
|
|
||||||
self.suggested_correction = suggested_correction
|
|
||||||
sortedby, reverse = self._get_query_sortedby()
|
|
||||||
|
|
||||||
page: ResultsPage = self.searcher.search_page(
|
|
||||||
q,
|
|
||||||
mask=mask,
|
|
||||||
filter=MappedDocIdSet(self.filter_queryset, self.searcher.ixreader),
|
|
||||||
pagenum=math.floor(item.start / self.page_size) + 1,
|
|
||||||
pagelen=self.page_size,
|
|
||||||
sortedby=sortedby,
|
|
||||||
reverse=reverse,
|
|
||||||
)
|
|
||||||
page.results.fragmenter = highlight.ContextFragmenter(surround=50)
|
|
||||||
page.results.formatter = HtmlFormatter(tagname="span", between=" ... ")
|
|
||||||
|
|
||||||
if not self.first_score and len(page.results) > 0 and sortedby is None:
|
|
||||||
self.first_score = page.results[0].score
|
|
||||||
|
|
||||||
page.results.top_n = [
|
|
||||||
(
|
|
||||||
(hit[0] / self.first_score) if self.first_score else None,
|
|
||||||
hit[1],
|
|
||||||
)
|
|
||||||
for hit in page.results.top_n
|
|
||||||
]
|
|
||||||
|
|
||||||
self.saved_results[item.start] = page
|
|
||||||
|
|
||||||
return page
|
|
||||||
|
|
||||||
|
|
||||||
class ManualResultsPage(list):
|
|
||||||
def __init__(self, hits) -> None:
|
|
||||||
super().__init__(hits)
|
|
||||||
self.results = ManualResults(hits)
|
|
||||||
|
|
||||||
|
|
||||||
class ManualResults:
|
|
||||||
def __init__(self, hits) -> None:
|
|
||||||
self._docnums = [hit.docnum for hit in hits]
|
|
||||||
|
|
||||||
def docs(self):
|
|
||||||
return self._docnums
|
|
||||||
|
|
||||||
|
|
||||||
class LocalDateParser(English):
|
|
||||||
def reverse_timezone_offset(self, d):
|
|
||||||
return (d.replace(tzinfo=django_timezone.get_current_timezone())).astimezone(
|
|
||||||
UTC,
|
|
||||||
)
|
|
||||||
|
|
||||||
def date_from(self, *args, **kwargs):
|
|
||||||
d = super().date_from(*args, **kwargs)
|
|
||||||
if isinstance(d, timespan):
|
|
||||||
d.start = self.reverse_timezone_offset(d.start)
|
|
||||||
d.end = self.reverse_timezone_offset(d.end)
|
|
||||||
elif isinstance(d, datetime):
|
|
||||||
d = self.reverse_timezone_offset(d)
|
|
||||||
return d
|
|
||||||
|
|
||||||
|
|
||||||
class DelayedFullTextQuery(DelayedQuery):
|
|
||||||
def _get_query(self) -> tuple:
|
|
||||||
q_str = self.query_params["query"]
|
|
||||||
q_str = rewrite_natural_date_keywords(q_str)
|
|
||||||
qp = MultifieldParser(
|
|
||||||
[
|
|
||||||
"content",
|
|
||||||
"title",
|
|
||||||
"correspondent",
|
|
||||||
"tag",
|
|
||||||
"type",
|
|
||||||
"notes",
|
|
||||||
"custom_fields",
|
|
||||||
],
|
|
||||||
self.searcher.ixreader.schema,
|
|
||||||
)
|
|
||||||
qp.add_plugin(
|
|
||||||
DateParserPlugin(
|
|
||||||
basedate=django_timezone.now(),
|
|
||||||
dateparser=LocalDateParser(),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
q = qp.parse(q_str)
|
|
||||||
suggested_correction = None
|
|
||||||
try:
|
|
||||||
corrected = self.searcher.correct_query(q, q_str)
|
|
||||||
if corrected.string != q_str:
|
|
||||||
corrected_results = self.searcher.search(
|
|
||||||
corrected.query,
|
|
||||||
limit=1,
|
|
||||||
filter=MappedDocIdSet(self.filter_queryset, self.searcher.ixreader),
|
|
||||||
scored=False,
|
|
||||||
)
|
|
||||||
if len(corrected_results) > 0:
|
|
||||||
suggested_correction = corrected.string
|
|
||||||
except Exception as e:
|
|
||||||
logger.info(
|
|
||||||
"Error while correcting query %s: %s",
|
|
||||||
f"{q_str!r}",
|
|
||||||
e,
|
|
||||||
)
|
|
||||||
|
|
||||||
return q, None, suggested_correction
|
|
||||||
|
|
||||||
|
|
||||||
class DelayedMoreLikeThisQuery(DelayedQuery):
|
|
||||||
def _get_query(self) -> tuple:
|
|
||||||
more_like_doc_id = int(self.query_params["more_like_id"])
|
|
||||||
content = Document.objects.get(id=more_like_doc_id).content
|
|
||||||
|
|
||||||
docnum = self.searcher.document_number(id=more_like_doc_id)
|
|
||||||
kts = self.searcher.key_terms_from_text(
|
|
||||||
"content",
|
|
||||||
content,
|
|
||||||
numterms=20,
|
|
||||||
model=classify.Bo1Model,
|
|
||||||
normalize=False,
|
|
||||||
)
|
|
||||||
q = query.Or(
|
|
||||||
[query.Term("content", word, boost=weight) for word, weight in kts],
|
|
||||||
)
|
|
||||||
mask: set = {docnum}
|
|
||||||
|
|
||||||
return q, mask, None
|
|
||||||
|
|
||||||
|
|
||||||
def autocomplete(
|
|
||||||
ix: FileIndex,
|
|
||||||
term: str,
|
|
||||||
limit: int = 10,
|
|
||||||
user: User | None = None,
|
|
||||||
) -> list:
|
|
||||||
"""
|
|
||||||
Mimics whoosh.reading.IndexReader.most_distinctive_terms with permissions
|
|
||||||
and without scoring
|
|
||||||
"""
|
|
||||||
terms = []
|
|
||||||
|
|
||||||
with ix.searcher(weighting=TF_IDF()) as s:
|
|
||||||
qp = QueryParser("content", schema=ix.schema)
|
|
||||||
# Don't let searches with a query that happen to match a field override the
|
|
||||||
# content field query instead and return bogus, not text data
|
|
||||||
qp.remove_plugin_class(FieldsPlugin)
|
|
||||||
q = qp.parse(f"{term.lower()}*")
|
|
||||||
user_criterias: list = get_permissions_criterias(user)
|
|
||||||
|
|
||||||
results = s.search(
|
|
||||||
q,
|
|
||||||
terms=True,
|
|
||||||
filter=query.Or(user_criterias) if user_criterias is not None else None,
|
|
||||||
)
|
|
||||||
|
|
||||||
termCounts = Counter()
|
|
||||||
if results.has_matched_terms():
|
|
||||||
for hit in results:
|
|
||||||
for _, match in hit.matched_terms():
|
|
||||||
termCounts[match] += 1
|
|
||||||
terms = [t for t, _ in termCounts.most_common(limit)]
|
|
||||||
|
|
||||||
term_encoded: bytes = term.encode("UTF-8")
|
|
||||||
if term_encoded in terms:
|
|
||||||
terms.insert(0, terms.pop(terms.index(term_encoded)))
|
|
||||||
|
|
||||||
return terms
|
|
||||||
|
|
||||||
|
|
||||||
def get_permissions_criterias(user: User | None = None) -> list:
|
|
||||||
user_criterias = [query.Term("has_owner", text=False)]
|
|
||||||
if user is not None:
|
|
||||||
if user.is_superuser: # superusers see all docs
|
|
||||||
user_criterias = []
|
|
||||||
else:
|
|
||||||
user_criterias.append(query.Term("owner_id", user.id))
|
|
||||||
user_criterias.append(
|
|
||||||
query.Term("viewer_id", str(user.id)),
|
|
||||||
)
|
|
||||||
return user_criterias
|
|
||||||
|
|
||||||
|
|
||||||
def rewrite_natural_date_keywords(query_string: str) -> str:
|
|
||||||
"""
|
|
||||||
Rewrites natural date keywords (e.g. added:today or added:"yesterday") to UTC range syntax for Whoosh.
|
|
||||||
This resolves timezone issues with date parsing in Whoosh as well as adding support for more
|
|
||||||
natural date keywords.
|
|
||||||
"""
|
|
||||||
|
|
||||||
tz = get_current_timezone()
|
|
||||||
local_now = now().astimezone(tz)
|
|
||||||
today = local_now.date()
|
|
||||||
|
|
||||||
# all supported Keywords
|
|
||||||
pattern = r"(\b(?:added|created|modified))\s*:\s*[\"']?(today|yesterday|this month|previous month|previous week|previous quarter|this year|previous year)[\"']?"
|
|
||||||
|
|
||||||
def repl(m):
|
|
||||||
field = m.group(1)
|
|
||||||
keyword = m.group(2).lower()
|
|
||||||
|
|
||||||
match keyword:
|
|
||||||
case "today":
|
|
||||||
start = datetime.combine(today, time.min, tzinfo=tz)
|
|
||||||
end = datetime.combine(today, time.max, tzinfo=tz)
|
|
||||||
|
|
||||||
case "yesterday":
|
|
||||||
yesterday = today - timedelta(days=1)
|
|
||||||
start = datetime.combine(yesterday, time.min, tzinfo=tz)
|
|
||||||
end = datetime.combine(yesterday, time.max, tzinfo=tz)
|
|
||||||
|
|
||||||
case "this month":
|
|
||||||
start = datetime(local_now.year, local_now.month, 1, 0, 0, 0, tzinfo=tz)
|
|
||||||
end = start + relativedelta(months=1) - timedelta(seconds=1)
|
|
||||||
|
|
||||||
case "previous month":
|
|
||||||
this_month_start = datetime(
|
|
||||||
local_now.year,
|
|
||||||
local_now.month,
|
|
||||||
1,
|
|
||||||
0,
|
|
||||||
0,
|
|
||||||
0,
|
|
||||||
tzinfo=tz,
|
|
||||||
)
|
|
||||||
start = this_month_start - relativedelta(months=1)
|
|
||||||
end = this_month_start - timedelta(seconds=1)
|
|
||||||
|
|
||||||
case "this year":
|
|
||||||
start = datetime(local_now.year, 1, 1, 0, 0, 0, tzinfo=tz)
|
|
||||||
end = datetime(local_now.year, 12, 31, 23, 59, 59, tzinfo=tz)
|
|
||||||
|
|
||||||
case "previous week":
|
|
||||||
days_since_monday = local_now.weekday()
|
|
||||||
this_week_start = datetime.combine(
|
|
||||||
today - timedelta(days=days_since_monday),
|
|
||||||
time.min,
|
|
||||||
tzinfo=tz,
|
|
||||||
)
|
|
||||||
start = this_week_start - timedelta(days=7)
|
|
||||||
end = this_week_start - timedelta(seconds=1)
|
|
||||||
|
|
||||||
case "previous quarter":
|
|
||||||
current_quarter = (local_now.month - 1) // 3 + 1
|
|
||||||
this_quarter_start_month = (current_quarter - 1) * 3 + 1
|
|
||||||
this_quarter_start = datetime(
|
|
||||||
local_now.year,
|
|
||||||
this_quarter_start_month,
|
|
||||||
1,
|
|
||||||
0,
|
|
||||||
0,
|
|
||||||
0,
|
|
||||||
tzinfo=tz,
|
|
||||||
)
|
|
||||||
start = this_quarter_start - relativedelta(months=3)
|
|
||||||
end = this_quarter_start - timedelta(seconds=1)
|
|
||||||
|
|
||||||
case "previous year":
|
|
||||||
start = datetime(local_now.year - 1, 1, 1, 0, 0, 0, tzinfo=tz)
|
|
||||||
end = datetime(local_now.year - 1, 12, 31, 23, 59, 59, tzinfo=tz)
|
|
||||||
|
|
||||||
# Convert to UTC and format
|
|
||||||
start_str = start.astimezone(UTC).strftime("%Y%m%d%H%M%S")
|
|
||||||
end_str = end.astimezone(UTC).strftime("%Y%m%d%H%M%S")
|
|
||||||
return f"{field}:[{start_str} TO {end_str}]"
|
|
||||||
|
|
||||||
return re.sub(pattern, repl, query_string, flags=re.IGNORECASE)
|
|
||||||
@@ -385,10 +385,10 @@ class Command(CryptMixin, PaperlessCommand):
|
|||||||
"workflow_webhook_actions": WorkflowActionWebhook.objects.all(),
|
"workflow_webhook_actions": WorkflowActionWebhook.objects.all(),
|
||||||
"workflows": Workflow.objects.all(),
|
"workflows": Workflow.objects.all(),
|
||||||
"custom_fields": CustomField.objects.all(),
|
"custom_fields": CustomField.objects.all(),
|
||||||
"custom_field_instances": CustomFieldInstance.objects.all(),
|
"custom_field_instances": CustomFieldInstance.global_objects.all(),
|
||||||
"app_configs": ApplicationConfiguration.objects.all(),
|
"app_configs": ApplicationConfiguration.objects.all(),
|
||||||
"notes": Note.objects.all(),
|
"notes": Note.global_objects.all(),
|
||||||
"documents": Document.objects.order_by("id").all(),
|
"documents": Document.global_objects.order_by("id").all(),
|
||||||
"social_accounts": SocialAccount.objects.all(),
|
"social_accounts": SocialAccount.objects.all(),
|
||||||
"social_apps": SocialApp.objects.all(),
|
"social_apps": SocialApp.objects.all(),
|
||||||
"social_tokens": SocialToken.objects.all(),
|
"social_tokens": SocialToken.objects.all(),
|
||||||
@@ -443,7 +443,7 @@ class Command(CryptMixin, PaperlessCommand):
|
|||||||
writer.write_batch(batch)
|
writer.write_batch(batch)
|
||||||
|
|
||||||
document_map: dict[int, Document] = {
|
document_map: dict[int, Document] = {
|
||||||
d.pk: d for d in Document.objects.order_by("id")
|
d.pk: d for d in Document.global_objects.order_by("id")
|
||||||
}
|
}
|
||||||
|
|
||||||
# 3. Export files from each document
|
# 3. Export files from each document
|
||||||
@@ -619,12 +619,15 @@ class Command(CryptMixin, PaperlessCommand):
|
|||||||
"""Write per-document manifest file for --split-manifest mode."""
|
"""Write per-document manifest file for --split-manifest mode."""
|
||||||
content = [document_dict]
|
content = [document_dict]
|
||||||
content.extend(
|
content.extend(
|
||||||
serializers.serialize("python", Note.objects.filter(document=document)),
|
serializers.serialize(
|
||||||
|
"python",
|
||||||
|
Note.global_objects.filter(document=document),
|
||||||
|
),
|
||||||
)
|
)
|
||||||
content.extend(
|
content.extend(
|
||||||
serializers.serialize(
|
serializers.serialize(
|
||||||
"python",
|
"python",
|
||||||
CustomFieldInstance.objects.filter(document=document),
|
CustomFieldInstance.global_objects.filter(document=document),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
manifest_name = base_name.with_name(f"{base_name.stem}-manifest.json")
|
manifest_name = base_name.with_name(f"{base_name.stem}-manifest.json")
|
||||||
|
|||||||
@@ -125,7 +125,7 @@ class Command(CryptMixin, PaperlessCommand):
|
|||||||
"Found existing user(s), this might indicate a non-empty installation",
|
"Found existing user(s), this might indicate a non-empty installation",
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
if Document.objects.count() != 0:
|
if Document.global_objects.count() != 0:
|
||||||
self.stdout.write(
|
self.stdout.write(
|
||||||
self.style.WARNING(
|
self.style.WARNING(
|
||||||
"Found existing documents(s), this might indicate a non-empty installation",
|
"Found existing documents(s), this might indicate a non-empty installation",
|
||||||
@@ -376,7 +376,7 @@ class Command(CryptMixin, PaperlessCommand):
|
|||||||
]
|
]
|
||||||
|
|
||||||
for record in self.track(document_records, description="Copying files..."):
|
for record in self.track(document_records, description="Copying files..."):
|
||||||
document = Document.objects.get(pk=record["pk"])
|
document = Document.global_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
|
||||||
|
|||||||
@@ -1,11 +1,26 @@
|
|||||||
|
import logging
|
||||||
|
|
||||||
|
from django.conf import settings
|
||||||
from django.db import transaction
|
from django.db import transaction
|
||||||
|
|
||||||
from documents.management.commands.base import PaperlessCommand
|
from documents.management.commands.base import PaperlessCommand
|
||||||
from documents.tasks import index_optimize
|
from documents.models import Document
|
||||||
from documents.tasks import index_reindex
|
from documents.search import get_backend
|
||||||
|
from documents.search import needs_rebuild
|
||||||
|
from documents.search import reset_backend
|
||||||
|
from documents.search import wipe_index
|
||||||
|
|
||||||
|
logger = logging.getLogger("paperless.management.document_index")
|
||||||
|
|
||||||
|
|
||||||
class Command(PaperlessCommand):
|
class Command(PaperlessCommand):
|
||||||
|
"""
|
||||||
|
Django management command for search index operations.
|
||||||
|
|
||||||
|
Provides subcommands for reindexing documents and optimizing the search index.
|
||||||
|
Supports conditional reindexing based on schema version and language changes.
|
||||||
|
"""
|
||||||
|
|
||||||
help = "Manages the document index."
|
help = "Manages the document index."
|
||||||
|
|
||||||
supports_progress_bar = True
|
supports_progress_bar = True
|
||||||
@@ -14,15 +29,49 @@ class Command(PaperlessCommand):
|
|||||||
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"])
|
||||||
|
parser.add_argument(
|
||||||
|
"--recreate",
|
||||||
|
action="store_true",
|
||||||
|
default=False,
|
||||||
|
help="Wipe and recreate the index from scratch (only used with reindex).",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--if-needed",
|
||||||
|
action="store_true",
|
||||||
|
default=False,
|
||||||
|
help=(
|
||||||
|
"Skip reindex if the index is already up to date. "
|
||||||
|
"Checks schema version and search language sentinels. "
|
||||||
|
"Safe to run on every startup or upgrade."
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
def handle(self, *args, **options):
|
def handle(self, *args, **options):
|
||||||
with transaction.atomic():
|
with transaction.atomic():
|
||||||
if options["command"] == "reindex":
|
if options["command"] == "reindex":
|
||||||
index_reindex(
|
if options.get("if_needed") and not needs_rebuild(settings.INDEX_DIR):
|
||||||
|
self.stdout.write("Search index is up to date.")
|
||||||
|
return
|
||||||
|
if options.get("recreate"):
|
||||||
|
wipe_index(settings.INDEX_DIR)
|
||||||
|
|
||||||
|
documents = Document.objects.select_related(
|
||||||
|
"correspondent",
|
||||||
|
"document_type",
|
||||||
|
"storage_path",
|
||||||
|
"owner",
|
||||||
|
).prefetch_related("tags", "notes", "custom_fields", "versions")
|
||||||
|
get_backend().rebuild(
|
||||||
|
documents,
|
||||||
iter_wrapper=lambda docs: self.track(
|
iter_wrapper=lambda docs: self.track(
|
||||||
docs,
|
docs,
|
||||||
description="Indexing documents...",
|
description="Indexing documents...",
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
reset_backend()
|
||||||
|
|
||||||
elif options["command"] == "optimize":
|
elif options["command"] == "optimize":
|
||||||
index_optimize()
|
logger.info(
|
||||||
|
"document_index optimize is a no-op — Tantivy manages "
|
||||||
|
"segment merging automatically.",
|
||||||
|
)
|
||||||
|
|||||||
@@ -0,0 +1,39 @@
|
|||||||
|
import re
|
||||||
|
|
||||||
|
from django.db import migrations
|
||||||
|
|
||||||
|
# Matches "note:" when NOT preceded by a word character or dot.
|
||||||
|
# This avoids false positives like "denote:" or already-migrated "notes.note:".
|
||||||
|
# Handles start-of-string, whitespace, parentheses, +/- operators per Whoosh syntax.
|
||||||
|
_NOTE_RE = re.compile(r"(?<![.\w])note:")
|
||||||
|
|
||||||
|
# Same logic for "custom_field:" -> "custom_fields.value:"
|
||||||
|
_CUSTOM_FIELD_RE = re.compile(r"(?<![.\w])custom_field:")
|
||||||
|
|
||||||
|
|
||||||
|
def migrate_fulltext_query_field_prefixes(apps, schema_editor):
|
||||||
|
SavedViewFilterRule = apps.get_model("documents", "SavedViewFilterRule")
|
||||||
|
|
||||||
|
# rule_type 20 = "fulltext query" — value is a search query string
|
||||||
|
for rule in SavedViewFilterRule.objects.filter(rule_type=20).exclude(
|
||||||
|
value__isnull=True,
|
||||||
|
):
|
||||||
|
new_value = _NOTE_RE.sub("notes.note:", rule.value)
|
||||||
|
new_value = _CUSTOM_FIELD_RE.sub("custom_fields.value:", new_value)
|
||||||
|
|
||||||
|
if new_value != rule.value:
|
||||||
|
rule.value = new_value
|
||||||
|
rule.save(update_fields=["value"])
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
dependencies = [
|
||||||
|
("documents", "0016_sha256_checksums"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.RunPython(
|
||||||
|
migrate_fulltext_query_field_prefixes,
|
||||||
|
migrations.RunPython.noop,
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -1114,19 +1114,7 @@ class CustomFieldInstance(SoftDeleteModel):
|
|||||||
]
|
]
|
||||||
|
|
||||||
def __str__(self) -> str:
|
def __str__(self) -> str:
|
||||||
value = (
|
return str(self.field.name) + f" : {self.value_for_search}"
|
||||||
next(
|
|
||||||
option.get("label")
|
|
||||||
for option in self.field.extra_data["select_options"]
|
|
||||||
if option.get("id") == self.value_select
|
|
||||||
)
|
|
||||||
if (
|
|
||||||
self.field.data_type == CustomField.FieldDataType.SELECT
|
|
||||||
and self.value_select is not None
|
|
||||||
)
|
|
||||||
else self.value
|
|
||||||
)
|
|
||||||
return str(self.field.name) + f" : {value}"
|
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def get_value_field_name(cls, data_type: CustomField.FieldDataType):
|
def get_value_field_name(cls, data_type: CustomField.FieldDataType):
|
||||||
@@ -1144,6 +1132,25 @@ class CustomFieldInstance(SoftDeleteModel):
|
|||||||
value_field_name = self.get_value_field_name(self.field.data_type)
|
value_field_name = self.get_value_field_name(self.field.data_type)
|
||||||
return getattr(self, value_field_name)
|
return getattr(self, value_field_name)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def value_for_search(self) -> str | None:
|
||||||
|
"""
|
||||||
|
Return the value suitable for full-text indexing and display, or None
|
||||||
|
if the value is unset.
|
||||||
|
|
||||||
|
For SELECT fields, resolves the human-readable label rather than the
|
||||||
|
opaque option ID stored in value_select.
|
||||||
|
"""
|
||||||
|
if self.value is None:
|
||||||
|
return None
|
||||||
|
if self.field.data_type == CustomField.FieldDataType.SELECT:
|
||||||
|
options = (self.field.extra_data or {}).get("select_options", [])
|
||||||
|
return next(
|
||||||
|
(o["label"] for o in options if o.get("id") == self.value),
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
return str(self.value)
|
||||||
|
|
||||||
|
|
||||||
if settings.AUDIT_LOG_ENABLED:
|
if settings.AUDIT_LOG_ENABLED:
|
||||||
auditlog.register(
|
auditlog.register(
|
||||||
|
|||||||
@@ -9,19 +9,14 @@ to wrap the document queryset (e.g., with a progress bar). The default
|
|||||||
is an identity function that adds no overhead.
|
is an identity function that adds no overhead.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
import uuid
|
import uuid
|
||||||
from collections import defaultdict
|
from collections import defaultdict
|
||||||
from collections.abc import Callable
|
|
||||||
from collections.abc import Iterable
|
|
||||||
from collections.abc import Iterator
|
from collections.abc import Iterator
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import TYPE_CHECKING
|
from typing import TYPE_CHECKING
|
||||||
from typing import Final
|
from typing import Final
|
||||||
from typing import TypedDict
|
from typing import TypedDict
|
||||||
from typing import TypeVar
|
|
||||||
|
|
||||||
from celery import states
|
from celery import states
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
@@ -29,14 +24,13 @@ from django.utils import timezone
|
|||||||
|
|
||||||
from documents.models import Document
|
from documents.models import Document
|
||||||
from documents.models import PaperlessTask
|
from documents.models import PaperlessTask
|
||||||
|
from documents.utils import IterWrapper
|
||||||
from documents.utils import compute_checksum
|
from documents.utils import compute_checksum
|
||||||
|
from documents.utils import identity
|
||||||
from paperless.config import GeneralConfig
|
from paperless.config import GeneralConfig
|
||||||
|
|
||||||
logger = logging.getLogger("paperless.sanity_checker")
|
logger = logging.getLogger("paperless.sanity_checker")
|
||||||
|
|
||||||
_T = TypeVar("_T")
|
|
||||||
IterWrapper = Callable[[Iterable[_T]], Iterable[_T]]
|
|
||||||
|
|
||||||
|
|
||||||
class MessageEntry(TypedDict):
|
class MessageEntry(TypedDict):
|
||||||
"""A single sanity check message with its severity level."""
|
"""A single sanity check message with its severity level."""
|
||||||
@@ -45,11 +39,6 @@ class MessageEntry(TypedDict):
|
|||||||
message: str
|
message: str
|
||||||
|
|
||||||
|
|
||||||
def _identity(iterable: Iterable[_T]) -> Iterable[_T]:
|
|
||||||
"""Pass through an iterable unchanged (default iter_wrapper)."""
|
|
||||||
return iterable
|
|
||||||
|
|
||||||
|
|
||||||
class SanityCheckMessages:
|
class SanityCheckMessages:
|
||||||
"""Collects sanity check messages grouped by document primary key.
|
"""Collects sanity check messages grouped by document primary key.
|
||||||
|
|
||||||
@@ -296,7 +285,7 @@ def _check_document(
|
|||||||
def check_sanity(
|
def check_sanity(
|
||||||
*,
|
*,
|
||||||
scheduled: bool = True,
|
scheduled: bool = True,
|
||||||
iter_wrapper: IterWrapper[Document] = _identity,
|
iter_wrapper: IterWrapper[Document] = identity,
|
||||||
) -> SanityCheckMessages:
|
) -> SanityCheckMessages:
|
||||||
"""Run a full sanity check on the document archive.
|
"""Run a full sanity check on the document archive.
|
||||||
|
|
||||||
|
|||||||
21
src/documents/search/__init__.py
Normal file
21
src/documents/search/__init__.py
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
from documents.search._backend import SearchIndexLockError
|
||||||
|
from documents.search._backend import SearchResults
|
||||||
|
from documents.search._backend import TantivyBackend
|
||||||
|
from documents.search._backend import TantivyRelevanceList
|
||||||
|
from documents.search._backend import WriteBatch
|
||||||
|
from documents.search._backend import get_backend
|
||||||
|
from documents.search._backend import reset_backend
|
||||||
|
from documents.search._schema import needs_rebuild
|
||||||
|
from documents.search._schema import wipe_index
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"SearchIndexLockError",
|
||||||
|
"SearchResults",
|
||||||
|
"TantivyBackend",
|
||||||
|
"TantivyRelevanceList",
|
||||||
|
"WriteBatch",
|
||||||
|
"get_backend",
|
||||||
|
"needs_rebuild",
|
||||||
|
"reset_backend",
|
||||||
|
"wipe_index",
|
||||||
|
]
|
||||||
858
src/documents/search/_backend.py
Normal file
858
src/documents/search/_backend.py
Normal file
@@ -0,0 +1,858 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import threading
|
||||||
|
import unicodedata
|
||||||
|
from collections import Counter
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from datetime import UTC
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import TYPE_CHECKING
|
||||||
|
from typing import Self
|
||||||
|
from typing import TypedDict
|
||||||
|
from typing import TypeVar
|
||||||
|
|
||||||
|
import filelock
|
||||||
|
import regex
|
||||||
|
import tantivy
|
||||||
|
from django.conf import settings
|
||||||
|
from django.utils.timezone import get_current_timezone
|
||||||
|
from guardian.shortcuts import get_users_with_perms
|
||||||
|
|
||||||
|
from documents.search._query import build_permission_filter
|
||||||
|
from documents.search._query import parse_user_query
|
||||||
|
from documents.search._schema import _write_sentinels
|
||||||
|
from documents.search._schema import build_schema
|
||||||
|
from documents.search._schema import open_or_rebuild_index
|
||||||
|
from documents.search._schema import wipe_index
|
||||||
|
from documents.search._tokenizer import register_tokenizers
|
||||||
|
from documents.utils import IterWrapper
|
||||||
|
from documents.utils import identity
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from django.contrib.auth.base_user import AbstractBaseUser
|
||||||
|
from django.db.models import QuerySet
|
||||||
|
|
||||||
|
from documents.models import Document
|
||||||
|
|
||||||
|
logger = logging.getLogger("paperless.search")
|
||||||
|
|
||||||
|
_WORD_RE = regex.compile(r"\w+")
|
||||||
|
_AUTOCOMPLETE_REGEX_TIMEOUT = 1.0 # seconds; guards against ReDoS on untrusted content
|
||||||
|
|
||||||
|
T = TypeVar("T")
|
||||||
|
|
||||||
|
|
||||||
|
def _ascii_fold(s: str) -> str:
|
||||||
|
"""
|
||||||
|
Normalize unicode to ASCII equivalent characters for search consistency.
|
||||||
|
|
||||||
|
Converts accented characters (e.g., "café") to their ASCII base forms ("cafe")
|
||||||
|
to enable cross-language searching without requiring exact diacritic matching.
|
||||||
|
"""
|
||||||
|
return unicodedata.normalize("NFD", s).encode("ascii", "ignore").decode()
|
||||||
|
|
||||||
|
|
||||||
|
def _extract_autocomplete_words(text_sources: list[str]) -> set[str]:
|
||||||
|
"""Extract and normalize words for autocomplete.
|
||||||
|
|
||||||
|
Splits on non-word characters (matching Tantivy's simple tokenizer), lowercases,
|
||||||
|
and ascii-folds each token. Uses the regex library with a timeout to guard against
|
||||||
|
ReDoS on untrusted document content.
|
||||||
|
"""
|
||||||
|
words = set()
|
||||||
|
for text in text_sources:
|
||||||
|
if not text:
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
tokens = _WORD_RE.findall(text, timeout=_AUTOCOMPLETE_REGEX_TIMEOUT)
|
||||||
|
except TimeoutError: # pragma: no cover
|
||||||
|
logger.warning(
|
||||||
|
"Autocomplete word extraction timed out for a text source; skipping.",
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
for token in tokens:
|
||||||
|
normalized = _ascii_fold(token.lower())
|
||||||
|
if normalized:
|
||||||
|
words.add(normalized)
|
||||||
|
return words
|
||||||
|
|
||||||
|
|
||||||
|
class SearchHit(TypedDict):
|
||||||
|
"""Type definition for search result hits."""
|
||||||
|
|
||||||
|
id: int
|
||||||
|
score: float
|
||||||
|
rank: int
|
||||||
|
highlights: dict[str, str]
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class SearchResults:
|
||||||
|
"""
|
||||||
|
Container for search results with pagination metadata.
|
||||||
|
|
||||||
|
Attributes:
|
||||||
|
hits: List of search results with scores and highlights
|
||||||
|
total: Total matching documents across all pages (for pagination)
|
||||||
|
query: Preprocessed query string after date/syntax rewriting
|
||||||
|
"""
|
||||||
|
|
||||||
|
hits: list[SearchHit]
|
||||||
|
total: int # total matching documents (for pagination)
|
||||||
|
query: str # preprocessed query string
|
||||||
|
|
||||||
|
|
||||||
|
class TantivyRelevanceList:
|
||||||
|
"""
|
||||||
|
DRF-compatible list wrapper for Tantivy search hits.
|
||||||
|
|
||||||
|
Provides paginated access to search results while storing all hits in memory
|
||||||
|
for efficient ID retrieval. Used by Django REST framework for pagination.
|
||||||
|
|
||||||
|
Methods:
|
||||||
|
__len__: Returns total hit count for pagination calculations
|
||||||
|
__getitem__: Slices the hit list for page-specific results
|
||||||
|
|
||||||
|
Note: Stores ALL post-filter hits so get_all_result_ids() can return
|
||||||
|
every matching document ID without requiring a second search query.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, hits: list[SearchHit]) -> None:
|
||||||
|
self._hits = hits
|
||||||
|
|
||||||
|
def __len__(self) -> int:
|
||||||
|
return len(self._hits)
|
||||||
|
|
||||||
|
def __getitem__(self, key: slice) -> list[SearchHit]:
|
||||||
|
return self._hits[key]
|
||||||
|
|
||||||
|
|
||||||
|
class SearchIndexLockError(Exception):
|
||||||
|
"""Raised when the search index file lock cannot be acquired within the timeout."""
|
||||||
|
|
||||||
|
|
||||||
|
class WriteBatch:
|
||||||
|
"""
|
||||||
|
Context manager for bulk index operations with file locking.
|
||||||
|
|
||||||
|
Provides transactional batch updates to the search index with proper
|
||||||
|
concurrency control via file locking. All operations within the batch
|
||||||
|
are committed atomically or rolled back on exception.
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
with backend.batch_update() as batch:
|
||||||
|
batch.add_or_update(document)
|
||||||
|
batch.remove(doc_id)
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, backend: TantivyBackend, lock_timeout: float):
|
||||||
|
self._backend = backend
|
||||||
|
self._lock_timeout = lock_timeout
|
||||||
|
self._writer = None
|
||||||
|
self._lock = None
|
||||||
|
|
||||||
|
def __enter__(self) -> Self:
|
||||||
|
if self._backend._path is not None:
|
||||||
|
lock_path = self._backend._path / ".tantivy.lock"
|
||||||
|
self._lock = filelock.FileLock(str(lock_path))
|
||||||
|
try:
|
||||||
|
self._lock.acquire(timeout=self._lock_timeout)
|
||||||
|
except filelock.Timeout as e: # pragma: no cover
|
||||||
|
raise SearchIndexLockError(
|
||||||
|
f"Could not acquire index lock within {self._lock_timeout}s",
|
||||||
|
) from e
|
||||||
|
|
||||||
|
self._writer = self._backend._index.writer()
|
||||||
|
return self
|
||||||
|
|
||||||
|
def __exit__(self, exc_type, exc_val, exc_tb):
|
||||||
|
try:
|
||||||
|
if exc_type is None:
|
||||||
|
self._writer.commit()
|
||||||
|
self._backend._index.reload()
|
||||||
|
# Explicitly delete writer to release tantivy's internal lock.
|
||||||
|
# On exception the uncommitted writer is simply discarded.
|
||||||
|
if self._writer is not None:
|
||||||
|
del self._writer
|
||||||
|
self._writer = None
|
||||||
|
finally:
|
||||||
|
if self._lock is not None:
|
||||||
|
self._lock.release()
|
||||||
|
|
||||||
|
def add_or_update(
|
||||||
|
self,
|
||||||
|
document: Document,
|
||||||
|
effective_content: str | None = None,
|
||||||
|
) -> None:
|
||||||
|
"""
|
||||||
|
Add or update a document in the batch.
|
||||||
|
|
||||||
|
Implements upsert behavior by deleting any existing document with the same ID
|
||||||
|
and adding the new version. This ensures stale document data (e.g., after
|
||||||
|
permission changes) doesn't persist in the index.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
document: Django Document instance to index
|
||||||
|
effective_content: Override document.content for indexing (used when
|
||||||
|
re-indexing with newer OCR text from document versions)
|
||||||
|
"""
|
||||||
|
self.remove(document.pk)
|
||||||
|
doc = self._backend._build_tantivy_doc(document, effective_content)
|
||||||
|
self._writer.add_document(doc)
|
||||||
|
|
||||||
|
def remove(self, doc_id: int) -> None:
|
||||||
|
"""
|
||||||
|
Remove a document from the batch by its primary key.
|
||||||
|
|
||||||
|
Uses range query instead of term query to work around unsigned integer
|
||||||
|
type detection bug in tantivy-py 0.25.
|
||||||
|
"""
|
||||||
|
# Use range query to work around u64 deletion bug
|
||||||
|
self._writer.delete_documents_by_query(
|
||||||
|
tantivy.Query.range_query(
|
||||||
|
self._backend._schema,
|
||||||
|
"id",
|
||||||
|
tantivy.FieldType.Unsigned,
|
||||||
|
doc_id,
|
||||||
|
doc_id,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class TantivyBackend:
|
||||||
|
"""
|
||||||
|
Tantivy search backend with explicit lifecycle management.
|
||||||
|
|
||||||
|
Provides full-text search capabilities using the Tantivy search engine.
|
||||||
|
Supports in-memory indexes (for testing) and persistent on-disk indexes
|
||||||
|
(for production use). Handles document indexing, search queries, autocompletion,
|
||||||
|
and "more like this" functionality.
|
||||||
|
|
||||||
|
The backend manages its own connection lifecycle and can be reset when
|
||||||
|
the underlying index directory changes (e.g., during test isolation).
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, path: Path | None = None):
|
||||||
|
# path=None → in-memory index (for tests)
|
||||||
|
# path=some_dir → on-disk index (for production)
|
||||||
|
self._path = path
|
||||||
|
self._index = None
|
||||||
|
self._schema = None
|
||||||
|
|
||||||
|
def open(self) -> None:
|
||||||
|
"""
|
||||||
|
Open or rebuild the index as needed.
|
||||||
|
|
||||||
|
For disk-based indexes, checks if rebuilding is needed due to schema
|
||||||
|
version or language changes. Registers custom tokenizers after opening.
|
||||||
|
Safe to call multiple times - subsequent calls are no-ops.
|
||||||
|
"""
|
||||||
|
if self._index is not None:
|
||||||
|
return # pragma: no cover
|
||||||
|
if self._path is not None:
|
||||||
|
self._index = open_or_rebuild_index(self._path)
|
||||||
|
else:
|
||||||
|
self._index = tantivy.Index(build_schema())
|
||||||
|
register_tokenizers(self._index, settings.SEARCH_LANGUAGE)
|
||||||
|
self._schema = self._index.schema
|
||||||
|
|
||||||
|
def close(self) -> None:
|
||||||
|
"""
|
||||||
|
Close the index and release resources.
|
||||||
|
|
||||||
|
Safe to call multiple times - subsequent calls are no-ops.
|
||||||
|
"""
|
||||||
|
self._index = None
|
||||||
|
self._schema = None
|
||||||
|
|
||||||
|
def _ensure_open(self) -> None:
|
||||||
|
"""Ensure the index is open before operations."""
|
||||||
|
if self._index is None:
|
||||||
|
self.open() # pragma: no cover
|
||||||
|
|
||||||
|
def _build_tantivy_doc(
|
||||||
|
self,
|
||||||
|
document: Document,
|
||||||
|
effective_content: str | None = None,
|
||||||
|
) -> tantivy.Document:
|
||||||
|
"""Build a tantivy Document from a Django Document instance.
|
||||||
|
|
||||||
|
``effective_content`` overrides ``document.content`` for indexing —
|
||||||
|
used when re-indexing a root document with a newer version's OCR text.
|
||||||
|
"""
|
||||||
|
content = (
|
||||||
|
effective_content if effective_content is not None else document.content
|
||||||
|
)
|
||||||
|
|
||||||
|
doc = tantivy.Document()
|
||||||
|
|
||||||
|
# Basic fields
|
||||||
|
doc.add_unsigned("id", document.pk)
|
||||||
|
doc.add_text("checksum", document.checksum)
|
||||||
|
doc.add_text("title", document.title)
|
||||||
|
doc.add_text("title_sort", document.title)
|
||||||
|
doc.add_text("content", content)
|
||||||
|
doc.add_text("bigram_content", content)
|
||||||
|
|
||||||
|
# Original filename - only add if not None/empty
|
||||||
|
if document.original_filename:
|
||||||
|
doc.add_text("original_filename", document.original_filename)
|
||||||
|
|
||||||
|
# Correspondent
|
||||||
|
if document.correspondent:
|
||||||
|
doc.add_text("correspondent", document.correspondent.name)
|
||||||
|
doc.add_text("correspondent_sort", document.correspondent.name)
|
||||||
|
doc.add_unsigned("correspondent_id", document.correspondent_id)
|
||||||
|
|
||||||
|
# Document type
|
||||||
|
if document.document_type:
|
||||||
|
doc.add_text("document_type", document.document_type.name)
|
||||||
|
doc.add_text("type_sort", document.document_type.name)
|
||||||
|
doc.add_unsigned("document_type_id", document.document_type_id)
|
||||||
|
|
||||||
|
# Storage path
|
||||||
|
if document.storage_path:
|
||||||
|
doc.add_text("storage_path", document.storage_path.name)
|
||||||
|
doc.add_unsigned("storage_path_id", document.storage_path_id)
|
||||||
|
|
||||||
|
# Tags — collect names for autocomplete in the same pass
|
||||||
|
tag_names: list[str] = []
|
||||||
|
for tag in document.tags.all():
|
||||||
|
doc.add_text("tag", tag.name)
|
||||||
|
doc.add_unsigned("tag_id", tag.pk)
|
||||||
|
tag_names.append(tag.name)
|
||||||
|
|
||||||
|
# Notes — JSON for structured queries (notes.user:alice, notes.note:text),
|
||||||
|
# companion text field for default full-text search.
|
||||||
|
num_notes = 0
|
||||||
|
for note in document.notes.all():
|
||||||
|
num_notes += 1
|
||||||
|
doc.add_json("notes", {"note": note.note, "user": note.user.username})
|
||||||
|
|
||||||
|
# Custom fields — JSON for structured queries (custom_fields.name:x, custom_fields.value:y),
|
||||||
|
# companion text field for default full-text search.
|
||||||
|
for cfi in document.custom_fields.all():
|
||||||
|
search_value = cfi.value_for_search
|
||||||
|
# Skip fields where there is no value yet
|
||||||
|
if search_value is None:
|
||||||
|
continue
|
||||||
|
doc.add_json(
|
||||||
|
"custom_fields",
|
||||||
|
{
|
||||||
|
"name": cfi.field.name,
|
||||||
|
"value": search_value,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
# Dates
|
||||||
|
created_date = datetime(
|
||||||
|
document.created.year,
|
||||||
|
document.created.month,
|
||||||
|
document.created.day,
|
||||||
|
tzinfo=UTC,
|
||||||
|
)
|
||||||
|
doc.add_date("created", created_date)
|
||||||
|
doc.add_date("modified", document.modified)
|
||||||
|
doc.add_date("added", document.added)
|
||||||
|
|
||||||
|
if document.archive_serial_number is not None:
|
||||||
|
doc.add_unsigned("asn", document.archive_serial_number)
|
||||||
|
|
||||||
|
if document.page_count is not None:
|
||||||
|
doc.add_unsigned("page_count", document.page_count)
|
||||||
|
|
||||||
|
doc.add_unsigned("num_notes", num_notes)
|
||||||
|
|
||||||
|
# Owner
|
||||||
|
if document.owner_id:
|
||||||
|
doc.add_unsigned("owner_id", document.owner_id)
|
||||||
|
|
||||||
|
# Viewers with permission
|
||||||
|
users_with_perms = get_users_with_perms(
|
||||||
|
document,
|
||||||
|
only_with_perms_in=["view_document"],
|
||||||
|
)
|
||||||
|
for user in users_with_perms:
|
||||||
|
doc.add_unsigned("viewer_id", user.pk)
|
||||||
|
|
||||||
|
# Autocomplete words
|
||||||
|
text_sources = [document.title, content]
|
||||||
|
if document.correspondent:
|
||||||
|
text_sources.append(document.correspondent.name)
|
||||||
|
if document.document_type:
|
||||||
|
text_sources.append(document.document_type.name)
|
||||||
|
text_sources.extend(tag_names)
|
||||||
|
|
||||||
|
for word in sorted(_extract_autocomplete_words(text_sources)):
|
||||||
|
doc.add_text("autocomplete_word", word)
|
||||||
|
|
||||||
|
return doc
|
||||||
|
|
||||||
|
def add_or_update(
|
||||||
|
self,
|
||||||
|
document: Document,
|
||||||
|
effective_content: str | None = None,
|
||||||
|
) -> None:
|
||||||
|
"""
|
||||||
|
Add or update a single document with file locking.
|
||||||
|
|
||||||
|
Convenience method for single-document updates. For bulk operations,
|
||||||
|
use batch_update() context manager for better performance.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
document: Django Document instance to index
|
||||||
|
effective_content: Override document.content for indexing
|
||||||
|
"""
|
||||||
|
self._ensure_open()
|
||||||
|
with self.batch_update(lock_timeout=5.0) as batch:
|
||||||
|
batch.add_or_update(document, effective_content)
|
||||||
|
|
||||||
|
def remove(self, doc_id: int) -> None:
|
||||||
|
"""
|
||||||
|
Remove a single document from the index with file locking.
|
||||||
|
|
||||||
|
Convenience method for single-document removal. For bulk operations,
|
||||||
|
use batch_update() context manager for better performance.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
doc_id: Primary key of the document to remove
|
||||||
|
"""
|
||||||
|
self._ensure_open()
|
||||||
|
with self.batch_update(lock_timeout=5.0) as batch:
|
||||||
|
batch.remove(doc_id)
|
||||||
|
|
||||||
|
def search(
|
||||||
|
self,
|
||||||
|
query: str,
|
||||||
|
user: AbstractBaseUser | None,
|
||||||
|
page: int,
|
||||||
|
page_size: int,
|
||||||
|
sort_field: str | None,
|
||||||
|
*,
|
||||||
|
sort_reverse: bool,
|
||||||
|
) -> SearchResults:
|
||||||
|
"""
|
||||||
|
Execute a search query against the document index.
|
||||||
|
|
||||||
|
Processes the user query through date rewriting, normalization, and
|
||||||
|
permission filtering before executing against Tantivy. Supports both
|
||||||
|
relevance-based and field-based sorting.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
query: User's search query (supports natural date keywords, field filters)
|
||||||
|
user: User for permission filtering (None for superuser/no filtering)
|
||||||
|
page: Page number (1-indexed) for pagination
|
||||||
|
page_size: Number of results per page
|
||||||
|
sort_field: Field to sort by (None for relevance ranking)
|
||||||
|
sort_reverse: Whether to reverse the sort order
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
SearchResults with hits, total count, and processed query
|
||||||
|
"""
|
||||||
|
self._ensure_open()
|
||||||
|
tz = get_current_timezone()
|
||||||
|
user_query = parse_user_query(self._index, query, tz)
|
||||||
|
|
||||||
|
# Apply permission filter if user is not None (not superuser)
|
||||||
|
if user is not None:
|
||||||
|
permission_filter = build_permission_filter(self._schema, user)
|
||||||
|
final_query = tantivy.Query.boolean_query(
|
||||||
|
[
|
||||||
|
(tantivy.Occur.Must, user_query),
|
||||||
|
(tantivy.Occur.Must, permission_filter),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
final_query = user_query
|
||||||
|
|
||||||
|
searcher = self._index.searcher()
|
||||||
|
offset = (page - 1) * page_size
|
||||||
|
|
||||||
|
# Map sort fields
|
||||||
|
sort_field_map = {
|
||||||
|
"title": "title_sort",
|
||||||
|
"correspondent__name": "correspondent_sort",
|
||||||
|
"document_type__name": "type_sort",
|
||||||
|
"created": "created",
|
||||||
|
"added": "added",
|
||||||
|
"modified": "modified",
|
||||||
|
"archive_serial_number": "asn",
|
||||||
|
"page_count": "page_count",
|
||||||
|
"num_notes": "num_notes",
|
||||||
|
}
|
||||||
|
|
||||||
|
# Perform search
|
||||||
|
if sort_field and sort_field in sort_field_map:
|
||||||
|
mapped_field = sort_field_map[sort_field]
|
||||||
|
results = searcher.search(
|
||||||
|
final_query,
|
||||||
|
limit=offset + page_size,
|
||||||
|
order_by_field=mapped_field,
|
||||||
|
order=tantivy.Order.Desc if sort_reverse else tantivy.Order.Asc,
|
||||||
|
)
|
||||||
|
# Field sorting: hits are still (score, DocAddress) tuples; score unused
|
||||||
|
all_hits = [(hit[1], 0.0) for hit in results.hits]
|
||||||
|
else:
|
||||||
|
# Score-based search: hits are (score, DocAddress) tuples
|
||||||
|
results = searcher.search(final_query, limit=offset + page_size)
|
||||||
|
all_hits = [(hit[1], hit[0]) for hit in results.hits]
|
||||||
|
|
||||||
|
total = results.count
|
||||||
|
|
||||||
|
# Normalize scores for score-based searches
|
||||||
|
if not sort_field and all_hits:
|
||||||
|
max_score = max(hit[1] for hit in all_hits) or 1.0
|
||||||
|
all_hits = [(hit[0], hit[1] / max_score) for hit in all_hits]
|
||||||
|
|
||||||
|
# Apply threshold filter if configured (score-based search only)
|
||||||
|
threshold = settings.ADVANCED_FUZZY_SEARCH_THRESHOLD
|
||||||
|
if threshold is not None and not sort_field:
|
||||||
|
all_hits = [hit for hit in all_hits if hit[1] >= threshold]
|
||||||
|
|
||||||
|
# Get the page's hits
|
||||||
|
page_hits = all_hits[offset : offset + page_size]
|
||||||
|
|
||||||
|
# Build result hits with highlights
|
||||||
|
hits: list[SearchHit] = []
|
||||||
|
snippet_generator = None
|
||||||
|
|
||||||
|
for rank, (doc_address, score) in enumerate(page_hits, start=offset + 1):
|
||||||
|
# Get the actual document from the searcher using the doc address
|
||||||
|
actual_doc = searcher.doc(doc_address)
|
||||||
|
doc_dict = actual_doc.to_dict()
|
||||||
|
doc_id = doc_dict["id"][0]
|
||||||
|
|
||||||
|
highlights: dict[str, str] = {}
|
||||||
|
|
||||||
|
# Generate highlights if score > 0
|
||||||
|
if score > 0:
|
||||||
|
try:
|
||||||
|
if snippet_generator is None:
|
||||||
|
snippet_generator = tantivy.SnippetGenerator.create(
|
||||||
|
searcher,
|
||||||
|
final_query,
|
||||||
|
self._schema,
|
||||||
|
"content",
|
||||||
|
)
|
||||||
|
|
||||||
|
content_snippet = snippet_generator.snippet_from_doc(actual_doc)
|
||||||
|
if content_snippet:
|
||||||
|
highlights["content"] = str(content_snippet)
|
||||||
|
|
||||||
|
# Try notes highlights
|
||||||
|
if "notes" in doc_dict:
|
||||||
|
notes_generator = tantivy.SnippetGenerator.create(
|
||||||
|
searcher,
|
||||||
|
final_query,
|
||||||
|
self._schema,
|
||||||
|
"notes",
|
||||||
|
)
|
||||||
|
notes_snippet = notes_generator.snippet_from_doc(actual_doc)
|
||||||
|
if notes_snippet:
|
||||||
|
highlights["notes"] = str(notes_snippet)
|
||||||
|
|
||||||
|
except Exception: # pragma: no cover
|
||||||
|
logger.debug("Failed to generate highlights for doc %s", doc_id)
|
||||||
|
|
||||||
|
hits.append(
|
||||||
|
SearchHit(
|
||||||
|
id=doc_id,
|
||||||
|
score=score,
|
||||||
|
rank=rank,
|
||||||
|
highlights=highlights,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
return SearchResults(
|
||||||
|
hits=hits,
|
||||||
|
total=total,
|
||||||
|
query=query,
|
||||||
|
)
|
||||||
|
|
||||||
|
def autocomplete(
|
||||||
|
self,
|
||||||
|
term: str,
|
||||||
|
limit: int,
|
||||||
|
user: AbstractBaseUser | None = None,
|
||||||
|
) -> list[str]:
|
||||||
|
"""
|
||||||
|
Get autocomplete suggestions for search queries.
|
||||||
|
|
||||||
|
Returns words that start with the given term prefix, ranked by document
|
||||||
|
frequency (how many documents contain each word). Optionally filters
|
||||||
|
results to only words from documents visible to the specified user.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
term: Prefix to match against autocomplete words
|
||||||
|
limit: Maximum number of suggestions to return
|
||||||
|
user: User for permission filtering (None for no filtering)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of word suggestions ordered by frequency, then alphabetically
|
||||||
|
"""
|
||||||
|
self._ensure_open()
|
||||||
|
normalized_term = _ascii_fold(term.lower())
|
||||||
|
|
||||||
|
searcher = self._index.searcher()
|
||||||
|
|
||||||
|
# Apply permission filter for non-superusers so autocomplete words
|
||||||
|
# from invisible documents don't leak to other users.
|
||||||
|
if user is not None and not user.is_superuser:
|
||||||
|
base_query = build_permission_filter(self._schema, user)
|
||||||
|
else:
|
||||||
|
base_query = tantivy.Query.all_query()
|
||||||
|
|
||||||
|
results = searcher.search(base_query, limit=10000)
|
||||||
|
|
||||||
|
# Count how many visible documents each word appears in.
|
||||||
|
# Using Counter (not set) preserves per-word document frequency so
|
||||||
|
# we can rank suggestions by how commonly they occur — the same
|
||||||
|
# signal Whoosh used for Tf/Idf-based autocomplete ordering.
|
||||||
|
word_counts: Counter[str] = Counter()
|
||||||
|
for _score, doc_address in results.hits:
|
||||||
|
stored_doc = searcher.doc(doc_address)
|
||||||
|
doc_dict = stored_doc.to_dict()
|
||||||
|
if "autocomplete_word" in doc_dict:
|
||||||
|
word_counts.update(doc_dict["autocomplete_word"])
|
||||||
|
|
||||||
|
# Filter to prefix matches, sort by document frequency descending;
|
||||||
|
# break ties alphabetically for stable, deterministic output.
|
||||||
|
matches = sorted(
|
||||||
|
(w for w in word_counts if w.startswith(normalized_term)),
|
||||||
|
key=lambda w: (-word_counts[w], w),
|
||||||
|
)
|
||||||
|
|
||||||
|
return matches[:limit]
|
||||||
|
|
||||||
|
def more_like_this(
|
||||||
|
self,
|
||||||
|
doc_id: int,
|
||||||
|
user: AbstractBaseUser | None,
|
||||||
|
page: int,
|
||||||
|
page_size: int,
|
||||||
|
) -> SearchResults:
|
||||||
|
"""
|
||||||
|
Find documents similar to the given document using content analysis.
|
||||||
|
|
||||||
|
Uses Tantivy's "more like this" query to find documents with similar
|
||||||
|
content patterns. The original document is excluded from results.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
doc_id: Primary key of the reference document
|
||||||
|
user: User for permission filtering (None for no filtering)
|
||||||
|
page: Page number (1-indexed) for pagination
|
||||||
|
page_size: Number of results per page
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
SearchResults with similar documents (excluding the original)
|
||||||
|
"""
|
||||||
|
self._ensure_open()
|
||||||
|
searcher = self._index.searcher()
|
||||||
|
|
||||||
|
# First find the document address
|
||||||
|
id_query = tantivy.Query.range_query(
|
||||||
|
self._schema,
|
||||||
|
"id",
|
||||||
|
tantivy.FieldType.Unsigned,
|
||||||
|
doc_id,
|
||||||
|
doc_id,
|
||||||
|
)
|
||||||
|
results = searcher.search(id_query, limit=1)
|
||||||
|
|
||||||
|
if not results.hits:
|
||||||
|
# Document not found
|
||||||
|
return SearchResults(hits=[], total=0, query=f"more_like:{doc_id}")
|
||||||
|
|
||||||
|
# Extract doc_address from (score, doc_address) tuple
|
||||||
|
doc_address = results.hits[0][1]
|
||||||
|
|
||||||
|
# Build more like this query
|
||||||
|
mlt_query = tantivy.Query.more_like_this_query(
|
||||||
|
doc_address,
|
||||||
|
min_doc_frequency=1,
|
||||||
|
max_doc_frequency=None,
|
||||||
|
min_term_frequency=1,
|
||||||
|
max_query_terms=12,
|
||||||
|
min_word_length=None,
|
||||||
|
max_word_length=None,
|
||||||
|
boost_factor=None,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Apply permission filter
|
||||||
|
if user is not None:
|
||||||
|
permission_filter = build_permission_filter(self._schema, user)
|
||||||
|
final_query = tantivy.Query.boolean_query(
|
||||||
|
[
|
||||||
|
(tantivy.Occur.Must, mlt_query),
|
||||||
|
(tantivy.Occur.Must, permission_filter),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
final_query = mlt_query
|
||||||
|
|
||||||
|
# Search
|
||||||
|
offset = (page - 1) * page_size
|
||||||
|
results = searcher.search(final_query, limit=offset + page_size)
|
||||||
|
|
||||||
|
total = results.count
|
||||||
|
# Convert from (score, doc_address) to (doc_address, score)
|
||||||
|
all_hits = [(hit[1], hit[0]) for hit in results.hits]
|
||||||
|
|
||||||
|
# Normalize scores
|
||||||
|
if all_hits:
|
||||||
|
max_score = max(hit[1] for hit in all_hits) or 1.0
|
||||||
|
all_hits = [(hit[0], hit[1] / max_score) for hit in all_hits]
|
||||||
|
|
||||||
|
# Get page hits
|
||||||
|
page_hits = all_hits[offset : offset + page_size]
|
||||||
|
|
||||||
|
# Build results
|
||||||
|
hits: list[SearchHit] = []
|
||||||
|
for rank, (doc_address, score) in enumerate(page_hits, start=offset + 1):
|
||||||
|
actual_doc = searcher.doc(doc_address)
|
||||||
|
doc_dict = actual_doc.to_dict()
|
||||||
|
result_doc_id = doc_dict["id"][0]
|
||||||
|
|
||||||
|
# Skip the original document
|
||||||
|
if result_doc_id == doc_id:
|
||||||
|
continue
|
||||||
|
|
||||||
|
hits.append(
|
||||||
|
SearchHit(
|
||||||
|
id=result_doc_id,
|
||||||
|
score=score,
|
||||||
|
rank=rank,
|
||||||
|
highlights={}, # MLT doesn't generate highlights
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
return SearchResults(
|
||||||
|
hits=hits,
|
||||||
|
total=max(0, total - 1), # Subtract 1 for the original document
|
||||||
|
query=f"more_like:{doc_id}",
|
||||||
|
)
|
||||||
|
|
||||||
|
def batch_update(self, lock_timeout: float = 30.0) -> WriteBatch:
|
||||||
|
"""
|
||||||
|
Get a batch context manager for bulk index operations.
|
||||||
|
|
||||||
|
Use this for efficient bulk document updates/deletions. All operations
|
||||||
|
within the batch are committed atomically at the end of the context.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
lock_timeout: Seconds to wait for file lock acquisition
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
WriteBatch context manager
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
SearchIndexLockError: If lock cannot be acquired within timeout
|
||||||
|
"""
|
||||||
|
self._ensure_open()
|
||||||
|
return WriteBatch(self, lock_timeout)
|
||||||
|
|
||||||
|
def rebuild(
|
||||||
|
self,
|
||||||
|
documents: QuerySet[Document],
|
||||||
|
iter_wrapper: IterWrapper[Document] = identity,
|
||||||
|
) -> None:
|
||||||
|
"""
|
||||||
|
Rebuild the entire search index from scratch.
|
||||||
|
|
||||||
|
Wipes the existing index and re-indexes all provided documents.
|
||||||
|
On failure, restores the previous index state to keep the backend usable.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
documents: QuerySet of Document instances to index
|
||||||
|
iter_wrapper: Optional wrapper function for progress tracking
|
||||||
|
(e.g., progress bar). Should yield each document unchanged.
|
||||||
|
"""
|
||||||
|
# Create new index (on-disk or in-memory)
|
||||||
|
if self._path is not None:
|
||||||
|
wipe_index(self._path)
|
||||||
|
new_index = tantivy.Index(build_schema(), path=str(self._path))
|
||||||
|
_write_sentinels(self._path)
|
||||||
|
else:
|
||||||
|
new_index = tantivy.Index(build_schema())
|
||||||
|
register_tokenizers(new_index, settings.SEARCH_LANGUAGE)
|
||||||
|
|
||||||
|
# Point instance at the new index so _build_tantivy_doc uses it
|
||||||
|
old_index, old_schema = self._index, self._schema
|
||||||
|
self._index = new_index
|
||||||
|
self._schema = new_index.schema
|
||||||
|
|
||||||
|
try:
|
||||||
|
writer = new_index.writer()
|
||||||
|
for document in iter_wrapper(documents):
|
||||||
|
doc = self._build_tantivy_doc(
|
||||||
|
document,
|
||||||
|
document.get_effective_content(),
|
||||||
|
)
|
||||||
|
writer.add_document(doc)
|
||||||
|
writer.commit()
|
||||||
|
new_index.reload()
|
||||||
|
except BaseException: # pragma: no cover
|
||||||
|
# Restore old index on failure so the backend remains usable
|
||||||
|
self._index = old_index
|
||||||
|
self._schema = old_schema
|
||||||
|
raise
|
||||||
|
|
||||||
|
|
||||||
|
# Module-level singleton with proper thread safety
|
||||||
|
_backend: TantivyBackend | None = None
|
||||||
|
_backend_path: Path | None = None # tracks which INDEX_DIR the singleton uses
|
||||||
|
_backend_lock = threading.RLock()
|
||||||
|
|
||||||
|
|
||||||
|
def get_backend() -> TantivyBackend:
|
||||||
|
"""
|
||||||
|
Get the global backend instance with thread safety.
|
||||||
|
|
||||||
|
Returns a singleton TantivyBackend instance, automatically reinitializing
|
||||||
|
when settings.INDEX_DIR changes. This ensures proper test isolation when
|
||||||
|
using pytest-xdist or @override_settings that change the index directory.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Thread-safe singleton TantivyBackend instance
|
||||||
|
"""
|
||||||
|
global _backend, _backend_path
|
||||||
|
|
||||||
|
current_path: Path = settings.INDEX_DIR
|
||||||
|
|
||||||
|
# Fast path: backend is initialized and path hasn't changed (no lock needed)
|
||||||
|
if _backend is not None and _backend_path == current_path:
|
||||||
|
return _backend
|
||||||
|
|
||||||
|
# Slow path: first call, or INDEX_DIR changed between calls
|
||||||
|
with _backend_lock:
|
||||||
|
# Double-check after acquiring lock — another thread may have beaten us
|
||||||
|
if _backend is not None and _backend_path == current_path:
|
||||||
|
return _backend # pragma: no cover
|
||||||
|
|
||||||
|
if _backend is not None:
|
||||||
|
_backend.close()
|
||||||
|
|
||||||
|
_backend = TantivyBackend(path=current_path)
|
||||||
|
_backend.open()
|
||||||
|
_backend_path = current_path
|
||||||
|
|
||||||
|
return _backend
|
||||||
|
|
||||||
|
|
||||||
|
def reset_backend() -> None:
|
||||||
|
"""
|
||||||
|
Reset the global backend instance with thread safety.
|
||||||
|
|
||||||
|
Forces creation of a new backend instance on the next get_backend() call.
|
||||||
|
Used for test isolation and when switching between different index directories.
|
||||||
|
"""
|
||||||
|
global _backend, _backend_path
|
||||||
|
|
||||||
|
with _backend_lock:
|
||||||
|
if _backend is not None:
|
||||||
|
_backend.close()
|
||||||
|
_backend = None
|
||||||
|
_backend_path = None
|
||||||
497
src/documents/search/_query.py
Normal file
497
src/documents/search/_query.py
Normal file
@@ -0,0 +1,497 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import UTC
|
||||||
|
from datetime import date
|
||||||
|
from datetime import datetime
|
||||||
|
from datetime import timedelta
|
||||||
|
from typing import TYPE_CHECKING
|
||||||
|
from typing import Final
|
||||||
|
|
||||||
|
import regex
|
||||||
|
import tantivy
|
||||||
|
from dateutil.relativedelta import relativedelta
|
||||||
|
from django.conf import settings
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from datetime import tzinfo
|
||||||
|
|
||||||
|
from django.contrib.auth.base_user import AbstractBaseUser
|
||||||
|
|
||||||
|
# Maximum seconds any single regex substitution may run.
|
||||||
|
# Prevents ReDoS on adversarial user-supplied query strings.
|
||||||
|
_REGEX_TIMEOUT: Final[float] = 1.0
|
||||||
|
|
||||||
|
_DATE_ONLY_FIELDS = frozenset({"created"})
|
||||||
|
|
||||||
|
_DATE_KEYWORDS = frozenset(
|
||||||
|
{
|
||||||
|
"today",
|
||||||
|
"yesterday",
|
||||||
|
"this_week",
|
||||||
|
"last_week",
|
||||||
|
"this_month",
|
||||||
|
"last_month",
|
||||||
|
"this_year",
|
||||||
|
"last_year",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
_FIELD_DATE_RE = regex.compile(
|
||||||
|
r"(\w+):(" + "|".join(_DATE_KEYWORDS) + r")\b",
|
||||||
|
)
|
||||||
|
_COMPACT_DATE_RE = regex.compile(r"\b(\d{14})\b")
|
||||||
|
_RELATIVE_RANGE_RE = regex.compile(
|
||||||
|
r"\[now([+-]\d+[dhm])?\s+TO\s+now([+-]\d+[dhm])?\]",
|
||||||
|
regex.IGNORECASE,
|
||||||
|
)
|
||||||
|
# Whoosh-style relative date range: e.g. [-1 week to now], [-7 days to now]
|
||||||
|
_WHOOSH_REL_RANGE_RE = regex.compile(
|
||||||
|
r"\[-(?P<n>\d+)\s+(?P<unit>second|minute|hour|day|week|month|year)s?\s+to\s+now\]",
|
||||||
|
regex.IGNORECASE,
|
||||||
|
)
|
||||||
|
# Whoosh-style 8-digit date: field:YYYYMMDD — field-aware so timezone can be applied correctly
|
||||||
|
_DATE8_RE = regex.compile(r"(?P<field>\w+):(?P<date8>\d{8})\b")
|
||||||
|
|
||||||
|
|
||||||
|
def _fmt(dt: datetime) -> str:
|
||||||
|
"""Format a datetime as an ISO 8601 UTC string for use in Tantivy range queries."""
|
||||||
|
return dt.astimezone(UTC).strftime("%Y-%m-%dT%H:%M:%SZ")
|
||||||
|
|
||||||
|
|
||||||
|
def _iso_range(lo: datetime, hi: datetime) -> str:
|
||||||
|
"""Format a [lo TO hi] range string in ISO 8601 for Tantivy query syntax."""
|
||||||
|
return f"[{_fmt(lo)} TO {_fmt(hi)}]"
|
||||||
|
|
||||||
|
|
||||||
|
def _date_only_range(keyword: str, tz: tzinfo) -> str:
|
||||||
|
"""
|
||||||
|
For `created` (DateField): use the local calendar date, converted to
|
||||||
|
midnight UTC boundaries. No offset arithmetic — date only.
|
||||||
|
"""
|
||||||
|
|
||||||
|
today = datetime.now(tz).date()
|
||||||
|
|
||||||
|
if keyword == "today":
|
||||||
|
lo = datetime(today.year, today.month, today.day, tzinfo=UTC)
|
||||||
|
return _iso_range(lo, lo + timedelta(days=1))
|
||||||
|
if keyword == "yesterday":
|
||||||
|
y = today - timedelta(days=1)
|
||||||
|
lo = datetime(y.year, y.month, y.day, tzinfo=UTC)
|
||||||
|
hi = datetime(today.year, today.month, today.day, tzinfo=UTC)
|
||||||
|
return _iso_range(lo, hi)
|
||||||
|
if keyword == "this_week":
|
||||||
|
mon = today - timedelta(days=today.weekday())
|
||||||
|
lo = datetime(mon.year, mon.month, mon.day, tzinfo=UTC)
|
||||||
|
return _iso_range(lo, lo + timedelta(weeks=1))
|
||||||
|
if keyword == "last_week":
|
||||||
|
this_mon = today - timedelta(days=today.weekday())
|
||||||
|
last_mon = this_mon - timedelta(weeks=1)
|
||||||
|
lo = datetime(last_mon.year, last_mon.month, last_mon.day, tzinfo=UTC)
|
||||||
|
hi = datetime(this_mon.year, this_mon.month, this_mon.day, tzinfo=UTC)
|
||||||
|
return _iso_range(lo, hi)
|
||||||
|
if keyword == "this_month":
|
||||||
|
lo = datetime(today.year, today.month, 1, tzinfo=UTC)
|
||||||
|
if today.month == 12:
|
||||||
|
hi = datetime(today.year + 1, 1, 1, tzinfo=UTC)
|
||||||
|
else:
|
||||||
|
hi = datetime(today.year, today.month + 1, 1, tzinfo=UTC)
|
||||||
|
return _iso_range(lo, hi)
|
||||||
|
if keyword == "last_month":
|
||||||
|
if today.month == 1:
|
||||||
|
lo = datetime(today.year - 1, 12, 1, tzinfo=UTC)
|
||||||
|
else:
|
||||||
|
lo = datetime(today.year, today.month - 1, 1, tzinfo=UTC)
|
||||||
|
hi = datetime(today.year, today.month, 1, tzinfo=UTC)
|
||||||
|
return _iso_range(lo, hi)
|
||||||
|
if keyword == "this_year":
|
||||||
|
lo = datetime(today.year, 1, 1, tzinfo=UTC)
|
||||||
|
return _iso_range(lo, datetime(today.year + 1, 1, 1, tzinfo=UTC))
|
||||||
|
if keyword == "last_year":
|
||||||
|
lo = datetime(today.year - 1, 1, 1, tzinfo=UTC)
|
||||||
|
return _iso_range(lo, datetime(today.year, 1, 1, tzinfo=UTC))
|
||||||
|
raise ValueError(f"Unknown keyword: {keyword}")
|
||||||
|
|
||||||
|
|
||||||
|
def _datetime_range(keyword: str, tz: tzinfo) -> str:
|
||||||
|
"""
|
||||||
|
For `added` / `modified` (DateTimeField, stored as UTC): convert local day
|
||||||
|
boundaries to UTC — full offset arithmetic required.
|
||||||
|
"""
|
||||||
|
|
||||||
|
now_local = datetime.now(tz)
|
||||||
|
today = now_local.date()
|
||||||
|
|
||||||
|
def _midnight(d: date) -> datetime:
|
||||||
|
return datetime(d.year, d.month, d.day, tzinfo=tz).astimezone(UTC)
|
||||||
|
|
||||||
|
if keyword == "today":
|
||||||
|
return _iso_range(_midnight(today), _midnight(today + timedelta(days=1)))
|
||||||
|
if keyword == "yesterday":
|
||||||
|
y = today - timedelta(days=1)
|
||||||
|
return _iso_range(_midnight(y), _midnight(today))
|
||||||
|
if keyword == "this_week":
|
||||||
|
mon = today - timedelta(days=today.weekday())
|
||||||
|
return _iso_range(_midnight(mon), _midnight(mon + timedelta(weeks=1)))
|
||||||
|
if keyword == "last_week":
|
||||||
|
this_mon = today - timedelta(days=today.weekday())
|
||||||
|
last_mon = this_mon - timedelta(weeks=1)
|
||||||
|
return _iso_range(_midnight(last_mon), _midnight(this_mon))
|
||||||
|
if keyword == "this_month":
|
||||||
|
first = today.replace(day=1)
|
||||||
|
if today.month == 12:
|
||||||
|
next_first = date(today.year + 1, 1, 1)
|
||||||
|
else:
|
||||||
|
next_first = date(today.year, today.month + 1, 1)
|
||||||
|
return _iso_range(_midnight(first), _midnight(next_first))
|
||||||
|
if keyword == "last_month":
|
||||||
|
this_first = today.replace(day=1)
|
||||||
|
if today.month == 1:
|
||||||
|
last_first = date(today.year - 1, 12, 1)
|
||||||
|
else:
|
||||||
|
last_first = date(today.year, today.month - 1, 1)
|
||||||
|
return _iso_range(_midnight(last_first), _midnight(this_first))
|
||||||
|
if keyword == "this_year":
|
||||||
|
return _iso_range(
|
||||||
|
_midnight(date(today.year, 1, 1)),
|
||||||
|
_midnight(date(today.year + 1, 1, 1)),
|
||||||
|
)
|
||||||
|
if keyword == "last_year":
|
||||||
|
return _iso_range(
|
||||||
|
_midnight(date(today.year - 1, 1, 1)),
|
||||||
|
_midnight(date(today.year, 1, 1)),
|
||||||
|
)
|
||||||
|
raise ValueError(f"Unknown keyword: {keyword}")
|
||||||
|
|
||||||
|
|
||||||
|
def _rewrite_compact_date(query: str) -> str:
|
||||||
|
"""Rewrite Whoosh compact date tokens (14-digit YYYYMMDDHHmmss) to ISO 8601."""
|
||||||
|
|
||||||
|
def _sub(m: regex.Match[str]) -> str:
|
||||||
|
raw = m.group(1)
|
||||||
|
try:
|
||||||
|
dt = datetime(
|
||||||
|
int(raw[0:4]),
|
||||||
|
int(raw[4:6]),
|
||||||
|
int(raw[6:8]),
|
||||||
|
int(raw[8:10]),
|
||||||
|
int(raw[10:12]),
|
||||||
|
int(raw[12:14]),
|
||||||
|
tzinfo=UTC,
|
||||||
|
)
|
||||||
|
return dt.strftime("%Y-%m-%dT%H:%M:%SZ")
|
||||||
|
except ValueError:
|
||||||
|
return str(m.group(0))
|
||||||
|
|
||||||
|
try:
|
||||||
|
return _COMPACT_DATE_RE.sub(_sub, query, timeout=_REGEX_TIMEOUT)
|
||||||
|
except TimeoutError: # pragma: no cover
|
||||||
|
raise ValueError(
|
||||||
|
"Query too complex to process (compact date rewrite timed out)",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _rewrite_relative_range(query: str) -> str:
|
||||||
|
"""Rewrite Whoosh relative ranges ([now-7d TO now]) to concrete ISO 8601 UTC boundaries."""
|
||||||
|
|
||||||
|
def _sub(m: regex.Match[str]) -> str:
|
||||||
|
now = datetime.now(UTC)
|
||||||
|
|
||||||
|
def _offset(s: str | None) -> timedelta:
|
||||||
|
if not s:
|
||||||
|
return timedelta(0)
|
||||||
|
sign = 1 if s[0] == "+" else -1
|
||||||
|
n, unit = int(s[1:-1]), s[-1]
|
||||||
|
return (
|
||||||
|
sign
|
||||||
|
* {
|
||||||
|
"d": timedelta(days=n),
|
||||||
|
"h": timedelta(hours=n),
|
||||||
|
"m": timedelta(minutes=n),
|
||||||
|
}[unit]
|
||||||
|
)
|
||||||
|
|
||||||
|
lo, hi = now + _offset(m.group(1)), now + _offset(m.group(2))
|
||||||
|
if lo > hi:
|
||||||
|
lo, hi = hi, lo
|
||||||
|
return f"[{_fmt(lo)} TO {_fmt(hi)}]"
|
||||||
|
|
||||||
|
try:
|
||||||
|
return _RELATIVE_RANGE_RE.sub(_sub, query, timeout=_REGEX_TIMEOUT)
|
||||||
|
except TimeoutError: # pragma: no cover
|
||||||
|
raise ValueError(
|
||||||
|
"Query too complex to process (relative range rewrite timed out)",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _rewrite_whoosh_relative_range(query: str) -> str:
|
||||||
|
"""Rewrite Whoosh-style relative date ranges ([-N unit to now]) to ISO 8601.
|
||||||
|
|
||||||
|
Supports: second, minute, hour, day, week, month, year (singular and plural).
|
||||||
|
Example: ``added:[-1 week to now]`` → ``added:[2025-01-01T… TO 2025-01-08T…]``
|
||||||
|
"""
|
||||||
|
now = datetime.now(UTC)
|
||||||
|
|
||||||
|
def _sub(m: regex.Match[str]) -> str:
|
||||||
|
n = int(m.group("n"))
|
||||||
|
unit = m.group("unit").lower()
|
||||||
|
delta_map: dict[str, timedelta | relativedelta] = {
|
||||||
|
"second": timedelta(seconds=n),
|
||||||
|
"minute": timedelta(minutes=n),
|
||||||
|
"hour": timedelta(hours=n),
|
||||||
|
"day": timedelta(days=n),
|
||||||
|
"week": timedelta(weeks=n),
|
||||||
|
"month": relativedelta(months=n),
|
||||||
|
"year": relativedelta(years=n),
|
||||||
|
}
|
||||||
|
lo = now - delta_map[unit]
|
||||||
|
return f"[{_fmt(lo)} TO {_fmt(now)}]"
|
||||||
|
|
||||||
|
try:
|
||||||
|
return _WHOOSH_REL_RANGE_RE.sub(_sub, query, timeout=_REGEX_TIMEOUT)
|
||||||
|
except TimeoutError: # pragma: no cover
|
||||||
|
raise ValueError(
|
||||||
|
"Query too complex to process (Whoosh relative range rewrite timed out)",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _rewrite_8digit_date(query: str, tz: tzinfo) -> str:
|
||||||
|
"""Rewrite field:YYYYMMDD date tokens to an ISO 8601 day range.
|
||||||
|
|
||||||
|
Runs after ``_rewrite_compact_date`` so 14-digit timestamps are already
|
||||||
|
converted and won't spuriously match here.
|
||||||
|
|
||||||
|
For DateField fields (e.g. ``created``) uses UTC midnight boundaries.
|
||||||
|
For DateTimeField fields (e.g. ``added``, ``modified``) uses local TZ
|
||||||
|
midnight boundaries converted to UTC — matching the ``_datetime_range``
|
||||||
|
behaviour for keyword dates.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def _sub(m: regex.Match[str]) -> str:
|
||||||
|
field = m.group("field")
|
||||||
|
raw = m.group("date8")
|
||||||
|
try:
|
||||||
|
year, month, day = int(raw[0:4]), int(raw[4:6]), int(raw[6:8])
|
||||||
|
d = date(year, month, day)
|
||||||
|
if field in _DATE_ONLY_FIELDS:
|
||||||
|
lo = datetime(d.year, d.month, d.day, tzinfo=UTC)
|
||||||
|
hi = lo + timedelta(days=1)
|
||||||
|
else:
|
||||||
|
# DateTimeField: use local-timezone midnight → UTC
|
||||||
|
lo = datetime(d.year, d.month, d.day, tzinfo=tz).astimezone(UTC)
|
||||||
|
hi = datetime(
|
||||||
|
(d + timedelta(days=1)).year,
|
||||||
|
(d + timedelta(days=1)).month,
|
||||||
|
(d + timedelta(days=1)).day,
|
||||||
|
tzinfo=tz,
|
||||||
|
).astimezone(UTC)
|
||||||
|
return f"{field}:[{_fmt(lo)} TO {_fmt(hi)}]"
|
||||||
|
except ValueError:
|
||||||
|
return m.group(0)
|
||||||
|
|
||||||
|
try:
|
||||||
|
return _DATE8_RE.sub(_sub, query, timeout=_REGEX_TIMEOUT)
|
||||||
|
except TimeoutError: # pragma: no cover
|
||||||
|
raise ValueError(
|
||||||
|
"Query too complex to process (8-digit date rewrite timed out)",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def rewrite_natural_date_keywords(query: str, tz: tzinfo) -> str:
|
||||||
|
"""
|
||||||
|
Rewrite natural date syntax to ISO 8601 format for Tantivy compatibility.
|
||||||
|
|
||||||
|
Performs the first stage of query preprocessing, converting various date
|
||||||
|
formats and keywords to ISO 8601 datetime ranges that Tantivy can parse:
|
||||||
|
- Compact 14-digit dates (YYYYMMDDHHmmss)
|
||||||
|
- Whoosh relative ranges ([-7 days to now], [now-1h TO now+2h])
|
||||||
|
- 8-digit dates with field awareness (created:20240115)
|
||||||
|
- Natural keywords (field:today, field:last_week, etc.)
|
||||||
|
|
||||||
|
Args:
|
||||||
|
query: Raw user query string
|
||||||
|
tz: Timezone for converting local date boundaries to UTC
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Query with date syntax rewritten to ISO 8601 ranges
|
||||||
|
|
||||||
|
Note:
|
||||||
|
Bare keywords without field prefixes pass through unchanged.
|
||||||
|
"""
|
||||||
|
query = _rewrite_compact_date(query)
|
||||||
|
query = _rewrite_whoosh_relative_range(query)
|
||||||
|
query = _rewrite_8digit_date(query, tz)
|
||||||
|
query = _rewrite_relative_range(query)
|
||||||
|
|
||||||
|
def _replace(m: regex.Match[str]) -> str:
|
||||||
|
field, keyword = m.group(1), m.group(2)
|
||||||
|
if field in _DATE_ONLY_FIELDS:
|
||||||
|
return f"{field}:{_date_only_range(keyword, tz)}"
|
||||||
|
return f"{field}:{_datetime_range(keyword, tz)}"
|
||||||
|
|
||||||
|
try:
|
||||||
|
return _FIELD_DATE_RE.sub(_replace, query, timeout=_REGEX_TIMEOUT)
|
||||||
|
except TimeoutError: # pragma: no cover
|
||||||
|
raise ValueError(
|
||||||
|
"Query too complex to process (date keyword rewrite timed out)",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def normalize_query(query: str) -> str:
|
||||||
|
"""
|
||||||
|
Normalize query syntax for better search behavior.
|
||||||
|
|
||||||
|
Expands comma-separated field values to explicit AND clauses and
|
||||||
|
collapses excessive whitespace for cleaner parsing:
|
||||||
|
- tag:foo,bar → tag:foo AND tag:bar
|
||||||
|
- multiple spaces → single spaces
|
||||||
|
|
||||||
|
Args:
|
||||||
|
query: Query string after date rewriting
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Normalized query string ready for Tantivy parsing
|
||||||
|
"""
|
||||||
|
|
||||||
|
def _expand(m: regex.Match[str]) -> str:
|
||||||
|
field = m.group(1)
|
||||||
|
values = [v.strip() for v in m.group(2).split(",") if v.strip()]
|
||||||
|
return " AND ".join(f"{field}:{v}" for v in values)
|
||||||
|
|
||||||
|
try:
|
||||||
|
query = regex.sub(
|
||||||
|
r"(\w+):([^\s\[\]]+(?:,[^\s\[\]]+)+)",
|
||||||
|
_expand,
|
||||||
|
query,
|
||||||
|
timeout=_REGEX_TIMEOUT,
|
||||||
|
)
|
||||||
|
return regex.sub(r" {2,}", " ", query, timeout=_REGEX_TIMEOUT).strip()
|
||||||
|
except TimeoutError: # pragma: no cover
|
||||||
|
raise ValueError("Query too complex to process (normalization timed out)")
|
||||||
|
|
||||||
|
|
||||||
|
_MAX_U64 = 2**64 - 1 # u64 max — used as inclusive upper bound for "any owner" range
|
||||||
|
|
||||||
|
|
||||||
|
def build_permission_filter(
|
||||||
|
schema: tantivy.Schema,
|
||||||
|
user: AbstractBaseUser,
|
||||||
|
) -> tantivy.Query:
|
||||||
|
"""
|
||||||
|
Build a query filter for user document permissions.
|
||||||
|
|
||||||
|
Creates a query that matches only documents visible to the specified user
|
||||||
|
according to paperless-ngx permission rules:
|
||||||
|
- Public documents (no owner) are visible to all users
|
||||||
|
- Private documents are visible to their owner
|
||||||
|
- Documents explicitly shared with the user are visible
|
||||||
|
|
||||||
|
Args:
|
||||||
|
schema: Tantivy schema for field validation
|
||||||
|
user: User to check permissions for
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Tantivy query that filters results to visible documents
|
||||||
|
|
||||||
|
Implementation Notes:
|
||||||
|
- Uses range_query instead of term_query to work around unsigned integer
|
||||||
|
type detection bug in tantivy-py 0.25
|
||||||
|
- Uses boolean_query for "no owner" check since exists_query is not
|
||||||
|
available in tantivy-py 0.25.1 (available in master)
|
||||||
|
- Uses disjunction_max_query to combine permission clauses with OR logic
|
||||||
|
"""
|
||||||
|
owner_any = tantivy.Query.range_query(
|
||||||
|
schema,
|
||||||
|
"owner_id",
|
||||||
|
tantivy.FieldType.Unsigned,
|
||||||
|
1,
|
||||||
|
_MAX_U64,
|
||||||
|
)
|
||||||
|
no_owner = tantivy.Query.boolean_query(
|
||||||
|
[
|
||||||
|
(tantivy.Occur.Must, tantivy.Query.all_query()),
|
||||||
|
(tantivy.Occur.MustNot, owner_any),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
owned = tantivy.Query.range_query(
|
||||||
|
schema,
|
||||||
|
"owner_id",
|
||||||
|
tantivy.FieldType.Unsigned,
|
||||||
|
user.pk,
|
||||||
|
user.pk,
|
||||||
|
)
|
||||||
|
shared = tantivy.Query.range_query(
|
||||||
|
schema,
|
||||||
|
"viewer_id",
|
||||||
|
tantivy.FieldType.Unsigned,
|
||||||
|
user.pk,
|
||||||
|
user.pk,
|
||||||
|
)
|
||||||
|
return tantivy.Query.disjunction_max_query([no_owner, owned, shared])
|
||||||
|
|
||||||
|
|
||||||
|
DEFAULT_SEARCH_FIELDS = [
|
||||||
|
"title",
|
||||||
|
"content",
|
||||||
|
"correspondent",
|
||||||
|
"document_type",
|
||||||
|
"tag",
|
||||||
|
]
|
||||||
|
_FIELD_BOOSTS = {"title": 2.0}
|
||||||
|
|
||||||
|
|
||||||
|
def parse_user_query(
|
||||||
|
index: tantivy.Index,
|
||||||
|
raw_query: str,
|
||||||
|
tz: tzinfo,
|
||||||
|
) -> tantivy.Query:
|
||||||
|
"""
|
||||||
|
Parse user query through the complete preprocessing pipeline.
|
||||||
|
|
||||||
|
Transforms the raw user query through multiple stages:
|
||||||
|
1. Date keyword rewriting (today → ISO 8601 ranges)
|
||||||
|
2. Query normalization (comma expansion, whitespace cleanup)
|
||||||
|
3. Tantivy parsing with field boosts
|
||||||
|
4. Optional fuzzy query blending (if ADVANCED_FUZZY_SEARCH_THRESHOLD set)
|
||||||
|
|
||||||
|
Args:
|
||||||
|
index: Tantivy index with registered tokenizers
|
||||||
|
raw_query: Original user query string
|
||||||
|
tz: Timezone for date boundary calculations
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Parsed Tantivy query ready for execution
|
||||||
|
|
||||||
|
Note:
|
||||||
|
When ADVANCED_FUZZY_SEARCH_THRESHOLD is configured, adds a low-priority
|
||||||
|
fuzzy query as a Should clause (0.1 boost) to catch approximate matches
|
||||||
|
while keeping exact matches ranked higher. The threshold value is applied
|
||||||
|
as a post-search score filter, not during query construction.
|
||||||
|
"""
|
||||||
|
|
||||||
|
query_str = rewrite_natural_date_keywords(raw_query, tz)
|
||||||
|
query_str = normalize_query(query_str)
|
||||||
|
|
||||||
|
exact = index.parse_query(
|
||||||
|
query_str,
|
||||||
|
DEFAULT_SEARCH_FIELDS,
|
||||||
|
field_boosts=_FIELD_BOOSTS,
|
||||||
|
)
|
||||||
|
|
||||||
|
threshold = settings.ADVANCED_FUZZY_SEARCH_THRESHOLD
|
||||||
|
if threshold is not None:
|
||||||
|
fuzzy = index.parse_query(
|
||||||
|
query_str,
|
||||||
|
DEFAULT_SEARCH_FIELDS,
|
||||||
|
field_boosts=_FIELD_BOOSTS,
|
||||||
|
# (prefix=True, distance=1, transposition_cost_one=True) — edit-distance fuzziness
|
||||||
|
fuzzy_fields={f: (True, 1, True) for f in DEFAULT_SEARCH_FIELDS},
|
||||||
|
)
|
||||||
|
return tantivy.Query.boolean_query(
|
||||||
|
[
|
||||||
|
(tantivy.Occur.Should, exact),
|
||||||
|
# 0.1 boost keeps fuzzy hits ranked below exact matches (intentional)
|
||||||
|
(tantivy.Occur.Should, tantivy.Query.boost_query(fuzzy, 0.1)),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
return exact
|
||||||
165
src/documents/search/_schema.py
Normal file
165
src/documents/search/_schema.py
Normal file
@@ -0,0 +1,165 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import shutil
|
||||||
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
|
import tantivy
|
||||||
|
from django.conf import settings
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
logger = logging.getLogger("paperless.search")
|
||||||
|
|
||||||
|
SCHEMA_VERSION = 1
|
||||||
|
|
||||||
|
|
||||||
|
def build_schema() -> tantivy.Schema:
|
||||||
|
"""
|
||||||
|
Build the Tantivy schema for the paperless document index.
|
||||||
|
|
||||||
|
Creates a comprehensive schema supporting full-text search, filtering,
|
||||||
|
sorting, and autocomplete functionality. Includes fields for document
|
||||||
|
content, metadata, permissions, custom fields, and notes.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Configured Tantivy schema ready for index creation
|
||||||
|
"""
|
||||||
|
sb = tantivy.SchemaBuilder()
|
||||||
|
|
||||||
|
sb.add_unsigned_field("id", stored=True, indexed=True, fast=True)
|
||||||
|
sb.add_text_field("checksum", stored=True, tokenizer_name="raw")
|
||||||
|
|
||||||
|
for field in (
|
||||||
|
"title",
|
||||||
|
"correspondent",
|
||||||
|
"document_type",
|
||||||
|
"storage_path",
|
||||||
|
"original_filename",
|
||||||
|
"content",
|
||||||
|
):
|
||||||
|
sb.add_text_field(field, stored=True, tokenizer_name="paperless_text")
|
||||||
|
|
||||||
|
# Shadow sort fields - fast, not stored/indexed
|
||||||
|
for field in ("title_sort", "correspondent_sort", "type_sort"):
|
||||||
|
sb.add_text_field(
|
||||||
|
field,
|
||||||
|
stored=False,
|
||||||
|
tokenizer_name="simple_analyzer",
|
||||||
|
fast=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
# CJK support - not stored, indexed only
|
||||||
|
sb.add_text_field("bigram_content", stored=False, tokenizer_name="bigram_analyzer")
|
||||||
|
|
||||||
|
# Autocomplete prefix scan - stored, not indexed
|
||||||
|
sb.add_text_field("autocomplete_word", stored=True, tokenizer_name="raw")
|
||||||
|
|
||||||
|
sb.add_text_field("tag", stored=True, tokenizer_name="paperless_text")
|
||||||
|
|
||||||
|
# JSON fields — structured queries: notes.user:alice, custom_fields.name:invoice
|
||||||
|
sb.add_json_field("notes", stored=True, tokenizer_name="paperless_text")
|
||||||
|
sb.add_json_field("custom_fields", stored=True, tokenizer_name="paperless_text")
|
||||||
|
|
||||||
|
for field in (
|
||||||
|
"correspondent_id",
|
||||||
|
"document_type_id",
|
||||||
|
"storage_path_id",
|
||||||
|
"tag_id",
|
||||||
|
"owner_id",
|
||||||
|
"viewer_id",
|
||||||
|
):
|
||||||
|
sb.add_unsigned_field(field, stored=False, indexed=True, fast=True)
|
||||||
|
|
||||||
|
for field in ("created", "modified", "added"):
|
||||||
|
sb.add_date_field(field, stored=True, indexed=True, fast=True)
|
||||||
|
|
||||||
|
for field in ("asn", "page_count", "num_notes"):
|
||||||
|
sb.add_unsigned_field(field, stored=True, indexed=True, fast=True)
|
||||||
|
|
||||||
|
return sb.build()
|
||||||
|
|
||||||
|
|
||||||
|
def needs_rebuild(index_dir: Path) -> bool:
|
||||||
|
"""
|
||||||
|
Check if the search index needs rebuilding.
|
||||||
|
|
||||||
|
Compares the current schema version and search language configuration
|
||||||
|
against sentinel files to determine if the index is compatible with
|
||||||
|
the current paperless-ngx version and settings.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
index_dir: Path to the search index directory
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True if the index needs rebuilding, False if it's up to date
|
||||||
|
"""
|
||||||
|
version_file = index_dir / ".schema_version"
|
||||||
|
if not version_file.exists():
|
||||||
|
return True
|
||||||
|
try:
|
||||||
|
if int(version_file.read_text().strip()) != SCHEMA_VERSION:
|
||||||
|
logger.info("Search index schema version mismatch - rebuilding.")
|
||||||
|
return True
|
||||||
|
except ValueError:
|
||||||
|
return True
|
||||||
|
|
||||||
|
language_file = index_dir / ".schema_language"
|
||||||
|
if not language_file.exists():
|
||||||
|
logger.info("Search index language sentinel missing - rebuilding.")
|
||||||
|
return True
|
||||||
|
if language_file.read_text().strip() != (settings.SEARCH_LANGUAGE or ""):
|
||||||
|
logger.info("Search index language changed - rebuilding.")
|
||||||
|
return True
|
||||||
|
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def wipe_index(index_dir: Path) -> None:
|
||||||
|
"""
|
||||||
|
Delete all contents of the index directory to prepare for rebuild.
|
||||||
|
|
||||||
|
Recursively removes all files and subdirectories within the index
|
||||||
|
directory while preserving the directory itself.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
index_dir: Path to the search index directory to clear
|
||||||
|
"""
|
||||||
|
for child in index_dir.iterdir():
|
||||||
|
if child.is_dir():
|
||||||
|
shutil.rmtree(child)
|
||||||
|
else:
|
||||||
|
child.unlink()
|
||||||
|
|
||||||
|
|
||||||
|
def _write_sentinels(index_dir: Path) -> None:
|
||||||
|
"""Write schema version and language sentinel files so the next index open can skip rebuilding."""
|
||||||
|
(index_dir / ".schema_version").write_text(str(SCHEMA_VERSION))
|
||||||
|
(index_dir / ".schema_language").write_text(settings.SEARCH_LANGUAGE or "")
|
||||||
|
|
||||||
|
|
||||||
|
def open_or_rebuild_index(index_dir: Path | None = None) -> tantivy.Index:
|
||||||
|
"""
|
||||||
|
Open the Tantivy index, creating or rebuilding as needed.
|
||||||
|
|
||||||
|
Checks if the index needs rebuilding due to schema version or language
|
||||||
|
changes. If rebuilding is needed, wipes the directory and creates a fresh
|
||||||
|
index with the current schema and configuration.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
index_dir: Path to index directory (defaults to settings.INDEX_DIR)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Opened Tantivy index (caller must register custom tokenizers)
|
||||||
|
"""
|
||||||
|
if index_dir is None:
|
||||||
|
index_dir = settings.INDEX_DIR
|
||||||
|
if not index_dir.exists():
|
||||||
|
return tantivy.Index(build_schema())
|
||||||
|
if needs_rebuild(index_dir):
|
||||||
|
wipe_index(index_dir)
|
||||||
|
idx = tantivy.Index(build_schema(), path=str(index_dir))
|
||||||
|
_write_sentinels(index_dir)
|
||||||
|
return idx
|
||||||
|
return tantivy.Index.open(str(index_dir))
|
||||||
116
src/documents/search/_tokenizer.py
Normal file
116
src/documents/search/_tokenizer.py
Normal file
@@ -0,0 +1,116 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
|
||||||
|
import tantivy
|
||||||
|
|
||||||
|
logger = logging.getLogger("paperless.search")
|
||||||
|
|
||||||
|
# Mapping of ISO 639-1 codes (and common aliases) -> Tantivy Snowball name
|
||||||
|
_LANGUAGE_MAP: dict[str, str] = {
|
||||||
|
"ar": "Arabic",
|
||||||
|
"arabic": "Arabic",
|
||||||
|
"da": "Danish",
|
||||||
|
"danish": "Danish",
|
||||||
|
"nl": "Dutch",
|
||||||
|
"dutch": "Dutch",
|
||||||
|
"en": "English",
|
||||||
|
"english": "English",
|
||||||
|
"fi": "Finnish",
|
||||||
|
"finnish": "Finnish",
|
||||||
|
"fr": "French",
|
||||||
|
"french": "French",
|
||||||
|
"de": "German",
|
||||||
|
"german": "German",
|
||||||
|
"el": "Greek",
|
||||||
|
"greek": "Greek",
|
||||||
|
"hu": "Hungarian",
|
||||||
|
"hungarian": "Hungarian",
|
||||||
|
"it": "Italian",
|
||||||
|
"italian": "Italian",
|
||||||
|
"no": "Norwegian",
|
||||||
|
"norwegian": "Norwegian",
|
||||||
|
"pt": "Portuguese",
|
||||||
|
"portuguese": "Portuguese",
|
||||||
|
"ro": "Romanian",
|
||||||
|
"romanian": "Romanian",
|
||||||
|
"ru": "Russian",
|
||||||
|
"russian": "Russian",
|
||||||
|
"es": "Spanish",
|
||||||
|
"spanish": "Spanish",
|
||||||
|
"sv": "Swedish",
|
||||||
|
"swedish": "Swedish",
|
||||||
|
"ta": "Tamil",
|
||||||
|
"tamil": "Tamil",
|
||||||
|
"tr": "Turkish",
|
||||||
|
"turkish": "Turkish",
|
||||||
|
}
|
||||||
|
|
||||||
|
SUPPORTED_LANGUAGES: frozenset[str] = frozenset(_LANGUAGE_MAP)
|
||||||
|
|
||||||
|
|
||||||
|
def register_tokenizers(index: tantivy.Index, language: str | None) -> None:
|
||||||
|
"""
|
||||||
|
Register all custom tokenizers required by the paperless schema.
|
||||||
|
|
||||||
|
Must be called on every Index instance since Tantivy requires tokenizer
|
||||||
|
re-registration after each index open/creation. Registers tokenizers for
|
||||||
|
full-text search, sorting, CJK language support, and fast-field indexing.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
index: Tantivy index instance to register tokenizers on
|
||||||
|
language: ISO 639-1 language code for stemming (None to disable)
|
||||||
|
|
||||||
|
Note:
|
||||||
|
simple_analyzer is registered as both a text and fast-field tokenizer
|
||||||
|
since sort shadow fields (title_sort, correspondent_sort, type_sort)
|
||||||
|
use fast=True and Tantivy requires fast-field tokenizers to exist
|
||||||
|
even for documents that omit those fields.
|
||||||
|
"""
|
||||||
|
index.register_tokenizer("paperless_text", _paperless_text(language))
|
||||||
|
index.register_tokenizer("simple_analyzer", _simple_analyzer())
|
||||||
|
index.register_tokenizer("bigram_analyzer", _bigram_analyzer())
|
||||||
|
# Fast-field tokenizer required for fast=True text fields in the schema
|
||||||
|
index.register_fast_field_tokenizer("simple_analyzer", _simple_analyzer())
|
||||||
|
|
||||||
|
|
||||||
|
def _paperless_text(language: str | None) -> tantivy.TextAnalyzer:
|
||||||
|
"""Main full-text tokenizer for content, title, etc: simple -> remove_long(65) -> lowercase -> ascii_fold [-> stemmer]"""
|
||||||
|
builder = (
|
||||||
|
tantivy.TextAnalyzerBuilder(tantivy.Tokenizer.simple())
|
||||||
|
.filter(tantivy.Filter.remove_long(65))
|
||||||
|
.filter(tantivy.Filter.lowercase())
|
||||||
|
.filter(tantivy.Filter.ascii_fold())
|
||||||
|
)
|
||||||
|
if language:
|
||||||
|
tantivy_lang = _LANGUAGE_MAP.get(language.lower())
|
||||||
|
if tantivy_lang:
|
||||||
|
builder = builder.filter(tantivy.Filter.stemmer(tantivy_lang))
|
||||||
|
else:
|
||||||
|
logger.warning(
|
||||||
|
"Unsupported search language '%s' - stemming disabled. Supported: %s",
|
||||||
|
language,
|
||||||
|
", ".join(sorted(SUPPORTED_LANGUAGES)),
|
||||||
|
)
|
||||||
|
return builder.build()
|
||||||
|
|
||||||
|
|
||||||
|
def _simple_analyzer() -> tantivy.TextAnalyzer:
|
||||||
|
"""Tokenizer for shadow sort fields (title_sort, correspondent_sort, type_sort): simple -> lowercase -> ascii_fold."""
|
||||||
|
return (
|
||||||
|
tantivy.TextAnalyzerBuilder(tantivy.Tokenizer.simple())
|
||||||
|
.filter(tantivy.Filter.lowercase())
|
||||||
|
.filter(tantivy.Filter.ascii_fold())
|
||||||
|
.build()
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _bigram_analyzer() -> tantivy.TextAnalyzer:
|
||||||
|
"""Enables substring search in CJK text: ngram(2,2) -> lowercase. CJK / no-whitespace language support."""
|
||||||
|
return (
|
||||||
|
tantivy.TextAnalyzerBuilder(
|
||||||
|
tantivy.Tokenizer.ngram(min_gram=2, max_gram=2, prefix_only=False),
|
||||||
|
)
|
||||||
|
.filter(tantivy.Filter.lowercase())
|
||||||
|
.build()
|
||||||
|
)
|
||||||
@@ -1293,22 +1293,18 @@ class SearchResultSerializer(DocumentSerializer):
|
|||||||
documents = self.context.get("documents")
|
documents = self.context.get("documents")
|
||||||
# Otherwise we fetch this document.
|
# Otherwise we fetch this document.
|
||||||
if documents is None: # pragma: no cover
|
if documents is None: # pragma: no cover
|
||||||
# In practice we only serialize **lists** of whoosh.searching.Hit.
|
# In practice we only serialize **lists** of SearchHit dicts.
|
||||||
# I'm keeping this check for completeness but marking it no cover for now.
|
# Keeping this check for completeness but marking it no cover for now.
|
||||||
documents = self.fetch_documents([hit["id"]])
|
documents = self.fetch_documents([hit["id"]])
|
||||||
document = documents[hit["id"]]
|
document = documents[hit["id"]]
|
||||||
|
|
||||||
notes = ",".join(
|
highlights = hit.get("highlights", {})
|
||||||
[str(c.note) for c in document.notes.all()],
|
|
||||||
)
|
|
||||||
r = super().to_representation(document)
|
r = super().to_representation(document)
|
||||||
r["__search_hit__"] = {
|
r["__search_hit__"] = {
|
||||||
"score": hit.score,
|
"score": hit["score"],
|
||||||
"highlights": hit.highlights("content", text=document.content),
|
"highlights": highlights.get("content", ""),
|
||||||
"note_highlights": (
|
"note_highlights": highlights.get("notes") or None,
|
||||||
hit.highlights("notes", text=notes) if document else None
|
"rank": hit["rank"],
|
||||||
),
|
|
||||||
"rank": hit.rank,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return r
|
return r
|
||||||
@@ -1558,6 +1554,41 @@ class DocumentListSerializer(serializers.Serializer):
|
|||||||
return documents
|
return documents
|
||||||
|
|
||||||
|
|
||||||
|
class DocumentSelectionSerializer(DocumentListSerializer):
|
||||||
|
documents = serializers.ListField(
|
||||||
|
required=False,
|
||||||
|
label="Documents",
|
||||||
|
write_only=True,
|
||||||
|
child=serializers.IntegerField(),
|
||||||
|
)
|
||||||
|
|
||||||
|
all = serializers.BooleanField(
|
||||||
|
default=False,
|
||||||
|
required=False,
|
||||||
|
write_only=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
filters = serializers.DictField(
|
||||||
|
required=False,
|
||||||
|
allow_empty=True,
|
||||||
|
write_only=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
def validate(self, attrs):
|
||||||
|
if attrs.get("all", False):
|
||||||
|
attrs.setdefault("documents", [])
|
||||||
|
return attrs
|
||||||
|
|
||||||
|
if "documents" not in attrs:
|
||||||
|
raise serializers.ValidationError(
|
||||||
|
"documents is required unless all is true.",
|
||||||
|
)
|
||||||
|
|
||||||
|
documents = attrs["documents"]
|
||||||
|
self._validate_document_id_list(documents)
|
||||||
|
return attrs
|
||||||
|
|
||||||
|
|
||||||
class SourceModeValidationMixin:
|
class SourceModeValidationMixin:
|
||||||
def validate_source_mode(self, source_mode: str) -> str:
|
def validate_source_mode(self, source_mode: str) -> str:
|
||||||
if source_mode not in bulk_edit.SourceModeChoices.__dict__.values():
|
if source_mode not in bulk_edit.SourceModeChoices.__dict__.values():
|
||||||
@@ -1565,7 +1596,7 @@ class SourceModeValidationMixin:
|
|||||||
return source_mode
|
return source_mode
|
||||||
|
|
||||||
|
|
||||||
class RotateDocumentsSerializer(DocumentListSerializer, SourceModeValidationMixin):
|
class RotateDocumentsSerializer(DocumentSelectionSerializer, SourceModeValidationMixin):
|
||||||
degrees = serializers.IntegerField(required=True)
|
degrees = serializers.IntegerField(required=True)
|
||||||
source_mode = serializers.CharField(
|
source_mode = serializers.CharField(
|
||||||
required=False,
|
required=False,
|
||||||
@@ -1648,17 +1679,17 @@ class RemovePasswordDocumentsSerializer(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
class DeleteDocumentsSerializer(DocumentListSerializer):
|
class DeleteDocumentsSerializer(DocumentSelectionSerializer):
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
class ReprocessDocumentsSerializer(DocumentListSerializer):
|
class ReprocessDocumentsSerializer(DocumentSelectionSerializer):
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
class BulkEditSerializer(
|
class BulkEditSerializer(
|
||||||
SerializerWithPerms,
|
SerializerWithPerms,
|
||||||
DocumentListSerializer,
|
DocumentSelectionSerializer,
|
||||||
SetPermissionsMixin,
|
SetPermissionsMixin,
|
||||||
SourceModeValidationMixin,
|
SourceModeValidationMixin,
|
||||||
):
|
):
|
||||||
@@ -1986,6 +2017,19 @@ class BulkEditSerializer(
|
|||||||
raise serializers.ValidationError("password must be a string")
|
raise serializers.ValidationError("password must be a string")
|
||||||
|
|
||||||
def validate(self, attrs):
|
def validate(self, attrs):
|
||||||
|
attrs = super().validate(attrs)
|
||||||
|
|
||||||
|
if attrs.get("all", False) and attrs["method"] in [
|
||||||
|
bulk_edit.merge,
|
||||||
|
bulk_edit.split,
|
||||||
|
bulk_edit.delete_pages,
|
||||||
|
bulk_edit.edit_pdf,
|
||||||
|
bulk_edit.remove_password,
|
||||||
|
]:
|
||||||
|
raise serializers.ValidationError(
|
||||||
|
"This method does not support all=true.",
|
||||||
|
)
|
||||||
|
|
||||||
method = attrs["method"]
|
method = attrs["method"]
|
||||||
parameters = attrs["parameters"]
|
parameters = attrs["parameters"]
|
||||||
|
|
||||||
@@ -2243,7 +2287,7 @@ class DocumentVersionLabelSerializer(serializers.Serializer):
|
|||||||
return normalized or None
|
return normalized or None
|
||||||
|
|
||||||
|
|
||||||
class BulkDownloadSerializer(DocumentListSerializer):
|
class BulkDownloadSerializer(DocumentSelectionSerializer):
|
||||||
content = serializers.ChoiceField(
|
content = serializers.ChoiceField(
|
||||||
choices=["archive", "originals", "both"],
|
choices=["archive", "originals", "both"],
|
||||||
default="archive",
|
default="archive",
|
||||||
@@ -2602,13 +2646,25 @@ class ShareLinkBundleSerializer(OwnedObjectSerializer):
|
|||||||
|
|
||||||
class BulkEditObjectsSerializer(SerializerWithPerms, SetPermissionsMixin):
|
class BulkEditObjectsSerializer(SerializerWithPerms, SetPermissionsMixin):
|
||||||
objects = serializers.ListField(
|
objects = serializers.ListField(
|
||||||
required=True,
|
required=False,
|
||||||
allow_empty=False,
|
allow_empty=True,
|
||||||
label="Objects",
|
label="Objects",
|
||||||
write_only=True,
|
write_only=True,
|
||||||
child=serializers.IntegerField(),
|
child=serializers.IntegerField(),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
all = serializers.BooleanField(
|
||||||
|
default=False,
|
||||||
|
required=False,
|
||||||
|
write_only=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
filters = serializers.DictField(
|
||||||
|
required=False,
|
||||||
|
allow_empty=True,
|
||||||
|
write_only=True,
|
||||||
|
)
|
||||||
|
|
||||||
object_type = serializers.ChoiceField(
|
object_type = serializers.ChoiceField(
|
||||||
choices=[
|
choices=[
|
||||||
"tags",
|
"tags",
|
||||||
@@ -2681,10 +2737,20 @@ class BulkEditObjectsSerializer(SerializerWithPerms, SetPermissionsMixin):
|
|||||||
|
|
||||||
def validate(self, attrs):
|
def validate(self, attrs):
|
||||||
object_type = attrs["object_type"]
|
object_type = attrs["object_type"]
|
||||||
objects = attrs["objects"]
|
objects = attrs.get("objects")
|
||||||
|
apply_to_all = attrs.get("all", False)
|
||||||
operation = attrs.get("operation")
|
operation = attrs.get("operation")
|
||||||
|
|
||||||
self._validate_objects(objects, object_type)
|
if apply_to_all:
|
||||||
|
attrs.setdefault("objects", [])
|
||||||
|
else:
|
||||||
|
if objects is None:
|
||||||
|
raise serializers.ValidationError(
|
||||||
|
"objects is required unless all is true.",
|
||||||
|
)
|
||||||
|
if len(objects) == 0:
|
||||||
|
raise serializers.ValidationError("objects must not be empty")
|
||||||
|
self._validate_objects(objects, object_type)
|
||||||
|
|
||||||
if operation == "set_permissions":
|
if operation == "set_permissions":
|
||||||
permissions = attrs.get("permissions")
|
permissions = attrs.get("permissions")
|
||||||
|
|||||||
@@ -790,15 +790,12 @@ def cleanup_user_deletion(sender, instance: User | Group, **kwargs) -> None:
|
|||||||
|
|
||||||
|
|
||||||
def add_to_index(sender, document, **kwargs) -> None:
|
def add_to_index(sender, document, **kwargs) -> None:
|
||||||
from documents import index
|
from documents.search import get_backend
|
||||||
|
|
||||||
index.add_or_update_document(document)
|
get_backend().add_or_update(
|
||||||
if document.root_document_id is not None and document.root_document is not None:
|
document,
|
||||||
# keep in sync when a new version is consumed.
|
effective_content=document.get_effective_content(),
|
||||||
index.add_or_update_document(
|
)
|
||||||
document.root_document,
|
|
||||||
effective_content=document.content,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def run_workflows_added(
|
def run_workflows_added(
|
||||||
|
|||||||
@@ -4,11 +4,9 @@ import shutil
|
|||||||
import uuid
|
import uuid
|
||||||
import zipfile
|
import zipfile
|
||||||
from collections.abc import Callable
|
from collections.abc import Callable
|
||||||
from collections.abc import Iterable
|
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from tempfile import TemporaryDirectory
|
from tempfile import TemporaryDirectory
|
||||||
from tempfile import mkstemp
|
from tempfile import mkstemp
|
||||||
from typing import TypeVar
|
|
||||||
|
|
||||||
from celery import Task
|
from celery import Task
|
||||||
from celery import shared_task
|
from celery import shared_task
|
||||||
@@ -20,9 +18,7 @@ from django.db import transaction
|
|||||||
from django.db.models.signals import post_save
|
from django.db.models.signals import post_save
|
||||||
from django.utils import timezone
|
from django.utils import timezone
|
||||||
from filelock import FileLock
|
from filelock import FileLock
|
||||||
from whoosh.writing import AsyncWriter
|
|
||||||
|
|
||||||
from documents import index
|
|
||||||
from documents import sanity_checker
|
from documents import sanity_checker
|
||||||
from documents.barcodes import BarcodePlugin
|
from documents.barcodes import BarcodePlugin
|
||||||
from documents.bulk_download import ArchiveOnlyStrategy
|
from documents.bulk_download import ArchiveOnlyStrategy
|
||||||
@@ -60,7 +56,9 @@ from documents.signals import document_updated
|
|||||||
from documents.signals.handlers import cleanup_document_deletion
|
from documents.signals.handlers import cleanup_document_deletion
|
||||||
from documents.signals.handlers import run_workflows
|
from documents.signals.handlers import run_workflows
|
||||||
from documents.signals.handlers import send_websocket_document_updated
|
from documents.signals.handlers import send_websocket_document_updated
|
||||||
|
from documents.utils import IterWrapper
|
||||||
from documents.utils import compute_checksum
|
from documents.utils import compute_checksum
|
||||||
|
from documents.utils import identity
|
||||||
from documents.workflows.utils import get_workflows_for_trigger
|
from documents.workflows.utils import get_workflows_for_trigger
|
||||||
from paperless.config import AIConfig
|
from paperless.config import AIConfig
|
||||||
from paperless.parsers import ParserContext
|
from paperless.parsers import ParserContext
|
||||||
@@ -69,34 +67,16 @@ from paperless_ai.indexing import llm_index_add_or_update_document
|
|||||||
from paperless_ai.indexing import llm_index_remove_document
|
from paperless_ai.indexing import llm_index_remove_document
|
||||||
from paperless_ai.indexing import update_llm_index
|
from paperless_ai.indexing import update_llm_index
|
||||||
|
|
||||||
_T = TypeVar("_T")
|
|
||||||
IterWrapper = Callable[[Iterable[_T]], Iterable[_T]]
|
|
||||||
|
|
||||||
|
|
||||||
if settings.AUDIT_LOG_ENABLED:
|
if settings.AUDIT_LOG_ENABLED:
|
||||||
from auditlog.models import LogEntry
|
from auditlog.models import LogEntry
|
||||||
logger = logging.getLogger("paperless.tasks")
|
logger = logging.getLogger("paperless.tasks")
|
||||||
|
|
||||||
|
|
||||||
def _identity(iterable: Iterable[_T]) -> Iterable[_T]:
|
|
||||||
return iterable
|
|
||||||
|
|
||||||
|
|
||||||
@shared_task
|
@shared_task
|
||||||
def index_optimize() -> None:
|
def index_optimize() -> None:
|
||||||
ix = index.open_index()
|
logger.info(
|
||||||
writer = AsyncWriter(ix)
|
"index_optimize is a no-op — Tantivy manages segment merging automatically.",
|
||||||
writer.commit(optimize=True)
|
)
|
||||||
|
|
||||||
|
|
||||||
def index_reindex(*, iter_wrapper: IterWrapper[Document] = _identity) -> None:
|
|
||||||
documents = Document.objects.all()
|
|
||||||
|
|
||||||
ix = index.open_index(recreate=True)
|
|
||||||
|
|
||||||
with AsyncWriter(ix) as writer:
|
|
||||||
for document in iter_wrapper(documents):
|
|
||||||
index.update_document(writer, document)
|
|
||||||
|
|
||||||
|
|
||||||
@shared_task
|
@shared_task
|
||||||
@@ -270,9 +250,9 @@ def sanity_check(*, scheduled=True, raise_on_error=True):
|
|||||||
|
|
||||||
@shared_task
|
@shared_task
|
||||||
def bulk_update_documents(document_ids) -> None:
|
def bulk_update_documents(document_ids) -> None:
|
||||||
documents = Document.objects.filter(id__in=document_ids)
|
from documents.search import get_backend
|
||||||
|
|
||||||
ix = index.open_index()
|
documents = Document.objects.filter(id__in=document_ids)
|
||||||
|
|
||||||
for doc in documents:
|
for doc in documents:
|
||||||
clear_document_caches(doc.pk)
|
clear_document_caches(doc.pk)
|
||||||
@@ -283,9 +263,9 @@ def bulk_update_documents(document_ids) -> None:
|
|||||||
)
|
)
|
||||||
post_save.send(Document, instance=doc, created=False)
|
post_save.send(Document, instance=doc, created=False)
|
||||||
|
|
||||||
with AsyncWriter(ix) as writer:
|
with get_backend().batch_update() as batch:
|
||||||
for doc in documents:
|
for doc in documents:
|
||||||
index.update_document(writer, doc)
|
batch.add_or_update(doc)
|
||||||
|
|
||||||
ai_config = AIConfig()
|
ai_config = AIConfig()
|
||||||
if ai_config.llm_index_enabled:
|
if ai_config.llm_index_enabled:
|
||||||
@@ -389,8 +369,9 @@ def update_document_content_maybe_archive_file(document_id) -> None:
|
|||||||
logger.info(
|
logger.info(
|
||||||
f"Updating index for document {document_id} ({document.archive_checksum})",
|
f"Updating index for document {document_id} ({document.archive_checksum})",
|
||||||
)
|
)
|
||||||
with index.open_index_writer() as writer:
|
from documents.search import get_backend
|
||||||
index.update_document(writer, document)
|
|
||||||
|
get_backend().add_or_update(document)
|
||||||
|
|
||||||
ai_config = AIConfig()
|
ai_config = AIConfig()
|
||||||
if ai_config.llm_index_enabled:
|
if ai_config.llm_index_enabled:
|
||||||
@@ -633,7 +614,7 @@ def update_document_parent_tags(tag: Tag, new_parent: Tag) -> None:
|
|||||||
@shared_task
|
@shared_task
|
||||||
def llmindex_index(
|
def llmindex_index(
|
||||||
*,
|
*,
|
||||||
iter_wrapper: IterWrapper[Document] = _identity,
|
iter_wrapper: IterWrapper[Document] = identity,
|
||||||
rebuild=False,
|
rebuild=False,
|
||||||
scheduled=True,
|
scheduled=True,
|
||||||
auto=False,
|
auto=False,
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import shutil
|
import shutil
|
||||||
import zoneinfo
|
import zoneinfo
|
||||||
|
from collections.abc import Generator
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import TYPE_CHECKING
|
from typing import TYPE_CHECKING
|
||||||
@@ -92,6 +93,26 @@ def sample_doc(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture()
|
||||||
|
def _search_index(
|
||||||
|
tmp_path: Path,
|
||||||
|
settings: SettingsWrapper,
|
||||||
|
) -> Generator[None, None, None]:
|
||||||
|
"""Create a temp index directory and point INDEX_DIR at it.
|
||||||
|
|
||||||
|
Resets the backend singleton before and after so each test gets a clean
|
||||||
|
index rather than reusing a stale singleton from another test.
|
||||||
|
"""
|
||||||
|
from documents.search import reset_backend
|
||||||
|
|
||||||
|
index_dir = tmp_path / "index"
|
||||||
|
index_dir.mkdir()
|
||||||
|
settings.INDEX_DIR = index_dir
|
||||||
|
reset_backend()
|
||||||
|
yield
|
||||||
|
reset_backend()
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture()
|
@pytest.fixture()
|
||||||
def settings_timezone(settings: SettingsWrapper) -> zoneinfo.ZoneInfo:
|
def settings_timezone(settings: SettingsWrapper) -> zoneinfo.ZoneInfo:
|
||||||
return zoneinfo.ZoneInfo(settings.TIME_ZONE)
|
return zoneinfo.ZoneInfo(settings.TIME_ZONE)
|
||||||
|
|||||||
0
src/documents/tests/search/__init__.py
Normal file
0
src/documents/tests/search/__init__.py
Normal file
33
src/documents/tests/search/conftest.py
Normal file
33
src/documents/tests/search/conftest.py
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from documents.search._backend import TantivyBackend
|
||||||
|
from documents.search._backend import reset_backend
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from collections.abc import Generator
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from pytest_django.fixtures import SettingsWrapper
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def index_dir(tmp_path: Path, settings: SettingsWrapper) -> Path:
|
||||||
|
path = tmp_path / "index"
|
||||||
|
path.mkdir()
|
||||||
|
settings.INDEX_DIR = path
|
||||||
|
return path
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def backend() -> Generator[TantivyBackend, None, None]:
|
||||||
|
b = TantivyBackend() # path=None → in-memory index
|
||||||
|
b.open()
|
||||||
|
try:
|
||||||
|
yield b
|
||||||
|
finally:
|
||||||
|
b.close()
|
||||||
|
reset_backend()
|
||||||
502
src/documents/tests/search/test_backend.py
Normal file
502
src/documents/tests/search/test_backend.py
Normal file
@@ -0,0 +1,502 @@
|
|||||||
|
import pytest
|
||||||
|
from django.contrib.auth.models import User
|
||||||
|
|
||||||
|
from documents.models import CustomField
|
||||||
|
from documents.models import CustomFieldInstance
|
||||||
|
from documents.models import Document
|
||||||
|
from documents.models import Note
|
||||||
|
from documents.search._backend import TantivyBackend
|
||||||
|
from documents.search._backend import get_backend
|
||||||
|
from documents.search._backend import reset_backend
|
||||||
|
|
||||||
|
pytestmark = [pytest.mark.search, pytest.mark.django_db]
|
||||||
|
|
||||||
|
|
||||||
|
class TestWriteBatch:
|
||||||
|
"""Test WriteBatch context manager functionality."""
|
||||||
|
|
||||||
|
def test_rolls_back_on_exception(self, backend: TantivyBackend):
|
||||||
|
"""Batch operations must rollback on exception to preserve index integrity."""
|
||||||
|
doc = Document.objects.create(
|
||||||
|
title="Rollback Target",
|
||||||
|
content="should survive",
|
||||||
|
checksum="RB1",
|
||||||
|
pk=1,
|
||||||
|
)
|
||||||
|
backend.add_or_update(doc)
|
||||||
|
|
||||||
|
try:
|
||||||
|
with backend.batch_update() as batch:
|
||||||
|
batch.remove(doc.pk)
|
||||||
|
raise RuntimeError("simulated failure")
|
||||||
|
except RuntimeError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
r = backend.search(
|
||||||
|
"should survive",
|
||||||
|
user=None,
|
||||||
|
page=1,
|
||||||
|
page_size=10,
|
||||||
|
sort_field=None,
|
||||||
|
sort_reverse=False,
|
||||||
|
)
|
||||||
|
assert r.total == 1
|
||||||
|
|
||||||
|
|
||||||
|
class TestSearch:
|
||||||
|
"""Test search functionality."""
|
||||||
|
|
||||||
|
def test_scores_normalised_top_hit_is_one(self, backend: TantivyBackend):
|
||||||
|
"""Search scores must be normalized so top hit has score 1.0 for UI consistency."""
|
||||||
|
for i, title in enumerate(["bank invoice", "bank statement", "bank receipt"]):
|
||||||
|
doc = Document.objects.create(
|
||||||
|
title=title,
|
||||||
|
content=title,
|
||||||
|
checksum=f"SN{i}",
|
||||||
|
pk=10 + i,
|
||||||
|
)
|
||||||
|
backend.add_or_update(doc)
|
||||||
|
r = backend.search(
|
||||||
|
"bank",
|
||||||
|
user=None,
|
||||||
|
page=1,
|
||||||
|
page_size=10,
|
||||||
|
sort_field=None,
|
||||||
|
sort_reverse=False,
|
||||||
|
)
|
||||||
|
assert r.hits[0]["score"] == pytest.approx(1.0)
|
||||||
|
assert all(0.0 <= h["score"] <= 1.0 for h in r.hits)
|
||||||
|
|
||||||
|
def test_sort_field_ascending(self, backend: TantivyBackend):
|
||||||
|
"""Searching with sort_reverse=False must return results in ascending ASN order."""
|
||||||
|
for asn in [30, 10, 20]:
|
||||||
|
doc = Document.objects.create(
|
||||||
|
title="sortable",
|
||||||
|
content="sortable content",
|
||||||
|
checksum=f"SFA{asn}",
|
||||||
|
archive_serial_number=asn,
|
||||||
|
)
|
||||||
|
backend.add_or_update(doc)
|
||||||
|
|
||||||
|
r = backend.search(
|
||||||
|
"sortable",
|
||||||
|
user=None,
|
||||||
|
page=1,
|
||||||
|
page_size=10,
|
||||||
|
sort_field="archive_serial_number",
|
||||||
|
sort_reverse=False,
|
||||||
|
)
|
||||||
|
assert r.total == 3
|
||||||
|
asns = [Document.objects.get(pk=h["id"]).archive_serial_number for h in r.hits]
|
||||||
|
assert asns == [10, 20, 30]
|
||||||
|
|
||||||
|
def test_sort_field_descending(self, backend: TantivyBackend):
|
||||||
|
"""Searching with sort_reverse=True must return results in descending ASN order."""
|
||||||
|
for asn in [30, 10, 20]:
|
||||||
|
doc = Document.objects.create(
|
||||||
|
title="sortable",
|
||||||
|
content="sortable content",
|
||||||
|
checksum=f"SFD{asn}",
|
||||||
|
archive_serial_number=asn,
|
||||||
|
)
|
||||||
|
backend.add_or_update(doc)
|
||||||
|
|
||||||
|
r = backend.search(
|
||||||
|
"sortable",
|
||||||
|
user=None,
|
||||||
|
page=1,
|
||||||
|
page_size=10,
|
||||||
|
sort_field="archive_serial_number",
|
||||||
|
sort_reverse=True,
|
||||||
|
)
|
||||||
|
assert r.total == 3
|
||||||
|
asns = [Document.objects.get(pk=h["id"]).archive_serial_number for h in r.hits]
|
||||||
|
assert asns == [30, 20, 10]
|
||||||
|
|
||||||
|
def test_fuzzy_threshold_filters_low_score_hits(
|
||||||
|
self,
|
||||||
|
backend: TantivyBackend,
|
||||||
|
settings,
|
||||||
|
):
|
||||||
|
"""When ADVANCED_FUZZY_SEARCH_THRESHOLD exceeds all normalized scores, hits must be filtered out."""
|
||||||
|
doc = Document.objects.create(
|
||||||
|
title="Invoice document",
|
||||||
|
content="financial report",
|
||||||
|
checksum="FT1",
|
||||||
|
pk=120,
|
||||||
|
)
|
||||||
|
backend.add_or_update(doc)
|
||||||
|
|
||||||
|
# Threshold above 1.0 filters every hit (normalized scores top out at 1.0)
|
||||||
|
settings.ADVANCED_FUZZY_SEARCH_THRESHOLD = 1.1
|
||||||
|
r = backend.search(
|
||||||
|
"invoice",
|
||||||
|
user=None,
|
||||||
|
page=1,
|
||||||
|
page_size=10,
|
||||||
|
sort_field=None,
|
||||||
|
sort_reverse=False,
|
||||||
|
)
|
||||||
|
assert r.hits == []
|
||||||
|
|
||||||
|
def test_owner_filter(self, backend: TantivyBackend):
|
||||||
|
"""Document owners can search their private documents; other users cannot access them."""
|
||||||
|
owner = User.objects.create_user("owner")
|
||||||
|
other = User.objects.create_user("other")
|
||||||
|
doc = Document.objects.create(
|
||||||
|
title="Private",
|
||||||
|
content="secret",
|
||||||
|
checksum="PF1",
|
||||||
|
pk=20,
|
||||||
|
owner=owner,
|
||||||
|
)
|
||||||
|
backend.add_or_update(doc)
|
||||||
|
|
||||||
|
assert (
|
||||||
|
backend.search(
|
||||||
|
"secret",
|
||||||
|
user=owner,
|
||||||
|
page=1,
|
||||||
|
page_size=10,
|
||||||
|
sort_field=None,
|
||||||
|
sort_reverse=False,
|
||||||
|
).total
|
||||||
|
== 1
|
||||||
|
)
|
||||||
|
assert (
|
||||||
|
backend.search(
|
||||||
|
"secret",
|
||||||
|
user=other,
|
||||||
|
page=1,
|
||||||
|
page_size=10,
|
||||||
|
sort_field=None,
|
||||||
|
sort_reverse=False,
|
||||||
|
).total
|
||||||
|
== 0
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class TestRebuild:
|
||||||
|
"""Test index rebuilding functionality."""
|
||||||
|
|
||||||
|
def test_with_iter_wrapper_called(self, backend: TantivyBackend):
|
||||||
|
"""Index rebuild must pass documents through iter_wrapper for progress tracking."""
|
||||||
|
seen = []
|
||||||
|
|
||||||
|
def wrapper(docs):
|
||||||
|
for doc in docs:
|
||||||
|
seen.append(doc.pk)
|
||||||
|
yield doc
|
||||||
|
|
||||||
|
Document.objects.create(title="Tracked", content="x", checksum="TW1", pk=30)
|
||||||
|
backend.rebuild(Document.objects.all(), iter_wrapper=wrapper)
|
||||||
|
assert 30 in seen
|
||||||
|
|
||||||
|
|
||||||
|
class TestAutocomplete:
|
||||||
|
"""Test autocomplete functionality."""
|
||||||
|
|
||||||
|
def test_basic_functionality(self, backend: TantivyBackend):
|
||||||
|
"""Autocomplete must return words matching the given prefix."""
|
||||||
|
doc = Document.objects.create(
|
||||||
|
title="Invoice from Microsoft Corporation",
|
||||||
|
content="payment details",
|
||||||
|
checksum="AC1",
|
||||||
|
pk=40,
|
||||||
|
)
|
||||||
|
backend.add_or_update(doc)
|
||||||
|
|
||||||
|
results = backend.autocomplete("micro", limit=10)
|
||||||
|
assert "microsoft" in results
|
||||||
|
|
||||||
|
def test_results_ordered_by_document_frequency(self, backend: TantivyBackend):
|
||||||
|
"""Autocomplete results must be ordered by document frequency to prioritize common terms."""
|
||||||
|
# "payment" appears in 3 docs; "payslip" in 1 — "pay" prefix should
|
||||||
|
# return "payment" before "payslip".
|
||||||
|
for i, (title, checksum) in enumerate(
|
||||||
|
[
|
||||||
|
("payment invoice", "AF1"),
|
||||||
|
("payment receipt", "AF2"),
|
||||||
|
("payment confirmation", "AF3"),
|
||||||
|
("payslip march", "AF4"),
|
||||||
|
],
|
||||||
|
start=41,
|
||||||
|
):
|
||||||
|
doc = Document.objects.create(
|
||||||
|
title=title,
|
||||||
|
content="details",
|
||||||
|
checksum=checksum,
|
||||||
|
pk=i,
|
||||||
|
)
|
||||||
|
backend.add_or_update(doc)
|
||||||
|
|
||||||
|
results = backend.autocomplete("pay", limit=10)
|
||||||
|
assert results.index("payment") < results.index("payslip")
|
||||||
|
|
||||||
|
|
||||||
|
class TestMoreLikeThis:
|
||||||
|
"""Test more like this functionality."""
|
||||||
|
|
||||||
|
def test_excludes_original(self, backend: TantivyBackend):
|
||||||
|
"""More like this queries must exclude the reference document from results."""
|
||||||
|
doc1 = Document.objects.create(
|
||||||
|
title="Important document",
|
||||||
|
content="financial information",
|
||||||
|
checksum="MLT1",
|
||||||
|
pk=50,
|
||||||
|
)
|
||||||
|
doc2 = Document.objects.create(
|
||||||
|
title="Another document",
|
||||||
|
content="financial report",
|
||||||
|
checksum="MLT2",
|
||||||
|
pk=51,
|
||||||
|
)
|
||||||
|
backend.add_or_update(doc1)
|
||||||
|
backend.add_or_update(doc2)
|
||||||
|
|
||||||
|
results = backend.more_like_this(doc_id=50, user=None, page=1, page_size=10)
|
||||||
|
returned_ids = [hit["id"] for hit in results.hits]
|
||||||
|
assert 50 not in returned_ids # Original document excluded
|
||||||
|
|
||||||
|
def test_with_user_applies_permission_filter(self, backend: TantivyBackend):
|
||||||
|
"""more_like_this with a user must exclude documents that user cannot see."""
|
||||||
|
viewer = User.objects.create_user("mlt_viewer")
|
||||||
|
other = User.objects.create_user("mlt_other")
|
||||||
|
public_doc = Document.objects.create(
|
||||||
|
title="Public financial document",
|
||||||
|
content="quarterly financial analysis report figures",
|
||||||
|
checksum="MLT3",
|
||||||
|
pk=52,
|
||||||
|
)
|
||||||
|
private_doc = Document.objects.create(
|
||||||
|
title="Private financial document",
|
||||||
|
content="quarterly financial analysis report figures",
|
||||||
|
checksum="MLT4",
|
||||||
|
pk=53,
|
||||||
|
owner=other,
|
||||||
|
)
|
||||||
|
backend.add_or_update(public_doc)
|
||||||
|
backend.add_or_update(private_doc)
|
||||||
|
|
||||||
|
results = backend.more_like_this(doc_id=52, user=viewer, page=1, page_size=10)
|
||||||
|
returned_ids = [hit["id"] for hit in results.hits]
|
||||||
|
# private_doc is owned by other, so viewer cannot see it
|
||||||
|
assert 53 not in returned_ids
|
||||||
|
|
||||||
|
def test_document_not_in_index_returns_empty(self, backend: TantivyBackend):
|
||||||
|
"""more_like_this for a doc_id absent from the index must return empty results."""
|
||||||
|
results = backend.more_like_this(doc_id=9999, user=None, page=1, page_size=10)
|
||||||
|
assert results.hits == []
|
||||||
|
assert results.total == 0
|
||||||
|
|
||||||
|
|
||||||
|
class TestSingleton:
|
||||||
|
"""Test get_backend() and reset_backend() singleton lifecycle."""
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def _clean(self):
|
||||||
|
reset_backend()
|
||||||
|
yield
|
||||||
|
reset_backend()
|
||||||
|
|
||||||
|
def test_returns_same_instance_on_repeated_calls(self, index_dir):
|
||||||
|
"""Singleton pattern: repeated calls to get_backend() must return the same instance."""
|
||||||
|
assert get_backend() is get_backend()
|
||||||
|
|
||||||
|
def test_reinitializes_when_index_dir_changes(self, tmp_path, settings):
|
||||||
|
"""Backend singleton must reinitialize when INDEX_DIR setting changes for test isolation."""
|
||||||
|
settings.INDEX_DIR = tmp_path / "a"
|
||||||
|
(tmp_path / "a").mkdir()
|
||||||
|
b1 = get_backend()
|
||||||
|
|
||||||
|
settings.INDEX_DIR = tmp_path / "b"
|
||||||
|
(tmp_path / "b").mkdir()
|
||||||
|
b2 = get_backend()
|
||||||
|
|
||||||
|
assert b1 is not b2
|
||||||
|
assert b2._path == tmp_path / "b"
|
||||||
|
|
||||||
|
def test_reset_forces_new_instance(self, index_dir):
|
||||||
|
"""reset_backend() must force creation of a new backend instance on next get_backend() call."""
|
||||||
|
b1 = get_backend()
|
||||||
|
reset_backend()
|
||||||
|
b2 = get_backend()
|
||||||
|
assert b1 is not b2
|
||||||
|
|
||||||
|
|
||||||
|
class TestFieldHandling:
|
||||||
|
"""Test handling of various document fields."""
|
||||||
|
|
||||||
|
def test_none_values_handled_correctly(self, backend: TantivyBackend):
|
||||||
|
"""Document fields with None values must not cause indexing errors."""
|
||||||
|
doc = Document.objects.create(
|
||||||
|
title="Test Doc",
|
||||||
|
content="test content",
|
||||||
|
checksum="NV1",
|
||||||
|
pk=60,
|
||||||
|
original_filename=None,
|
||||||
|
page_count=None,
|
||||||
|
)
|
||||||
|
# Should not raise an exception
|
||||||
|
backend.add_or_update(doc)
|
||||||
|
|
||||||
|
results = backend.search(
|
||||||
|
"test",
|
||||||
|
user=None,
|
||||||
|
page=1,
|
||||||
|
page_size=10,
|
||||||
|
sort_field=None,
|
||||||
|
sort_reverse=False,
|
||||||
|
)
|
||||||
|
assert results.total == 1
|
||||||
|
|
||||||
|
def test_custom_fields_include_name_and_value(self, backend: TantivyBackend):
|
||||||
|
"""Custom fields must be indexed with both field name and value for structured queries."""
|
||||||
|
# Create a custom field
|
||||||
|
field = CustomField.objects.create(
|
||||||
|
name="Invoice Number",
|
||||||
|
data_type=CustomField.FieldDataType.STRING,
|
||||||
|
)
|
||||||
|
doc = Document.objects.create(
|
||||||
|
title="Invoice",
|
||||||
|
content="test",
|
||||||
|
checksum="CF1",
|
||||||
|
pk=70,
|
||||||
|
)
|
||||||
|
CustomFieldInstance.objects.create(
|
||||||
|
document=doc,
|
||||||
|
field=field,
|
||||||
|
value_text="INV-2024-001",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Should not raise an exception during indexing
|
||||||
|
backend.add_or_update(doc)
|
||||||
|
|
||||||
|
results = backend.search(
|
||||||
|
"invoice",
|
||||||
|
user=None,
|
||||||
|
page=1,
|
||||||
|
page_size=10,
|
||||||
|
sort_field=None,
|
||||||
|
sort_reverse=False,
|
||||||
|
)
|
||||||
|
assert results.total == 1
|
||||||
|
|
||||||
|
def test_select_custom_field_indexes_label_not_id(self, backend: TantivyBackend):
|
||||||
|
"""SELECT custom fields must index the human-readable label, not the opaque option ID."""
|
||||||
|
field = CustomField.objects.create(
|
||||||
|
name="Category",
|
||||||
|
data_type=CustomField.FieldDataType.SELECT,
|
||||||
|
extra_data={
|
||||||
|
"select_options": [
|
||||||
|
{"id": "opt_abc", "label": "Invoice"},
|
||||||
|
{"id": "opt_def", "label": "Receipt"},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
doc = Document.objects.create(
|
||||||
|
title="Categorised doc",
|
||||||
|
content="test",
|
||||||
|
checksum="SEL1",
|
||||||
|
pk=71,
|
||||||
|
)
|
||||||
|
CustomFieldInstance.objects.create(
|
||||||
|
document=doc,
|
||||||
|
field=field,
|
||||||
|
value_select="opt_abc",
|
||||||
|
)
|
||||||
|
backend.add_or_update(doc)
|
||||||
|
|
||||||
|
# Label should be findable
|
||||||
|
results = backend.search(
|
||||||
|
"custom_fields.value:invoice",
|
||||||
|
user=None,
|
||||||
|
page=1,
|
||||||
|
page_size=10,
|
||||||
|
sort_field=None,
|
||||||
|
sort_reverse=False,
|
||||||
|
)
|
||||||
|
assert results.total == 1
|
||||||
|
|
||||||
|
# Opaque ID must not appear in the index
|
||||||
|
results = backend.search(
|
||||||
|
"custom_fields.value:opt_abc",
|
||||||
|
user=None,
|
||||||
|
page=1,
|
||||||
|
page_size=10,
|
||||||
|
sort_field=None,
|
||||||
|
sort_reverse=False,
|
||||||
|
)
|
||||||
|
assert results.total == 0
|
||||||
|
|
||||||
|
def test_none_custom_field_value_not_indexed(self, backend: TantivyBackend):
|
||||||
|
"""Custom field instances with no value set must not produce an index entry."""
|
||||||
|
field = CustomField.objects.create(
|
||||||
|
name="Optional",
|
||||||
|
data_type=CustomField.FieldDataType.SELECT,
|
||||||
|
extra_data={"select_options": [{"id": "opt_1", "label": "Yes"}]},
|
||||||
|
)
|
||||||
|
doc = Document.objects.create(
|
||||||
|
title="Unset field doc",
|
||||||
|
content="test",
|
||||||
|
checksum="SEL2",
|
||||||
|
pk=72,
|
||||||
|
)
|
||||||
|
CustomFieldInstance.objects.create(
|
||||||
|
document=doc,
|
||||||
|
field=field,
|
||||||
|
value_select=None,
|
||||||
|
)
|
||||||
|
backend.add_or_update(doc)
|
||||||
|
|
||||||
|
# The string "none" must not appear as an indexed value
|
||||||
|
results = backend.search(
|
||||||
|
"custom_fields.value:none",
|
||||||
|
user=None,
|
||||||
|
page=1,
|
||||||
|
page_size=10,
|
||||||
|
sort_field=None,
|
||||||
|
sort_reverse=False,
|
||||||
|
)
|
||||||
|
assert results.total == 0
|
||||||
|
|
||||||
|
def test_notes_include_user_information(self, backend: TantivyBackend):
|
||||||
|
"""Notes must be indexed with user information when available for structured queries."""
|
||||||
|
user = User.objects.create_user("notewriter")
|
||||||
|
doc = Document.objects.create(
|
||||||
|
title="Doc with notes",
|
||||||
|
content="test",
|
||||||
|
checksum="NT1",
|
||||||
|
pk=80,
|
||||||
|
)
|
||||||
|
Note.objects.create(document=doc, note="Important note", user=user)
|
||||||
|
|
||||||
|
# Should not raise an exception during indexing
|
||||||
|
backend.add_or_update(doc)
|
||||||
|
|
||||||
|
# Test basic document search first
|
||||||
|
results = backend.search(
|
||||||
|
"test",
|
||||||
|
user=None,
|
||||||
|
page=1,
|
||||||
|
page_size=10,
|
||||||
|
sort_field=None,
|
||||||
|
sort_reverse=False,
|
||||||
|
)
|
||||||
|
assert results.total == 1, (
|
||||||
|
f"Expected 1, got {results.total}. Document content should be searchable."
|
||||||
|
)
|
||||||
|
|
||||||
|
# Test notes search — must use structured JSON syntax now that note
|
||||||
|
# is no longer in DEFAULT_SEARCH_FIELDS
|
||||||
|
results = backend.search(
|
||||||
|
"notes.note:important",
|
||||||
|
user=None,
|
||||||
|
page=1,
|
||||||
|
page_size=10,
|
||||||
|
sort_field=None,
|
||||||
|
sort_reverse=False,
|
||||||
|
)
|
||||||
|
assert results.total == 1, (
|
||||||
|
f"Expected 1, got {results.total}. Note content should be searchable via notes.note: prefix."
|
||||||
|
)
|
||||||
@@ -0,0 +1,138 @@
|
|||||||
|
import pytest
|
||||||
|
|
||||||
|
from documents.tests.utils import TestMigrations
|
||||||
|
|
||||||
|
pytestmark = pytest.mark.search
|
||||||
|
|
||||||
|
|
||||||
|
class TestMigrateFulltextQueryFieldPrefixes(TestMigrations):
|
||||||
|
migrate_from = "0016_sha256_checksums"
|
||||||
|
migrate_to = "0017_migrate_fulltext_query_field_prefixes"
|
||||||
|
|
||||||
|
def setUpBeforeMigration(self, apps) -> None:
|
||||||
|
User = apps.get_model("auth", "User")
|
||||||
|
SavedView = apps.get_model("documents", "SavedView")
|
||||||
|
SavedViewFilterRule = apps.get_model("documents", "SavedViewFilterRule")
|
||||||
|
|
||||||
|
user = User.objects.create(username="testuser")
|
||||||
|
|
||||||
|
def make_rule(value: str):
|
||||||
|
view = SavedView.objects.create(
|
||||||
|
owner=user,
|
||||||
|
name=f"view-{value}",
|
||||||
|
sort_field="created",
|
||||||
|
)
|
||||||
|
return SavedViewFilterRule.objects.create(
|
||||||
|
saved_view=view,
|
||||||
|
rule_type=20, # fulltext query
|
||||||
|
value=value,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Simple field prefixes
|
||||||
|
self.rule_note = make_rule("note:invoice")
|
||||||
|
self.rule_cf = make_rule("custom_field:amount")
|
||||||
|
|
||||||
|
# Combined query
|
||||||
|
self.rule_combined = make_rule("note:invoice AND custom_field:total")
|
||||||
|
|
||||||
|
# Parenthesized groups (Whoosh syntax)
|
||||||
|
self.rule_parens = make_rule("(note:invoice OR note:receipt)")
|
||||||
|
|
||||||
|
# Prefix operators
|
||||||
|
self.rule_plus = make_rule("+note:foo")
|
||||||
|
self.rule_minus = make_rule("-note:bar")
|
||||||
|
|
||||||
|
# Boosted
|
||||||
|
self.rule_boost = make_rule("note:test^2")
|
||||||
|
|
||||||
|
# Should NOT be rewritten — no field prefix match
|
||||||
|
self.rule_no_match = make_rule("title:hello content:world")
|
||||||
|
|
||||||
|
# Should NOT false-positive on word boundaries
|
||||||
|
self.rule_denote = make_rule("denote:foo")
|
||||||
|
|
||||||
|
# Already using new syntax — should be idempotent
|
||||||
|
self.rule_already_migrated = make_rule("notes.note:foo")
|
||||||
|
self.rule_already_migrated_cf = make_rule("custom_fields.value:bar")
|
||||||
|
|
||||||
|
# Null value — should not crash
|
||||||
|
view_null = SavedView.objects.create(
|
||||||
|
owner=user,
|
||||||
|
name="view-null",
|
||||||
|
sort_field="created",
|
||||||
|
)
|
||||||
|
self.rule_null = SavedViewFilterRule.objects.create(
|
||||||
|
saved_view=view_null,
|
||||||
|
rule_type=20,
|
||||||
|
value=None,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Non-fulltext rule type — should be untouched
|
||||||
|
view_other = SavedView.objects.create(
|
||||||
|
owner=user,
|
||||||
|
name="view-other-type",
|
||||||
|
sort_field="created",
|
||||||
|
)
|
||||||
|
self.rule_other_type = SavedViewFilterRule.objects.create(
|
||||||
|
saved_view=view_other,
|
||||||
|
rule_type=0, # title contains
|
||||||
|
value="note:something",
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_note_prefix_rewritten(self):
|
||||||
|
self.rule_note.refresh_from_db()
|
||||||
|
self.assertEqual(self.rule_note.value, "notes.note:invoice")
|
||||||
|
|
||||||
|
def test_custom_field_prefix_rewritten(self):
|
||||||
|
self.rule_cf.refresh_from_db()
|
||||||
|
self.assertEqual(self.rule_cf.value, "custom_fields.value:amount")
|
||||||
|
|
||||||
|
def test_combined_query_rewritten(self):
|
||||||
|
self.rule_combined.refresh_from_db()
|
||||||
|
self.assertEqual(
|
||||||
|
self.rule_combined.value,
|
||||||
|
"notes.note:invoice AND custom_fields.value:total",
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_parenthesized_groups(self):
|
||||||
|
self.rule_parens.refresh_from_db()
|
||||||
|
self.assertEqual(
|
||||||
|
self.rule_parens.value,
|
||||||
|
"(notes.note:invoice OR notes.note:receipt)",
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_plus_prefix(self):
|
||||||
|
self.rule_plus.refresh_from_db()
|
||||||
|
self.assertEqual(self.rule_plus.value, "+notes.note:foo")
|
||||||
|
|
||||||
|
def test_minus_prefix(self):
|
||||||
|
self.rule_minus.refresh_from_db()
|
||||||
|
self.assertEqual(self.rule_minus.value, "-notes.note:bar")
|
||||||
|
|
||||||
|
def test_boosted(self):
|
||||||
|
self.rule_boost.refresh_from_db()
|
||||||
|
self.assertEqual(self.rule_boost.value, "notes.note:test^2")
|
||||||
|
|
||||||
|
def test_no_match_unchanged(self):
|
||||||
|
self.rule_no_match.refresh_from_db()
|
||||||
|
self.assertEqual(self.rule_no_match.value, "title:hello content:world")
|
||||||
|
|
||||||
|
def test_word_boundary_no_false_positive(self):
|
||||||
|
self.rule_denote.refresh_from_db()
|
||||||
|
self.assertEqual(self.rule_denote.value, "denote:foo")
|
||||||
|
|
||||||
|
def test_already_migrated_idempotent(self):
|
||||||
|
self.rule_already_migrated.refresh_from_db()
|
||||||
|
self.assertEqual(self.rule_already_migrated.value, "notes.note:foo")
|
||||||
|
|
||||||
|
def test_already_migrated_cf_idempotent(self):
|
||||||
|
self.rule_already_migrated_cf.refresh_from_db()
|
||||||
|
self.assertEqual(self.rule_already_migrated_cf.value, "custom_fields.value:bar")
|
||||||
|
|
||||||
|
def test_null_value_no_crash(self):
|
||||||
|
self.rule_null.refresh_from_db()
|
||||||
|
self.assertIsNone(self.rule_null.value)
|
||||||
|
|
||||||
|
def test_non_fulltext_rule_untouched(self):
|
||||||
|
self.rule_other_type.refresh_from_db()
|
||||||
|
self.assertEqual(self.rule_other_type.value, "note:something")
|
||||||
530
src/documents/tests/search/test_query.py
Normal file
530
src/documents/tests/search/test_query.py
Normal file
@@ -0,0 +1,530 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import re
|
||||||
|
from datetime import UTC
|
||||||
|
from datetime import datetime
|
||||||
|
from datetime import tzinfo
|
||||||
|
from typing import TYPE_CHECKING
|
||||||
|
from zoneinfo import ZoneInfo
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
import tantivy
|
||||||
|
import time_machine
|
||||||
|
|
||||||
|
from documents.search._query import _date_only_range
|
||||||
|
from documents.search._query import _datetime_range
|
||||||
|
from documents.search._query import _rewrite_compact_date
|
||||||
|
from documents.search._query import build_permission_filter
|
||||||
|
from documents.search._query import normalize_query
|
||||||
|
from documents.search._query import parse_user_query
|
||||||
|
from documents.search._query import rewrite_natural_date_keywords
|
||||||
|
from documents.search._schema import build_schema
|
||||||
|
from documents.search._tokenizer import register_tokenizers
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from django.contrib.auth.base_user import AbstractBaseUser
|
||||||
|
|
||||||
|
pytestmark = pytest.mark.search
|
||||||
|
|
||||||
|
EASTERN = ZoneInfo("America/New_York") # UTC-5 / UTC-4 (DST)
|
||||||
|
AUCKLAND = ZoneInfo("Pacific/Auckland") # UTC+13 in southern-hemisphere summer
|
||||||
|
|
||||||
|
|
||||||
|
def _range(result: str, field: str) -> tuple[str, str]:
|
||||||
|
m = re.search(rf"{field}:\[(.+?) TO (.+?)\]", result)
|
||||||
|
assert m, f"No range for {field!r} in: {result!r}"
|
||||||
|
return m.group(1), m.group(2)
|
||||||
|
|
||||||
|
|
||||||
|
class TestCreatedDateField:
|
||||||
|
"""
|
||||||
|
created is a Django DateField: indexed as midnight UTC of the local calendar
|
||||||
|
date. No offset arithmetic needed - the local calendar date is what matters.
|
||||||
|
"""
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
("tz", "expected_lo", "expected_hi"),
|
||||||
|
[
|
||||||
|
pytest.param(UTC, "2026-03-28T00:00:00Z", "2026-03-29T00:00:00Z", id="utc"),
|
||||||
|
pytest.param(
|
||||||
|
EASTERN,
|
||||||
|
"2026-03-28T00:00:00Z",
|
||||||
|
"2026-03-29T00:00:00Z",
|
||||||
|
id="eastern_same_calendar_date",
|
||||||
|
),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
@time_machine.travel(datetime(2026, 3, 28, 15, 30, tzinfo=UTC), tick=False)
|
||||||
|
def test_today(self, tz: tzinfo, expected_lo: str, expected_hi: str) -> None:
|
||||||
|
lo, hi = _range(rewrite_natural_date_keywords("created:today", tz), "created")
|
||||||
|
assert lo == expected_lo
|
||||||
|
assert hi == expected_hi
|
||||||
|
|
||||||
|
@time_machine.travel(datetime(2026, 3, 28, 3, 0, tzinfo=UTC), tick=False)
|
||||||
|
def test_today_auckland_ahead_of_utc(self) -> None:
|
||||||
|
# UTC 03:00 -> Auckland (UTC+13) = 16:00 same date; local date = 2026-03-28
|
||||||
|
lo, _ = _range(
|
||||||
|
rewrite_natural_date_keywords("created:today", AUCKLAND),
|
||||||
|
"created",
|
||||||
|
)
|
||||||
|
assert lo == "2026-03-28T00:00:00Z"
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
("field", "keyword", "expected_lo", "expected_hi"),
|
||||||
|
[
|
||||||
|
pytest.param(
|
||||||
|
"created",
|
||||||
|
"yesterday",
|
||||||
|
"2026-03-27T00:00:00Z",
|
||||||
|
"2026-03-28T00:00:00Z",
|
||||||
|
id="yesterday",
|
||||||
|
),
|
||||||
|
pytest.param(
|
||||||
|
"created",
|
||||||
|
"this_week",
|
||||||
|
"2026-03-23T00:00:00Z",
|
||||||
|
"2026-03-30T00:00:00Z",
|
||||||
|
id="this_week_mon_sun",
|
||||||
|
),
|
||||||
|
pytest.param(
|
||||||
|
"created",
|
||||||
|
"last_week",
|
||||||
|
"2026-03-16T00:00:00Z",
|
||||||
|
"2026-03-23T00:00:00Z",
|
||||||
|
id="last_week",
|
||||||
|
),
|
||||||
|
pytest.param(
|
||||||
|
"created",
|
||||||
|
"this_month",
|
||||||
|
"2026-03-01T00:00:00Z",
|
||||||
|
"2026-04-01T00:00:00Z",
|
||||||
|
id="this_month",
|
||||||
|
),
|
||||||
|
pytest.param(
|
||||||
|
"created",
|
||||||
|
"last_month",
|
||||||
|
"2026-02-01T00:00:00Z",
|
||||||
|
"2026-03-01T00:00:00Z",
|
||||||
|
id="last_month",
|
||||||
|
),
|
||||||
|
pytest.param(
|
||||||
|
"created",
|
||||||
|
"this_year",
|
||||||
|
"2026-01-01T00:00:00Z",
|
||||||
|
"2027-01-01T00:00:00Z",
|
||||||
|
id="this_year",
|
||||||
|
),
|
||||||
|
pytest.param(
|
||||||
|
"created",
|
||||||
|
"last_year",
|
||||||
|
"2025-01-01T00:00:00Z",
|
||||||
|
"2026-01-01T00:00:00Z",
|
||||||
|
id="last_year",
|
||||||
|
),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
@time_machine.travel(datetime(2026, 3, 28, 15, 0, tzinfo=UTC), tick=False)
|
||||||
|
def test_date_keywords(
|
||||||
|
self,
|
||||||
|
field: str,
|
||||||
|
keyword: str,
|
||||||
|
expected_lo: str,
|
||||||
|
expected_hi: str,
|
||||||
|
) -> None:
|
||||||
|
# 2026-03-28 is Saturday; Mon-Sun week calculation built into expectations
|
||||||
|
query = f"{field}:{keyword}"
|
||||||
|
lo, hi = _range(rewrite_natural_date_keywords(query, UTC), field)
|
||||||
|
assert lo == expected_lo
|
||||||
|
assert hi == expected_hi
|
||||||
|
|
||||||
|
@time_machine.travel(datetime(2026, 12, 15, 12, 0, tzinfo=UTC), tick=False)
|
||||||
|
def test_this_month_december_wraps_to_next_year(self) -> None:
|
||||||
|
# December: next month must roll over to January 1 of next year
|
||||||
|
lo, hi = _range(
|
||||||
|
rewrite_natural_date_keywords("created:this_month", UTC),
|
||||||
|
"created",
|
||||||
|
)
|
||||||
|
assert lo == "2026-12-01T00:00:00Z"
|
||||||
|
assert hi == "2027-01-01T00:00:00Z"
|
||||||
|
|
||||||
|
@time_machine.travel(datetime(2026, 1, 15, 12, 0, tzinfo=UTC), tick=False)
|
||||||
|
def test_last_month_january_wraps_to_previous_year(self) -> None:
|
||||||
|
# January: last month must roll back to December 1 of previous year
|
||||||
|
lo, hi = _range(
|
||||||
|
rewrite_natural_date_keywords("created:last_month", UTC),
|
||||||
|
"created",
|
||||||
|
)
|
||||||
|
assert lo == "2025-12-01T00:00:00Z"
|
||||||
|
assert hi == "2026-01-01T00:00:00Z"
|
||||||
|
|
||||||
|
def test_unknown_keyword_raises(self) -> None:
|
||||||
|
with pytest.raises(ValueError, match="Unknown keyword"):
|
||||||
|
_date_only_range("bogus_keyword", UTC)
|
||||||
|
|
||||||
|
|
||||||
|
class TestDateTimeFields:
|
||||||
|
"""
|
||||||
|
added/modified store full UTC datetimes. Natural keywords must convert
|
||||||
|
the local day boundaries to UTC - timezone offset arithmetic IS required.
|
||||||
|
"""
|
||||||
|
|
||||||
|
@time_machine.travel(datetime(2026, 3, 28, 15, 30, tzinfo=UTC), tick=False)
|
||||||
|
def test_added_today_eastern(self) -> None:
|
||||||
|
# EDT = UTC-4; local midnight 2026-03-28 00:00 EDT = 2026-03-28 04:00 UTC
|
||||||
|
lo, hi = _range(rewrite_natural_date_keywords("added:today", EASTERN), "added")
|
||||||
|
assert lo == "2026-03-28T04:00:00Z"
|
||||||
|
assert hi == "2026-03-29T04:00:00Z"
|
||||||
|
|
||||||
|
@time_machine.travel(datetime(2026, 3, 29, 2, 0, tzinfo=UTC), tick=False)
|
||||||
|
def test_added_today_auckland_midnight_crossing(self) -> None:
|
||||||
|
# UTC 02:00 on 2026-03-29 -> Auckland (UTC+13) = 2026-03-29 15:00 local
|
||||||
|
# Auckland midnight = UTC 2026-03-28 11:00
|
||||||
|
lo, hi = _range(rewrite_natural_date_keywords("added:today", AUCKLAND), "added")
|
||||||
|
assert lo == "2026-03-28T11:00:00Z"
|
||||||
|
assert hi == "2026-03-29T11:00:00Z"
|
||||||
|
|
||||||
|
@time_machine.travel(datetime(2026, 3, 28, 15, 0, tzinfo=UTC), tick=False)
|
||||||
|
def test_modified_today_utc(self) -> None:
|
||||||
|
lo, hi = _range(
|
||||||
|
rewrite_natural_date_keywords("modified:today", UTC),
|
||||||
|
"modified",
|
||||||
|
)
|
||||||
|
assert lo == "2026-03-28T00:00:00Z"
|
||||||
|
assert hi == "2026-03-29T00:00:00Z"
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
("keyword", "expected_lo", "expected_hi"),
|
||||||
|
[
|
||||||
|
pytest.param(
|
||||||
|
"yesterday",
|
||||||
|
"2026-03-27T00:00:00Z",
|
||||||
|
"2026-03-28T00:00:00Z",
|
||||||
|
id="yesterday",
|
||||||
|
),
|
||||||
|
pytest.param(
|
||||||
|
"this_week",
|
||||||
|
"2026-03-23T00:00:00Z",
|
||||||
|
"2026-03-30T00:00:00Z",
|
||||||
|
id="this_week",
|
||||||
|
),
|
||||||
|
pytest.param(
|
||||||
|
"last_week",
|
||||||
|
"2026-03-16T00:00:00Z",
|
||||||
|
"2026-03-23T00:00:00Z",
|
||||||
|
id="last_week",
|
||||||
|
),
|
||||||
|
pytest.param(
|
||||||
|
"this_month",
|
||||||
|
"2026-03-01T00:00:00Z",
|
||||||
|
"2026-04-01T00:00:00Z",
|
||||||
|
id="this_month",
|
||||||
|
),
|
||||||
|
pytest.param(
|
||||||
|
"last_month",
|
||||||
|
"2026-02-01T00:00:00Z",
|
||||||
|
"2026-03-01T00:00:00Z",
|
||||||
|
id="last_month",
|
||||||
|
),
|
||||||
|
pytest.param(
|
||||||
|
"this_year",
|
||||||
|
"2026-01-01T00:00:00Z",
|
||||||
|
"2027-01-01T00:00:00Z",
|
||||||
|
id="this_year",
|
||||||
|
),
|
||||||
|
pytest.param(
|
||||||
|
"last_year",
|
||||||
|
"2025-01-01T00:00:00Z",
|
||||||
|
"2026-01-01T00:00:00Z",
|
||||||
|
id="last_year",
|
||||||
|
),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
@time_machine.travel(datetime(2026, 3, 28, 12, 0, tzinfo=UTC), tick=False)
|
||||||
|
def test_datetime_keywords_utc(
|
||||||
|
self,
|
||||||
|
keyword: str,
|
||||||
|
expected_lo: str,
|
||||||
|
expected_hi: str,
|
||||||
|
) -> None:
|
||||||
|
# 2026-03-28 is Saturday; weekday()==5 so Monday=2026-03-23
|
||||||
|
lo, hi = _range(rewrite_natural_date_keywords(f"added:{keyword}", UTC), "added")
|
||||||
|
assert lo == expected_lo
|
||||||
|
assert hi == expected_hi
|
||||||
|
|
||||||
|
@time_machine.travel(datetime(2026, 12, 15, 12, 0, tzinfo=UTC), tick=False)
|
||||||
|
def test_this_month_december_wraps_to_next_year(self) -> None:
|
||||||
|
# December: next month wraps to January of next year
|
||||||
|
lo, hi = _range(rewrite_natural_date_keywords("added:this_month", UTC), "added")
|
||||||
|
assert lo == "2026-12-01T00:00:00Z"
|
||||||
|
assert hi == "2027-01-01T00:00:00Z"
|
||||||
|
|
||||||
|
@time_machine.travel(datetime(2026, 1, 15, 12, 0, tzinfo=UTC), tick=False)
|
||||||
|
def test_last_month_january_wraps_to_previous_year(self) -> None:
|
||||||
|
# January: last month wraps back to December of previous year
|
||||||
|
lo, hi = _range(rewrite_natural_date_keywords("added:last_month", UTC), "added")
|
||||||
|
assert lo == "2025-12-01T00:00:00Z"
|
||||||
|
assert hi == "2026-01-01T00:00:00Z"
|
||||||
|
|
||||||
|
def test_unknown_keyword_raises(self) -> None:
|
||||||
|
with pytest.raises(ValueError, match="Unknown keyword"):
|
||||||
|
_datetime_range("bogus_keyword", UTC)
|
||||||
|
|
||||||
|
|
||||||
|
class TestWhooshQueryRewriting:
|
||||||
|
"""All Whoosh query syntax variants must be rewritten to ISO 8601 before Tantivy parses them."""
|
||||||
|
|
||||||
|
@time_machine.travel(datetime(2026, 3, 28, 15, 0, tzinfo=UTC), tick=False)
|
||||||
|
def test_compact_date_shim_rewrites_to_iso(self) -> None:
|
||||||
|
result = rewrite_natural_date_keywords("created:20240115120000", UTC)
|
||||||
|
assert "2024-01-15" in result
|
||||||
|
assert "20240115120000" not in result
|
||||||
|
|
||||||
|
@time_machine.travel(datetime(2026, 3, 28, 15, 0, tzinfo=UTC), tick=False)
|
||||||
|
def test_relative_range_shim_removes_now(self) -> None:
|
||||||
|
result = rewrite_natural_date_keywords("added:[now-7d TO now]", UTC)
|
||||||
|
assert "now" not in result
|
||||||
|
assert "2026-03-" in result
|
||||||
|
|
||||||
|
@time_machine.travel(datetime(2026, 3, 28, 12, 0, tzinfo=UTC), tick=False)
|
||||||
|
def test_bracket_minus_7_days(self) -> None:
|
||||||
|
lo, hi = _range(
|
||||||
|
rewrite_natural_date_keywords("added:[-7 days to now]", UTC),
|
||||||
|
"added",
|
||||||
|
)
|
||||||
|
assert lo == "2026-03-21T12:00:00Z"
|
||||||
|
assert hi == "2026-03-28T12:00:00Z"
|
||||||
|
|
||||||
|
@time_machine.travel(datetime(2026, 3, 28, 12, 0, tzinfo=UTC), tick=False)
|
||||||
|
def test_bracket_minus_1_week(self) -> None:
|
||||||
|
lo, hi = _range(
|
||||||
|
rewrite_natural_date_keywords("added:[-1 week to now]", UTC),
|
||||||
|
"added",
|
||||||
|
)
|
||||||
|
assert lo == "2026-03-21T12:00:00Z"
|
||||||
|
assert hi == "2026-03-28T12:00:00Z"
|
||||||
|
|
||||||
|
@time_machine.travel(datetime(2026, 3, 28, 12, 0, tzinfo=UTC), tick=False)
|
||||||
|
def test_bracket_minus_1_month_uses_relativedelta(self) -> None:
|
||||||
|
# relativedelta(months=1) from 2026-03-28 = 2026-02-28 (not 29)
|
||||||
|
lo, hi = _range(
|
||||||
|
rewrite_natural_date_keywords("created:[-1 month to now]", UTC),
|
||||||
|
"created",
|
||||||
|
)
|
||||||
|
assert lo == "2026-02-28T12:00:00Z"
|
||||||
|
assert hi == "2026-03-28T12:00:00Z"
|
||||||
|
|
||||||
|
@time_machine.travel(datetime(2026, 3, 28, 12, 0, tzinfo=UTC), tick=False)
|
||||||
|
def test_bracket_minus_1_year(self) -> None:
|
||||||
|
lo, hi = _range(
|
||||||
|
rewrite_natural_date_keywords("modified:[-1 year to now]", UTC),
|
||||||
|
"modified",
|
||||||
|
)
|
||||||
|
assert lo == "2025-03-28T12:00:00Z"
|
||||||
|
assert hi == "2026-03-28T12:00:00Z"
|
||||||
|
|
||||||
|
@time_machine.travel(datetime(2026, 3, 28, 12, 0, tzinfo=UTC), tick=False)
|
||||||
|
def test_bracket_plural_unit_hours(self) -> None:
|
||||||
|
lo, hi = _range(
|
||||||
|
rewrite_natural_date_keywords("added:[-3 hours to now]", UTC),
|
||||||
|
"added",
|
||||||
|
)
|
||||||
|
assert lo == "2026-03-28T09:00:00Z"
|
||||||
|
assert hi == "2026-03-28T12:00:00Z"
|
||||||
|
|
||||||
|
@time_machine.travel(datetime(2026, 3, 28, 12, 0, tzinfo=UTC), tick=False)
|
||||||
|
def test_bracket_case_insensitive(self) -> None:
|
||||||
|
result = rewrite_natural_date_keywords("added:[-1 WEEK TO NOW]", UTC)
|
||||||
|
assert "now" not in result.lower()
|
||||||
|
lo, hi = _range(result, "added")
|
||||||
|
assert lo == "2026-03-21T12:00:00Z"
|
||||||
|
assert hi == "2026-03-28T12:00:00Z"
|
||||||
|
|
||||||
|
@time_machine.travel(datetime(2026, 3, 28, 12, 0, tzinfo=UTC), tick=False)
|
||||||
|
def test_relative_range_swaps_bounds_when_lo_exceeds_hi(self) -> None:
|
||||||
|
# [now+1h TO now-1h] has lo > hi before substitution; they must be swapped
|
||||||
|
lo, hi = _range(
|
||||||
|
rewrite_natural_date_keywords("added:[now+1h TO now-1h]", UTC),
|
||||||
|
"added",
|
||||||
|
)
|
||||||
|
assert lo == "2026-03-28T11:00:00Z"
|
||||||
|
assert hi == "2026-03-28T13:00:00Z"
|
||||||
|
|
||||||
|
def test_8digit_created_date_field_always_uses_utc_midnight(self) -> None:
|
||||||
|
# created is a DateField: boundaries are always UTC midnight, no TZ offset
|
||||||
|
result = rewrite_natural_date_keywords("created:20231201", EASTERN)
|
||||||
|
lo, hi = _range(result, "created")
|
||||||
|
assert lo == "2023-12-01T00:00:00Z"
|
||||||
|
assert hi == "2023-12-02T00:00:00Z"
|
||||||
|
|
||||||
|
def test_8digit_added_datetime_field_converts_local_midnight_to_utc(self) -> None:
|
||||||
|
# added is DateTimeField: midnight Dec 1 Eastern (EST = UTC-5) = 05:00 UTC
|
||||||
|
result = rewrite_natural_date_keywords("added:20231201", EASTERN)
|
||||||
|
lo, hi = _range(result, "added")
|
||||||
|
assert lo == "2023-12-01T05:00:00Z"
|
||||||
|
assert hi == "2023-12-02T05:00:00Z"
|
||||||
|
|
||||||
|
def test_8digit_modified_datetime_field_converts_local_midnight_to_utc(
|
||||||
|
self,
|
||||||
|
) -> None:
|
||||||
|
result = rewrite_natural_date_keywords("modified:20231201", EASTERN)
|
||||||
|
lo, hi = _range(result, "modified")
|
||||||
|
assert lo == "2023-12-01T05:00:00Z"
|
||||||
|
assert hi == "2023-12-02T05:00:00Z"
|
||||||
|
|
||||||
|
def test_8digit_invalid_date_passes_through_unchanged(self) -> None:
|
||||||
|
assert rewrite_natural_date_keywords("added:20231340", UTC) == "added:20231340"
|
||||||
|
|
||||||
|
def test_compact_14digit_invalid_date_passes_through_unchanged(self) -> None:
|
||||||
|
# Month=13 makes datetime() raise ValueError; the token must be left as-is
|
||||||
|
assert _rewrite_compact_date("20231300120000") == "20231300120000"
|
||||||
|
|
||||||
|
|
||||||
|
class TestParseUserQuery:
|
||||||
|
"""parse_user_query runs the full preprocessing pipeline."""
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def query_index(self) -> tantivy.Index:
|
||||||
|
schema = build_schema()
|
||||||
|
idx = tantivy.Index(schema, path=None)
|
||||||
|
register_tokenizers(idx, "")
|
||||||
|
return idx
|
||||||
|
|
||||||
|
def test_returns_tantivy_query(self, query_index: tantivy.Index) -> None:
|
||||||
|
assert isinstance(parse_user_query(query_index, "invoice", UTC), tantivy.Query)
|
||||||
|
|
||||||
|
def test_fuzzy_mode_does_not_raise(
|
||||||
|
self,
|
||||||
|
query_index: tantivy.Index,
|
||||||
|
settings,
|
||||||
|
) -> None:
|
||||||
|
settings.ADVANCED_FUZZY_SEARCH_THRESHOLD = 0.5
|
||||||
|
assert isinstance(parse_user_query(query_index, "invoice", UTC), tantivy.Query)
|
||||||
|
|
||||||
|
def test_date_rewriting_applied_before_tantivy_parse(
|
||||||
|
self,
|
||||||
|
query_index: tantivy.Index,
|
||||||
|
) -> None:
|
||||||
|
# created:today must be rewritten to an ISO range before Tantivy parses it;
|
||||||
|
# if passed raw, Tantivy would reject "today" as an invalid date value
|
||||||
|
with time_machine.travel(datetime(2026, 3, 28, 12, 0, tzinfo=UTC), tick=False):
|
||||||
|
q = parse_user_query(query_index, "created:today", UTC)
|
||||||
|
assert isinstance(q, tantivy.Query)
|
||||||
|
|
||||||
|
|
||||||
|
class TestPassthrough:
|
||||||
|
"""Queries without field prefixes or unrelated content pass through unchanged."""
|
||||||
|
|
||||||
|
def test_bare_keyword_no_field_prefix_unchanged(self) -> None:
|
||||||
|
# Bare 'today' with no field: prefix passes through unchanged
|
||||||
|
result = rewrite_natural_date_keywords("bank statement today", UTC)
|
||||||
|
assert "today" in result
|
||||||
|
|
||||||
|
def test_unrelated_query_unchanged(self) -> None:
|
||||||
|
assert rewrite_natural_date_keywords("title:invoice", UTC) == "title:invoice"
|
||||||
|
|
||||||
|
|
||||||
|
class TestNormalizeQuery:
|
||||||
|
"""normalize_query expands comma-separated values and collapses whitespace."""
|
||||||
|
|
||||||
|
def test_normalize_expands_comma_separated_tags(self) -> None:
|
||||||
|
assert normalize_query("tag:foo,bar") == "tag:foo AND tag:bar"
|
||||||
|
|
||||||
|
def test_normalize_expands_three_values(self) -> None:
|
||||||
|
assert normalize_query("tag:foo,bar,baz") == "tag:foo AND tag:bar AND tag:baz"
|
||||||
|
|
||||||
|
def test_normalize_collapses_whitespace(self) -> None:
|
||||||
|
assert normalize_query("bank statement") == "bank statement"
|
||||||
|
|
||||||
|
def test_normalize_no_commas_unchanged(self) -> None:
|
||||||
|
assert normalize_query("bank statement") == "bank statement"
|
||||||
|
|
||||||
|
|
||||||
|
class TestPermissionFilter:
|
||||||
|
"""
|
||||||
|
build_permission_filter tests use an in-memory index — no DB access needed.
|
||||||
|
|
||||||
|
Users are constructed as unsaved model instances (django_user_model(pk=N))
|
||||||
|
so no database round-trip occurs; only .pk is read by build_permission_filter.
|
||||||
|
"""
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def perm_index(self) -> tantivy.Index:
|
||||||
|
schema = build_schema()
|
||||||
|
idx = tantivy.Index(schema, path=None)
|
||||||
|
register_tokenizers(idx, "")
|
||||||
|
return idx
|
||||||
|
|
||||||
|
def _add_doc(
|
||||||
|
self,
|
||||||
|
idx: tantivy.Index,
|
||||||
|
doc_id: int,
|
||||||
|
owner_id: int | None = None,
|
||||||
|
viewer_ids: tuple[int, ...] = (),
|
||||||
|
) -> None:
|
||||||
|
writer = idx.writer()
|
||||||
|
doc = tantivy.Document()
|
||||||
|
doc.add_unsigned("id", doc_id)
|
||||||
|
# Only add owner_id field if the document has an owner
|
||||||
|
if owner_id is not None:
|
||||||
|
doc.add_unsigned("owner_id", owner_id)
|
||||||
|
for vid in viewer_ids:
|
||||||
|
doc.add_unsigned("viewer_id", vid)
|
||||||
|
writer.add_document(doc)
|
||||||
|
writer.commit()
|
||||||
|
idx.reload()
|
||||||
|
|
||||||
|
def test_perm_no_owner_visible_to_any_user(
|
||||||
|
self,
|
||||||
|
perm_index: tantivy.Index,
|
||||||
|
django_user_model: type[AbstractBaseUser],
|
||||||
|
) -> None:
|
||||||
|
"""Documents with no owner must be visible to every user."""
|
||||||
|
self._add_doc(perm_index, doc_id=1, owner_id=None)
|
||||||
|
user = django_user_model(pk=99)
|
||||||
|
perm = build_permission_filter(perm_index.schema, user)
|
||||||
|
assert perm_index.searcher().search(perm, limit=10).count == 1
|
||||||
|
|
||||||
|
def test_perm_owned_by_user_is_visible(
|
||||||
|
self,
|
||||||
|
perm_index: tantivy.Index,
|
||||||
|
django_user_model: type[AbstractBaseUser],
|
||||||
|
) -> None:
|
||||||
|
"""A document owned by the requesting user must be visible."""
|
||||||
|
self._add_doc(perm_index, doc_id=2, owner_id=42)
|
||||||
|
user = django_user_model(pk=42)
|
||||||
|
perm = build_permission_filter(perm_index.schema, user)
|
||||||
|
assert perm_index.searcher().search(perm, limit=10).count == 1
|
||||||
|
|
||||||
|
def test_perm_owned_by_other_not_visible(
|
||||||
|
self,
|
||||||
|
perm_index: tantivy.Index,
|
||||||
|
django_user_model: type[AbstractBaseUser],
|
||||||
|
) -> None:
|
||||||
|
"""A document owned by a different user must not be visible."""
|
||||||
|
self._add_doc(perm_index, doc_id=3, owner_id=42)
|
||||||
|
user = django_user_model(pk=99)
|
||||||
|
perm = build_permission_filter(perm_index.schema, user)
|
||||||
|
assert perm_index.searcher().search(perm, limit=10).count == 0
|
||||||
|
|
||||||
|
def test_perm_shared_viewer_is_visible(
|
||||||
|
self,
|
||||||
|
perm_index: tantivy.Index,
|
||||||
|
django_user_model: type[AbstractBaseUser],
|
||||||
|
) -> None:
|
||||||
|
"""A document explicitly shared with a user must be visible to that user."""
|
||||||
|
self._add_doc(perm_index, doc_id=4, owner_id=42, viewer_ids=(99,))
|
||||||
|
user = django_user_model(pk=99)
|
||||||
|
perm = build_permission_filter(perm_index.schema, user)
|
||||||
|
assert perm_index.searcher().search(perm, limit=10).count == 1
|
||||||
|
|
||||||
|
def test_perm_only_owned_docs_hidden_from_others(
|
||||||
|
self,
|
||||||
|
perm_index: tantivy.Index,
|
||||||
|
django_user_model: type[AbstractBaseUser],
|
||||||
|
) -> None:
|
||||||
|
"""Only unowned documents appear when the user owns none of them."""
|
||||||
|
self._add_doc(perm_index, doc_id=5, owner_id=10) # owned by 10
|
||||||
|
self._add_doc(perm_index, doc_id=6, owner_id=None) # unowned
|
||||||
|
user = django_user_model(pk=20)
|
||||||
|
perm = build_permission_filter(perm_index.schema, user)
|
||||||
|
assert perm_index.searcher().search(perm, limit=10).count == 1 # only unowned
|
||||||
63
src/documents/tests/search/test_schema.py
Normal file
63
src/documents/tests/search/test_schema.py
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from documents.search._schema import SCHEMA_VERSION
|
||||||
|
from documents.search._schema import needs_rebuild
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from pytest_django.fixtures import SettingsWrapper
|
||||||
|
|
||||||
|
pytestmark = pytest.mark.search
|
||||||
|
|
||||||
|
|
||||||
|
class TestNeedsRebuild:
|
||||||
|
"""needs_rebuild covers all sentinel-file states that require a full reindex."""
|
||||||
|
|
||||||
|
def test_returns_true_when_version_file_missing(self, index_dir: Path) -> None:
|
||||||
|
assert needs_rebuild(index_dir) is True
|
||||||
|
|
||||||
|
def test_returns_false_when_version_and_language_match(
|
||||||
|
self,
|
||||||
|
index_dir: Path,
|
||||||
|
settings: SettingsWrapper,
|
||||||
|
) -> None:
|
||||||
|
settings.SEARCH_LANGUAGE = "en"
|
||||||
|
(index_dir / ".schema_version").write_text(str(SCHEMA_VERSION))
|
||||||
|
(index_dir / ".schema_language").write_text("en")
|
||||||
|
assert needs_rebuild(index_dir) is False
|
||||||
|
|
||||||
|
def test_returns_true_on_schema_version_mismatch(self, index_dir: Path) -> None:
|
||||||
|
(index_dir / ".schema_version").write_text(str(SCHEMA_VERSION - 1))
|
||||||
|
assert needs_rebuild(index_dir) is True
|
||||||
|
|
||||||
|
def test_returns_true_when_version_file_not_an_integer(
|
||||||
|
self,
|
||||||
|
index_dir: Path,
|
||||||
|
) -> None:
|
||||||
|
(index_dir / ".schema_version").write_text("not-a-number")
|
||||||
|
assert needs_rebuild(index_dir) is True
|
||||||
|
|
||||||
|
def test_returns_true_when_language_sentinel_missing(
|
||||||
|
self,
|
||||||
|
index_dir: Path,
|
||||||
|
settings: SettingsWrapper,
|
||||||
|
) -> None:
|
||||||
|
settings.SEARCH_LANGUAGE = "en"
|
||||||
|
(index_dir / ".schema_version").write_text(str(SCHEMA_VERSION))
|
||||||
|
# .schema_language intentionally absent
|
||||||
|
assert needs_rebuild(index_dir) is True
|
||||||
|
|
||||||
|
def test_returns_true_when_language_sentinel_content_differs(
|
||||||
|
self,
|
||||||
|
index_dir: Path,
|
||||||
|
settings: SettingsWrapper,
|
||||||
|
) -> None:
|
||||||
|
settings.SEARCH_LANGUAGE = "de"
|
||||||
|
(index_dir / ".schema_version").write_text(str(SCHEMA_VERSION))
|
||||||
|
(index_dir / ".schema_language").write_text("en")
|
||||||
|
assert needs_rebuild(index_dir) is True
|
||||||
78
src/documents/tests/search/test_tokenizer.py
Normal file
78
src/documents/tests/search/test_tokenizer.py
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
import tantivy
|
||||||
|
|
||||||
|
from documents.search._tokenizer import _bigram_analyzer
|
||||||
|
from documents.search._tokenizer import _paperless_text
|
||||||
|
from documents.search._tokenizer import register_tokenizers
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from _pytest.logging import LogCaptureFixture
|
||||||
|
|
||||||
|
pytestmark = pytest.mark.search
|
||||||
|
|
||||||
|
|
||||||
|
class TestTokenizers:
|
||||||
|
@pytest.fixture
|
||||||
|
def content_index(self) -> tantivy.Index:
|
||||||
|
"""Index with just a content field for ASCII folding tests."""
|
||||||
|
sb = tantivy.SchemaBuilder()
|
||||||
|
sb.add_text_field("content", stored=True, tokenizer_name="paperless_text")
|
||||||
|
schema = sb.build()
|
||||||
|
idx = tantivy.Index(schema, path=None)
|
||||||
|
idx.register_tokenizer("paperless_text", _paperless_text(""))
|
||||||
|
return idx
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def bigram_index(self) -> tantivy.Index:
|
||||||
|
"""Index with bigram field for CJK tests."""
|
||||||
|
sb = tantivy.SchemaBuilder()
|
||||||
|
sb.add_text_field(
|
||||||
|
"bigram_content",
|
||||||
|
stored=False,
|
||||||
|
tokenizer_name="bigram_analyzer",
|
||||||
|
)
|
||||||
|
schema = sb.build()
|
||||||
|
idx = tantivy.Index(schema, path=None)
|
||||||
|
idx.register_tokenizer("bigram_analyzer", _bigram_analyzer())
|
||||||
|
return idx
|
||||||
|
|
||||||
|
def test_ascii_fold_finds_accented_content(
|
||||||
|
self,
|
||||||
|
content_index: tantivy.Index,
|
||||||
|
) -> None:
|
||||||
|
"""ASCII folding allows searching accented text with plain ASCII queries."""
|
||||||
|
writer = content_index.writer()
|
||||||
|
doc = tantivy.Document()
|
||||||
|
doc.add_text("content", "café résumé")
|
||||||
|
writer.add_document(doc)
|
||||||
|
writer.commit()
|
||||||
|
content_index.reload()
|
||||||
|
q = content_index.parse_query("cafe resume", ["content"])
|
||||||
|
assert content_index.searcher().search(q, limit=5).count == 1
|
||||||
|
|
||||||
|
def test_bigram_finds_cjk_substring(self, bigram_index: tantivy.Index) -> None:
|
||||||
|
"""Bigram tokenizer enables substring search in CJK languages without whitespace delimiters."""
|
||||||
|
writer = bigram_index.writer()
|
||||||
|
doc = tantivy.Document()
|
||||||
|
doc.add_text("bigram_content", "東京都")
|
||||||
|
writer.add_document(doc)
|
||||||
|
writer.commit()
|
||||||
|
bigram_index.reload()
|
||||||
|
q = bigram_index.parse_query("東京", ["bigram_content"])
|
||||||
|
assert bigram_index.searcher().search(q, limit=5).count == 1
|
||||||
|
|
||||||
|
def test_unsupported_language_logs_warning(self, caplog: LogCaptureFixture) -> None:
|
||||||
|
"""Unsupported language codes should log a warning and disable stemming gracefully."""
|
||||||
|
sb = tantivy.SchemaBuilder()
|
||||||
|
sb.add_text_field("content", stored=True, tokenizer_name="paperless_text")
|
||||||
|
schema = sb.build()
|
||||||
|
idx = tantivy.Index(schema, path=None)
|
||||||
|
|
||||||
|
with caplog.at_level(logging.WARNING, logger="paperless.search"):
|
||||||
|
register_tokenizers(idx, "klingon")
|
||||||
|
assert "klingon" in caplog.text
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import types
|
import types
|
||||||
from unittest.mock import patch
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
import tantivy
|
||||||
from django.contrib.admin.sites import AdminSite
|
from django.contrib.admin.sites import AdminSite
|
||||||
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
|
||||||
@@ -8,36 +9,54 @@ from django.test import TestCase
|
|||||||
from django.utils import timezone
|
from django.utils import timezone
|
||||||
from rest_framework import status
|
from rest_framework import status
|
||||||
|
|
||||||
from documents import index
|
|
||||||
from documents.admin import DocumentAdmin
|
from documents.admin import DocumentAdmin
|
||||||
from documents.admin import TagAdmin
|
from documents.admin import TagAdmin
|
||||||
from documents.models import Document
|
from documents.models import Document
|
||||||
from documents.models import Tag
|
from documents.models import Tag
|
||||||
|
from documents.search import get_backend
|
||||||
|
from documents.search import reset_backend
|
||||||
from documents.tests.utils import DirectoriesMixin
|
from documents.tests.utils import DirectoriesMixin
|
||||||
from paperless.admin import PaperlessUserAdmin
|
from paperless.admin import PaperlessUserAdmin
|
||||||
|
|
||||||
|
|
||||||
class TestDocumentAdmin(DirectoriesMixin, TestCase):
|
class TestDocumentAdmin(DirectoriesMixin, TestCase):
|
||||||
def get_document_from_index(self, doc):
|
def get_document_from_index(self, doc):
|
||||||
ix = index.open_index()
|
backend = get_backend()
|
||||||
with ix.searcher() as searcher:
|
searcher = backend._index.searcher()
|
||||||
return searcher.document(id=doc.id)
|
results = searcher.search(
|
||||||
|
tantivy.Query.range_query(
|
||||||
|
backend._schema,
|
||||||
|
"id",
|
||||||
|
tantivy.FieldType.Unsigned,
|
||||||
|
doc.pk,
|
||||||
|
doc.pk,
|
||||||
|
),
|
||||||
|
limit=1,
|
||||||
|
)
|
||||||
|
if results.hits:
|
||||||
|
return searcher.doc(results.hits[0][1]).to_dict()
|
||||||
|
return None
|
||||||
|
|
||||||
def setUp(self) -> None:
|
def setUp(self) -> None:
|
||||||
super().setUp()
|
super().setUp()
|
||||||
|
reset_backend()
|
||||||
self.doc_admin = DocumentAdmin(model=Document, admin_site=AdminSite())
|
self.doc_admin = DocumentAdmin(model=Document, admin_site=AdminSite())
|
||||||
|
|
||||||
|
def tearDown(self) -> None:
|
||||||
|
reset_backend()
|
||||||
|
super().tearDown()
|
||||||
|
|
||||||
def test_save_model(self) -> None:
|
def test_save_model(self) -> None:
|
||||||
doc = Document.objects.create(title="test")
|
doc = Document.objects.create(title="test")
|
||||||
|
|
||||||
doc.title = "new title"
|
doc.title = "new title"
|
||||||
self.doc_admin.save_model(None, doc, None, None)
|
self.doc_admin.save_model(None, doc, None, None)
|
||||||
self.assertEqual(Document.objects.get(id=doc.id).title, "new title")
|
self.assertEqual(Document.objects.get(id=doc.id).title, "new title")
|
||||||
self.assertEqual(self.get_document_from_index(doc)["id"], doc.id)
|
self.assertEqual(self.get_document_from_index(doc)["id"], [doc.id])
|
||||||
|
|
||||||
def test_delete_model(self) -> None:
|
def test_delete_model(self) -> None:
|
||||||
doc = Document.objects.create(title="test")
|
doc = Document.objects.create(title="test")
|
||||||
index.add_or_update_document(doc)
|
get_backend().add_or_update(doc)
|
||||||
self.assertIsNotNone(self.get_document_from_index(doc))
|
self.assertIsNotNone(self.get_document_from_index(doc))
|
||||||
|
|
||||||
self.doc_admin.delete_model(None, doc)
|
self.doc_admin.delete_model(None, doc)
|
||||||
@@ -53,7 +72,7 @@ class TestDocumentAdmin(DirectoriesMixin, TestCase):
|
|||||||
checksum=f"{i:02}",
|
checksum=f"{i:02}",
|
||||||
)
|
)
|
||||||
docs.append(doc)
|
docs.append(doc)
|
||||||
index.add_or_update_document(doc)
|
get_backend().add_or_update(doc)
|
||||||
|
|
||||||
self.assertEqual(Document.objects.count(), 42)
|
self.assertEqual(Document.objects.count(), 42)
|
||||||
|
|
||||||
|
|||||||
@@ -614,6 +614,63 @@ 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.assertEqual(Document.objects.count(), 5)
|
self.assertEqual(Document.objects.count(), 5)
|
||||||
|
|
||||||
|
def test_api_requires_documents_unless_all_is_true(self) -> None:
|
||||||
|
response = self.client.post(
|
||||||
|
"/api/documents/bulk_edit/",
|
||||||
|
json.dumps(
|
||||||
|
{
|
||||||
|
"method": "set_storage_path",
|
||||||
|
"parameters": {"storage_path": self.sp1.id},
|
||||||
|
},
|
||||||
|
),
|
||||||
|
content_type="application/json",
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
|
||||||
|
self.assertIn(b"documents is required unless all is true", response.content)
|
||||||
|
|
||||||
|
@mock.patch("documents.serialisers.bulk_edit.set_storage_path")
|
||||||
|
def test_api_bulk_edit_with_all_true_resolves_documents_from_filters(
|
||||||
|
self,
|
||||||
|
m,
|
||||||
|
) -> None:
|
||||||
|
self.setup_mock(m, "set_storage_path")
|
||||||
|
|
||||||
|
response = self.client.post(
|
||||||
|
"/api/documents/bulk_edit/",
|
||||||
|
json.dumps(
|
||||||
|
{
|
||||||
|
"all": True,
|
||||||
|
"filters": {"title__icontains": "B"},
|
||||||
|
"method": "set_storage_path",
|
||||||
|
"parameters": {"storage_path": self.sp1.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.doc2.id])
|
||||||
|
self.assertEqual(kwargs["storage_path"], self.sp1.id)
|
||||||
|
|
||||||
|
def test_api_bulk_edit_with_all_true_rejects_unsupported_methods(self) -> None:
|
||||||
|
response = self.client.post(
|
||||||
|
"/api/documents/bulk_edit/",
|
||||||
|
json.dumps(
|
||||||
|
{
|
||||||
|
"all": True,
|
||||||
|
"method": "merge",
|
||||||
|
"parameters": {"metadata_document_id": self.doc2.id},
|
||||||
|
},
|
||||||
|
),
|
||||||
|
content_type="application/json",
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
|
||||||
|
self.assertIn(b"This method does not support all=true", response.content)
|
||||||
|
|
||||||
def test_api_invalid_method(self) -> None:
|
def test_api_invalid_method(self) -> None:
|
||||||
self.assertEqual(Document.objects.count(), 5)
|
self.assertEqual(Document.objects.count(), 5)
|
||||||
response = self.client.post(
|
response = self.client.post(
|
||||||
|
|||||||
@@ -109,7 +109,7 @@ class TestDocumentVersioningApi(DirectoriesMixin, APITestCase):
|
|||||||
mime_type="application/pdf",
|
mime_type="application/pdf",
|
||||||
)
|
)
|
||||||
|
|
||||||
with mock.patch("documents.index.remove_document_from_index"):
|
with mock.patch("documents.search.get_backend"):
|
||||||
resp = self.client.delete(f"/api/documents/{root.id}/versions/{root.id}/")
|
resp = self.client.delete(f"/api/documents/{root.id}/versions/{root.id}/")
|
||||||
|
|
||||||
self.assertEqual(resp.status_code, status.HTTP_400_BAD_REQUEST)
|
self.assertEqual(resp.status_code, status.HTTP_400_BAD_REQUEST)
|
||||||
@@ -137,10 +137,7 @@ class TestDocumentVersioningApi(DirectoriesMixin, APITestCase):
|
|||||||
content="v2-content",
|
content="v2-content",
|
||||||
)
|
)
|
||||||
|
|
||||||
with (
|
with mock.patch("documents.search.get_backend"):
|
||||||
mock.patch("documents.index.remove_document_from_index"),
|
|
||||||
mock.patch("documents.index.add_or_update_document"),
|
|
||||||
):
|
|
||||||
resp = self.client.delete(f"/api/documents/{root.id}/versions/{v2.id}/")
|
resp = self.client.delete(f"/api/documents/{root.id}/versions/{v2.id}/")
|
||||||
|
|
||||||
self.assertEqual(resp.status_code, status.HTTP_200_OK)
|
self.assertEqual(resp.status_code, status.HTTP_200_OK)
|
||||||
@@ -149,10 +146,7 @@ class TestDocumentVersioningApi(DirectoriesMixin, APITestCase):
|
|||||||
root.refresh_from_db()
|
root.refresh_from_db()
|
||||||
self.assertEqual(root.content, "root-content")
|
self.assertEqual(root.content, "root-content")
|
||||||
|
|
||||||
with (
|
with mock.patch("documents.search.get_backend"):
|
||||||
mock.patch("documents.index.remove_document_from_index"),
|
|
||||||
mock.patch("documents.index.add_or_update_document"),
|
|
||||||
):
|
|
||||||
resp = self.client.delete(f"/api/documents/{root.id}/versions/{v1.id}/")
|
resp = self.client.delete(f"/api/documents/{root.id}/versions/{v1.id}/")
|
||||||
|
|
||||||
self.assertEqual(resp.status_code, status.HTTP_200_OK)
|
self.assertEqual(resp.status_code, status.HTTP_200_OK)
|
||||||
@@ -175,10 +169,7 @@ class TestDocumentVersioningApi(DirectoriesMixin, APITestCase):
|
|||||||
)
|
)
|
||||||
version_id = version.id
|
version_id = version.id
|
||||||
|
|
||||||
with (
|
with mock.patch("documents.search.get_backend"):
|
||||||
mock.patch("documents.index.remove_document_from_index"),
|
|
||||||
mock.patch("documents.index.add_or_update_document"),
|
|
||||||
):
|
|
||||||
resp = self.client.delete(
|
resp = self.client.delete(
|
||||||
f"/api/documents/{root.id}/versions/{version_id}/",
|
f"/api/documents/{root.id}/versions/{version_id}/",
|
||||||
)
|
)
|
||||||
@@ -225,7 +216,7 @@ class TestDocumentVersioningApi(DirectoriesMixin, APITestCase):
|
|||||||
root_document=other_root,
|
root_document=other_root,
|
||||||
)
|
)
|
||||||
|
|
||||||
with mock.patch("documents.index.remove_document_from_index"):
|
with mock.patch("documents.search.get_backend"):
|
||||||
resp = self.client.delete(
|
resp = self.client.delete(
|
||||||
f"/api/documents/{root.id}/versions/{other_version.id}/",
|
f"/api/documents/{root.id}/versions/{other_version.id}/",
|
||||||
)
|
)
|
||||||
@@ -245,10 +236,7 @@ class TestDocumentVersioningApi(DirectoriesMixin, APITestCase):
|
|||||||
root_document=root,
|
root_document=root,
|
||||||
)
|
)
|
||||||
|
|
||||||
with (
|
with mock.patch("documents.search.get_backend"):
|
||||||
mock.patch("documents.index.remove_document_from_index"),
|
|
||||||
mock.patch("documents.index.add_or_update_document"),
|
|
||||||
):
|
|
||||||
resp = self.client.delete(
|
resp = self.client.delete(
|
||||||
f"/api/documents/{version.id}/versions/{version.id}/",
|
f"/api/documents/{version.id}/versions/{version.id}/",
|
||||||
)
|
)
|
||||||
@@ -275,18 +263,17 @@ class TestDocumentVersioningApi(DirectoriesMixin, APITestCase):
|
|||||||
root_document=root,
|
root_document=root,
|
||||||
)
|
)
|
||||||
|
|
||||||
with (
|
with mock.patch("documents.search.get_backend") as mock_get_backend:
|
||||||
mock.patch("documents.index.remove_document_from_index") as remove_index,
|
mock_backend = mock.MagicMock()
|
||||||
mock.patch("documents.index.add_or_update_document") as add_or_update,
|
mock_get_backend.return_value = mock_backend
|
||||||
):
|
|
||||||
resp = self.client.delete(
|
resp = self.client.delete(
|
||||||
f"/api/documents/{root.id}/versions/{version.id}/",
|
f"/api/documents/{root.id}/versions/{version.id}/",
|
||||||
)
|
)
|
||||||
|
|
||||||
self.assertEqual(resp.status_code, status.HTTP_200_OK)
|
self.assertEqual(resp.status_code, status.HTTP_200_OK)
|
||||||
remove_index.assert_called_once_with(version)
|
mock_backend.remove.assert_called_once_with(version.pk)
|
||||||
add_or_update.assert_called_once()
|
mock_backend.add_or_update.assert_called_once()
|
||||||
self.assertEqual(add_or_update.call_args[0][0].id, root.id)
|
self.assertEqual(mock_backend.add_or_update.call_args[0][0].id, root.id)
|
||||||
|
|
||||||
def test_delete_version_returns_403_without_permission(self) -> None:
|
def test_delete_version_returns_403_without_permission(self) -> None:
|
||||||
owner = User.objects.create_user(username="owner")
|
owner = User.objects.create_user(username="owner")
|
||||||
|
|||||||
@@ -1119,21 +1119,19 @@ class TestDocumentApi(DirectoriesMixin, DocumentConsumeDelayMixin, APITestCase):
|
|||||||
[u1_doc1.id],
|
[u1_doc1.id],
|
||||||
)
|
)
|
||||||
|
|
||||||
def test_pagination_all(self) -> None:
|
def test_pagination_results(self) -> None:
|
||||||
"""
|
"""
|
||||||
GIVEN:
|
GIVEN:
|
||||||
- A set of 50 documents
|
- A set of 50 documents
|
||||||
WHEN:
|
WHEN:
|
||||||
- API request for document filtering
|
- API request for document filtering
|
||||||
THEN:
|
THEN:
|
||||||
- Results are paginated (25 items) and response["all"] returns all ids (50 items)
|
- Results are paginated (25 items) and count reflects all results (50 items)
|
||||||
"""
|
"""
|
||||||
t = Tag.objects.create(name="tag")
|
t = Tag.objects.create(name="tag")
|
||||||
docs = []
|
|
||||||
for i in range(50):
|
for i in range(50):
|
||||||
d = Document.objects.create(checksum=i, content=f"test{i}")
|
d = Document.objects.create(checksum=i, content=f"test{i}")
|
||||||
d.tags.add(t)
|
d.tags.add(t)
|
||||||
docs.append(d)
|
|
||||||
|
|
||||||
response = self.client.get(
|
response = self.client.get(
|
||||||
f"/api/documents/?tags__id__in={t.id}",
|
f"/api/documents/?tags__id__in={t.id}",
|
||||||
@@ -1141,9 +1139,84 @@ class TestDocumentApi(DirectoriesMixin, DocumentConsumeDelayMixin, APITestCase):
|
|||||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||||
results = response.data["results"]
|
results = response.data["results"]
|
||||||
self.assertEqual(len(results), 25)
|
self.assertEqual(len(results), 25)
|
||||||
self.assertEqual(len(response.data["all"]), 50)
|
self.assertEqual(response.data["count"], 50)
|
||||||
|
self.assertNotIn("all", response.data)
|
||||||
|
|
||||||
|
def test_pagination_all_for_api_version_9(self) -> None:
|
||||||
|
"""
|
||||||
|
GIVEN:
|
||||||
|
- A set of documents matching a filter
|
||||||
|
WHEN:
|
||||||
|
- API request uses legacy version 9
|
||||||
|
THEN:
|
||||||
|
- Response includes "all" for backward compatibility
|
||||||
|
"""
|
||||||
|
t = Tag.objects.create(name="tag")
|
||||||
|
docs = []
|
||||||
|
for i in range(4):
|
||||||
|
d = Document.objects.create(checksum=i, content=f"test{i}")
|
||||||
|
d.tags.add(t)
|
||||||
|
docs.append(d)
|
||||||
|
|
||||||
|
response = self.client.get(
|
||||||
|
f"/api/documents/?tags__id__in={t.id}",
|
||||||
|
headers={"Accept": "application/json; version=9"},
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||||
|
self.assertIn("all", response.data)
|
||||||
self.assertCountEqual(response.data["all"], [d.id for d in docs])
|
self.assertCountEqual(response.data["all"], [d.id for d in docs])
|
||||||
|
|
||||||
|
def test_list_with_include_selection_data(self) -> None:
|
||||||
|
correspondent = Correspondent.objects.create(name="c1")
|
||||||
|
doc_type = DocumentType.objects.create(name="dt1")
|
||||||
|
storage_path = StoragePath.objects.create(name="sp1")
|
||||||
|
tag = Tag.objects.create(name="tag")
|
||||||
|
|
||||||
|
matching_doc = Document.objects.create(
|
||||||
|
checksum="A",
|
||||||
|
correspondent=correspondent,
|
||||||
|
document_type=doc_type,
|
||||||
|
storage_path=storage_path,
|
||||||
|
)
|
||||||
|
matching_doc.tags.add(tag)
|
||||||
|
|
||||||
|
non_matching_doc = Document.objects.create(checksum="B")
|
||||||
|
non_matching_doc.tags.add(Tag.objects.create(name="other"))
|
||||||
|
|
||||||
|
response = self.client.get(
|
||||||
|
f"/api/documents/?tags__id__in={tag.id}&include_selection_data=true",
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||||
|
self.assertIn("selection_data", response.data)
|
||||||
|
|
||||||
|
selected_correspondent = next(
|
||||||
|
item
|
||||||
|
for item in response.data["selection_data"]["selected_correspondents"]
|
||||||
|
if item["id"] == correspondent.id
|
||||||
|
)
|
||||||
|
selected_tag = next(
|
||||||
|
item
|
||||||
|
for item in response.data["selection_data"]["selected_tags"]
|
||||||
|
if item["id"] == tag.id
|
||||||
|
)
|
||||||
|
selected_type = next(
|
||||||
|
item
|
||||||
|
for item in response.data["selection_data"]["selected_document_types"]
|
||||||
|
if item["id"] == doc_type.id
|
||||||
|
)
|
||||||
|
selected_storage_path = next(
|
||||||
|
item
|
||||||
|
for item in response.data["selection_data"]["selected_storage_paths"]
|
||||||
|
if item["id"] == storage_path.id
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(selected_correspondent["document_count"], 1)
|
||||||
|
self.assertEqual(selected_tag["document_count"], 1)
|
||||||
|
self.assertEqual(selected_type["document_count"], 1)
|
||||||
|
self.assertEqual(selected_storage_path["document_count"], 1)
|
||||||
|
|
||||||
def test_statistics(self) -> None:
|
def test_statistics(self) -> None:
|
||||||
doc1 = Document.objects.create(
|
doc1 = Document.objects.create(
|
||||||
title="none1",
|
title="none1",
|
||||||
|
|||||||
@@ -145,6 +145,22 @@ class TestApiObjects(DirectoriesMixin, APITestCase):
|
|||||||
response.data["last_correspondence"],
|
response.data["last_correspondence"],
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def test_paginated_objects_include_all_only_for_legacy_version(self) -> None:
|
||||||
|
response_v10 = self.client.get("/api/correspondents/")
|
||||||
|
self.assertEqual(response_v10.status_code, status.HTTP_200_OK)
|
||||||
|
self.assertNotIn("all", response_v10.data)
|
||||||
|
|
||||||
|
response_v9 = self.client.get(
|
||||||
|
"/api/correspondents/",
|
||||||
|
headers={"Accept": "application/json; version=9"},
|
||||||
|
)
|
||||||
|
self.assertEqual(response_v9.status_code, status.HTTP_200_OK)
|
||||||
|
self.assertIn("all", response_v9.data)
|
||||||
|
self.assertCountEqual(
|
||||||
|
response_v9.data["all"],
|
||||||
|
[self.c1.id, self.c2.id, self.c3.id],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class TestApiStoragePaths(DirectoriesMixin, APITestCase):
|
class TestApiStoragePaths(DirectoriesMixin, APITestCase):
|
||||||
ENDPOINT = "/api/storage_paths/"
|
ENDPOINT = "/api/storage_paths/"
|
||||||
@@ -794,6 +810,62 @@ class TestBulkEditObjects(APITestCase):
|
|||||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||||
self.assertEqual(StoragePath.objects.count(), 0)
|
self.assertEqual(StoragePath.objects.count(), 0)
|
||||||
|
|
||||||
|
def test_bulk_objects_delete_all_filtered(self) -> None:
|
||||||
|
"""
|
||||||
|
GIVEN:
|
||||||
|
- Existing objects that can be filtered by name
|
||||||
|
WHEN:
|
||||||
|
- bulk_edit_objects API endpoint is called with all=true and filters
|
||||||
|
THEN:
|
||||||
|
- Matching objects are deleted without passing explicit IDs
|
||||||
|
"""
|
||||||
|
Correspondent.objects.create(name="c2")
|
||||||
|
|
||||||
|
response = self.client.post(
|
||||||
|
"/api/bulk_edit_objects/",
|
||||||
|
json.dumps(
|
||||||
|
{
|
||||||
|
"all": True,
|
||||||
|
"filters": {"name__icontains": "c"},
|
||||||
|
"object_type": "correspondents",
|
||||||
|
"operation": "delete",
|
||||||
|
},
|
||||||
|
),
|
||||||
|
content_type="application/json",
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||||
|
self.assertEqual(Correspondent.objects.count(), 0)
|
||||||
|
|
||||||
|
def test_bulk_objects_delete_all_filtered_tags_includes_descendants(self) -> None:
|
||||||
|
"""
|
||||||
|
GIVEN:
|
||||||
|
- Root tag with descendants
|
||||||
|
WHEN:
|
||||||
|
- bulk_edit_objects API endpoint is called with all=true
|
||||||
|
THEN:
|
||||||
|
- Root tags and descendants are deleted
|
||||||
|
"""
|
||||||
|
parent = Tag.objects.create(name="parent")
|
||||||
|
child = Tag.objects.create(name="child", tn_parent=parent)
|
||||||
|
|
||||||
|
response = self.client.post(
|
||||||
|
"/api/bulk_edit_objects/",
|
||||||
|
json.dumps(
|
||||||
|
{
|
||||||
|
"all": True,
|
||||||
|
"filters": {"is_root": True},
|
||||||
|
"object_type": "tags",
|
||||||
|
"operation": "delete",
|
||||||
|
},
|
||||||
|
),
|
||||||
|
content_type="application/json",
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||||
|
self.assertFalse(Tag.objects.filter(id=parent.id).exists())
|
||||||
|
self.assertFalse(Tag.objects.filter(id=child.id).exists())
|
||||||
|
|
||||||
def test_bulk_edit_object_permissions_insufficient_global_perms(self) -> None:
|
def test_bulk_edit_object_permissions_insufficient_global_perms(self) -> None:
|
||||||
"""
|
"""
|
||||||
GIVEN:
|
GIVEN:
|
||||||
@@ -881,3 +953,40 @@ class TestBulkEditObjects(APITestCase):
|
|||||||
|
|
||||||
self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN)
|
self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN)
|
||||||
self.assertEqual(response.content, b"Insufficient permissions")
|
self.assertEqual(response.content, b"Insufficient permissions")
|
||||||
|
|
||||||
|
def test_bulk_edit_all_filtered_permissions_insufficient_object_perms(
|
||||||
|
self,
|
||||||
|
) -> None:
|
||||||
|
"""
|
||||||
|
GIVEN:
|
||||||
|
- Filter-matching objects include one that the user cannot edit
|
||||||
|
WHEN:
|
||||||
|
- bulk_edit_objects API endpoint is called with all=true
|
||||||
|
THEN:
|
||||||
|
- Operation applies only to editable objects
|
||||||
|
"""
|
||||||
|
self.t2.owner = User.objects.get(username="temp_admin")
|
||||||
|
self.t2.save()
|
||||||
|
|
||||||
|
self.user1.user_permissions.add(
|
||||||
|
*Permission.objects.filter(codename="delete_tag"),
|
||||||
|
)
|
||||||
|
self.user1.save()
|
||||||
|
self.client.force_authenticate(user=self.user1)
|
||||||
|
|
||||||
|
response = self.client.post(
|
||||||
|
"/api/bulk_edit_objects/",
|
||||||
|
json.dumps(
|
||||||
|
{
|
||||||
|
"all": True,
|
||||||
|
"filters": {"name__icontains": "t"},
|
||||||
|
"object_type": "tags",
|
||||||
|
"operation": "delete",
|
||||||
|
},
|
||||||
|
),
|
||||||
|
content_type="application/json",
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||||
|
self.assertTrue(Tag.objects.filter(id=self.t2.id).exists())
|
||||||
|
self.assertFalse(Tag.objects.filter(id=self.t1.id).exists())
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import datetime
|
|||||||
from datetime import timedelta
|
from datetime import timedelta
|
||||||
from unittest import mock
|
from unittest import mock
|
||||||
|
|
||||||
|
import pytest
|
||||||
from dateutil.relativedelta import relativedelta
|
from dateutil.relativedelta import relativedelta
|
||||||
from django.contrib.auth.models import Group
|
from django.contrib.auth.models import Group
|
||||||
from django.contrib.auth.models import Permission
|
from django.contrib.auth.models import Permission
|
||||||
@@ -11,9 +12,7 @@ from django.utils import timezone
|
|||||||
from guardian.shortcuts import assign_perm
|
from guardian.shortcuts import assign_perm
|
||||||
from rest_framework import status
|
from rest_framework import status
|
||||||
from rest_framework.test import APITestCase
|
from rest_framework.test import APITestCase
|
||||||
from whoosh.writing import AsyncWriter
|
|
||||||
|
|
||||||
from documents import index
|
|
||||||
from documents.bulk_edit import set_permissions
|
from documents.bulk_edit import set_permissions
|
||||||
from documents.models import Correspondent
|
from documents.models import Correspondent
|
||||||
from documents.models import CustomField
|
from documents.models import CustomField
|
||||||
@@ -25,18 +24,27 @@ from documents.models import SavedView
|
|||||||
from documents.models import StoragePath
|
from documents.models import StoragePath
|
||||||
from documents.models import Tag
|
from documents.models import Tag
|
||||||
from documents.models import Workflow
|
from documents.models import Workflow
|
||||||
|
from documents.search import get_backend
|
||||||
|
from documents.search import reset_backend
|
||||||
from documents.tests.utils import DirectoriesMixin
|
from documents.tests.utils import DirectoriesMixin
|
||||||
from paperless_mail.models import MailAccount
|
from paperless_mail.models import MailAccount
|
||||||
from paperless_mail.models import MailRule
|
from paperless_mail.models import MailRule
|
||||||
|
|
||||||
|
pytestmark = pytest.mark.search
|
||||||
|
|
||||||
|
|
||||||
class TestDocumentSearchApi(DirectoriesMixin, APITestCase):
|
class TestDocumentSearchApi(DirectoriesMixin, APITestCase):
|
||||||
def setUp(self) -> None:
|
def setUp(self) -> None:
|
||||||
super().setUp()
|
super().setUp()
|
||||||
|
reset_backend()
|
||||||
|
|
||||||
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)
|
||||||
|
|
||||||
|
def tearDown(self) -> None:
|
||||||
|
reset_backend()
|
||||||
|
super().tearDown()
|
||||||
|
|
||||||
def test_search(self) -> None:
|
def test_search(self) -> None:
|
||||||
d1 = Document.objects.create(
|
d1 = Document.objects.create(
|
||||||
title="invoice",
|
title="invoice",
|
||||||
@@ -57,37 +65,96 @@ class TestDocumentSearchApi(DirectoriesMixin, APITestCase):
|
|||||||
checksum="C",
|
checksum="C",
|
||||||
original_filename="someepdf.pdf",
|
original_filename="someepdf.pdf",
|
||||||
)
|
)
|
||||||
with AsyncWriter(index.open_index()) as writer:
|
backend = get_backend()
|
||||||
# Note to future self: there is a reason we dont use a model signal handler to update the index: some operations edit many documents at once
|
backend.add_or_update(d1)
|
||||||
# (retagger, renamer) and we don't want to open a writer for each of these, but rather perform the entire operation with one writer.
|
backend.add_or_update(d2)
|
||||||
# That's why we can't open the writer in a model on_save handler or something.
|
backend.add_or_update(d3)
|
||||||
index.update_document(writer, d1)
|
|
||||||
index.update_document(writer, d2)
|
|
||||||
index.update_document(writer, d3)
|
|
||||||
response = self.client.get("/api/documents/?query=bank")
|
response = self.client.get("/api/documents/?query=bank")
|
||||||
results = response.data["results"]
|
results = response.data["results"]
|
||||||
self.assertEqual(response.data["count"], 3)
|
self.assertEqual(response.data["count"], 3)
|
||||||
self.assertEqual(len(results), 3)
|
self.assertEqual(len(results), 3)
|
||||||
self.assertCountEqual(response.data["all"], [d1.id, d2.id, d3.id])
|
|
||||||
|
|
||||||
response = self.client.get("/api/documents/?query=september")
|
response = self.client.get("/api/documents/?query=september")
|
||||||
results = response.data["results"]
|
results = response.data["results"]
|
||||||
self.assertEqual(response.data["count"], 1)
|
self.assertEqual(response.data["count"], 1)
|
||||||
self.assertEqual(len(results), 1)
|
self.assertEqual(len(results), 1)
|
||||||
self.assertCountEqual(response.data["all"], [d3.id])
|
|
||||||
self.assertEqual(results[0]["original_file_name"], "someepdf.pdf")
|
self.assertEqual(results[0]["original_file_name"], "someepdf.pdf")
|
||||||
|
|
||||||
response = self.client.get("/api/documents/?query=statement")
|
response = self.client.get("/api/documents/?query=statement")
|
||||||
results = response.data["results"]
|
results = response.data["results"]
|
||||||
self.assertEqual(response.data["count"], 2)
|
self.assertEqual(response.data["count"], 2)
|
||||||
self.assertEqual(len(results), 2)
|
self.assertEqual(len(results), 2)
|
||||||
self.assertCountEqual(response.data["all"], [d2.id, d3.id])
|
|
||||||
|
|
||||||
response = self.client.get("/api/documents/?query=sfegdfg")
|
response = self.client.get("/api/documents/?query=sfegdfg")
|
||||||
results = response.data["results"]
|
results = response.data["results"]
|
||||||
self.assertEqual(response.data["count"], 0)
|
self.assertEqual(response.data["count"], 0)
|
||||||
self.assertEqual(len(results), 0)
|
self.assertEqual(len(results), 0)
|
||||||
self.assertCountEqual(response.data["all"], [])
|
|
||||||
|
def test_search_returns_all_for_api_version_9(self) -> None:
|
||||||
|
d1 = Document.objects.create(
|
||||||
|
title="invoice",
|
||||||
|
content="bank payment",
|
||||||
|
checksum="A",
|
||||||
|
pk=1,
|
||||||
|
)
|
||||||
|
d2 = Document.objects.create(
|
||||||
|
title="bank statement",
|
||||||
|
content="bank transfer",
|
||||||
|
checksum="B",
|
||||||
|
pk=2,
|
||||||
|
)
|
||||||
|
backend = get_backend()
|
||||||
|
backend.add_or_update(d1)
|
||||||
|
backend.add_or_update(d2)
|
||||||
|
|
||||||
|
response = self.client.get(
|
||||||
|
"/api/documents/?query=bank",
|
||||||
|
headers={"Accept": "application/json; version=9"},
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||||
|
self.assertIn("all", response.data)
|
||||||
|
self.assertCountEqual(response.data["all"], [d1.id, d2.id])
|
||||||
|
|
||||||
|
def test_search_with_include_selection_data(self) -> None:
|
||||||
|
correspondent = Correspondent.objects.create(name="c1")
|
||||||
|
doc_type = DocumentType.objects.create(name="dt1")
|
||||||
|
storage_path = StoragePath.objects.create(name="sp1")
|
||||||
|
tag = Tag.objects.create(name="tag")
|
||||||
|
|
||||||
|
matching_doc = Document.objects.create(
|
||||||
|
title="bank statement",
|
||||||
|
content="bank content",
|
||||||
|
checksum="A",
|
||||||
|
correspondent=correspondent,
|
||||||
|
document_type=doc_type,
|
||||||
|
storage_path=storage_path,
|
||||||
|
)
|
||||||
|
matching_doc.tags.add(tag)
|
||||||
|
|
||||||
|
get_backend().add_or_update(matching_doc)
|
||||||
|
|
||||||
|
response = self.client.get(
|
||||||
|
"/api/documents/?query=bank&include_selection_data=true",
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||||
|
self.assertIn("selection_data", response.data)
|
||||||
|
|
||||||
|
selected_correspondent = next(
|
||||||
|
item
|
||||||
|
for item in response.data["selection_data"]["selected_correspondents"]
|
||||||
|
if item["id"] == correspondent.id
|
||||||
|
)
|
||||||
|
selected_tag = next(
|
||||||
|
item
|
||||||
|
for item in response.data["selection_data"]["selected_tags"]
|
||||||
|
if item["id"] == tag.id
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(selected_correspondent["document_count"], 1)
|
||||||
|
self.assertEqual(selected_tag["document_count"], 1)
|
||||||
|
|
||||||
def test_search_custom_field_ordering(self) -> None:
|
def test_search_custom_field_ordering(self) -> None:
|
||||||
custom_field = CustomField.objects.create(
|
custom_field = CustomField.objects.create(
|
||||||
@@ -125,10 +192,10 @@ class TestDocumentSearchApi(DirectoriesMixin, APITestCase):
|
|||||||
value_int=20,
|
value_int=20,
|
||||||
)
|
)
|
||||||
|
|
||||||
with AsyncWriter(index.open_index()) as writer:
|
backend = get_backend()
|
||||||
index.update_document(writer, d1)
|
backend.add_or_update(d1)
|
||||||
index.update_document(writer, d2)
|
backend.add_or_update(d2)
|
||||||
index.update_document(writer, d3)
|
backend.add_or_update(d3)
|
||||||
|
|
||||||
response = self.client.get(
|
response = self.client.get(
|
||||||
f"/api/documents/?query=match&ordering=custom_field_{custom_field.pk}",
|
f"/api/documents/?query=match&ordering=custom_field_{custom_field.pk}",
|
||||||
@@ -149,15 +216,15 @@ class TestDocumentSearchApi(DirectoriesMixin, APITestCase):
|
|||||||
)
|
)
|
||||||
|
|
||||||
def test_search_multi_page(self) -> None:
|
def test_search_multi_page(self) -> None:
|
||||||
with AsyncWriter(index.open_index()) as writer:
|
backend = get_backend()
|
||||||
for i in range(55):
|
for i in range(55):
|
||||||
doc = Document.objects.create(
|
doc = Document.objects.create(
|
||||||
checksum=str(i),
|
checksum=str(i),
|
||||||
pk=i + 1,
|
pk=i + 1,
|
||||||
title=f"Document {i + 1}",
|
title=f"Document {i + 1}",
|
||||||
content="content",
|
content="content",
|
||||||
)
|
)
|
||||||
index.update_document(writer, doc)
|
backend.add_or_update(doc)
|
||||||
|
|
||||||
# This is here so that we test that no document gets returned twice (might happen if the paging is not working)
|
# This is here so that we test that no document gets returned twice (might happen if the paging is not working)
|
||||||
seen_ids = []
|
seen_ids = []
|
||||||
@@ -184,15 +251,15 @@ class TestDocumentSearchApi(DirectoriesMixin, APITestCase):
|
|||||||
seen_ids.append(result["id"])
|
seen_ids.append(result["id"])
|
||||||
|
|
||||||
def test_search_invalid_page(self) -> None:
|
def test_search_invalid_page(self) -> None:
|
||||||
with AsyncWriter(index.open_index()) as writer:
|
backend = get_backend()
|
||||||
for i in range(15):
|
for i in range(15):
|
||||||
doc = Document.objects.create(
|
doc = Document.objects.create(
|
||||||
checksum=str(i),
|
checksum=str(i),
|
||||||
pk=i + 1,
|
pk=i + 1,
|
||||||
title=f"Document {i + 1}",
|
title=f"Document {i + 1}",
|
||||||
content="content",
|
content="content",
|
||||||
)
|
)
|
||||||
index.update_document(writer, doc)
|
backend.add_or_update(doc)
|
||||||
|
|
||||||
response = self.client.get("/api/documents/?query=content&page=0&page_size=10")
|
response = self.client.get("/api/documents/?query=content&page=0&page_size=10")
|
||||||
self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND)
|
self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND)
|
||||||
@@ -230,26 +297,25 @@ class TestDocumentSearchApi(DirectoriesMixin, APITestCase):
|
|||||||
pk=3,
|
pk=3,
|
||||||
checksum="C",
|
checksum="C",
|
||||||
)
|
)
|
||||||
with index.open_index_writer() as writer:
|
backend = get_backend()
|
||||||
index.update_document(writer, d1)
|
backend.add_or_update(d1)
|
||||||
index.update_document(writer, d2)
|
backend.add_or_update(d2)
|
||||||
index.update_document(writer, d3)
|
backend.add_or_update(d3)
|
||||||
|
|
||||||
response = self.client.get("/api/documents/?query=added:[-1 week to now]")
|
response = self.client.get("/api/documents/?query=added:[-1 week to now]")
|
||||||
results = response.data["results"]
|
results = response.data["results"]
|
||||||
# Expect 3 documents returned
|
# Expect 3 documents returned
|
||||||
self.assertEqual(len(results), 3)
|
self.assertEqual(len(results), 3)
|
||||||
|
|
||||||
for idx, subset in enumerate(
|
result_map = {r["id"]: r for r in results}
|
||||||
[
|
self.assertEqual(set(result_map.keys()), {1, 2, 3})
|
||||||
{"id": 1, "title": "invoice"},
|
for subset in [
|
||||||
{"id": 2, "title": "bank statement 1"},
|
{"id": 1, "title": "invoice"},
|
||||||
{"id": 3, "title": "bank statement 3"},
|
{"id": 2, "title": "bank statement 1"},
|
||||||
],
|
{"id": 3, "title": "bank statement 3"},
|
||||||
):
|
]:
|
||||||
result = results[idx]
|
r = result_map[subset["id"]]
|
||||||
# Assert subset in results
|
self.assertDictEqual(r, {**r, **subset})
|
||||||
self.assertDictEqual(result, {**result, **subset})
|
|
||||||
|
|
||||||
@override_settings(
|
@override_settings(
|
||||||
TIME_ZONE="America/Chicago",
|
TIME_ZONE="America/Chicago",
|
||||||
@@ -285,10 +351,10 @@ class TestDocumentSearchApi(DirectoriesMixin, APITestCase):
|
|||||||
# 7 days, 1 hour and 1 minute ago
|
# 7 days, 1 hour and 1 minute ago
|
||||||
added=timezone.now() - timedelta(days=7, hours=1, minutes=1),
|
added=timezone.now() - timedelta(days=7, hours=1, minutes=1),
|
||||||
)
|
)
|
||||||
with index.open_index_writer() as writer:
|
backend = get_backend()
|
||||||
index.update_document(writer, d1)
|
backend.add_or_update(d1)
|
||||||
index.update_document(writer, d2)
|
backend.add_or_update(d2)
|
||||||
index.update_document(writer, d3)
|
backend.add_or_update(d3)
|
||||||
|
|
||||||
response = self.client.get("/api/documents/?query=added:[-1 week to now]")
|
response = self.client.get("/api/documents/?query=added:[-1 week to now]")
|
||||||
results = response.data["results"]
|
results = response.data["results"]
|
||||||
@@ -296,12 +362,14 @@ class TestDocumentSearchApi(DirectoriesMixin, APITestCase):
|
|||||||
# Expect 2 documents returned
|
# Expect 2 documents returned
|
||||||
self.assertEqual(len(results), 2)
|
self.assertEqual(len(results), 2)
|
||||||
|
|
||||||
for idx, subset in enumerate(
|
result_map = {r["id"]: r for r in results}
|
||||||
[{"id": 1, "title": "invoice"}, {"id": 2, "title": "bank statement 1"}],
|
self.assertEqual(set(result_map.keys()), {1, 2})
|
||||||
):
|
for subset in [
|
||||||
result = results[idx]
|
{"id": 1, "title": "invoice"},
|
||||||
# Assert subset in results
|
{"id": 2, "title": "bank statement 1"},
|
||||||
self.assertDictEqual(result, {**result, **subset})
|
]:
|
||||||
|
r = result_map[subset["id"]]
|
||||||
|
self.assertDictEqual(r, {**r, **subset})
|
||||||
|
|
||||||
@override_settings(
|
@override_settings(
|
||||||
TIME_ZONE="Europe/Sofia",
|
TIME_ZONE="Europe/Sofia",
|
||||||
@@ -337,10 +405,10 @@ class TestDocumentSearchApi(DirectoriesMixin, APITestCase):
|
|||||||
# 7 days, 1 hour and 1 minute ago
|
# 7 days, 1 hour and 1 minute ago
|
||||||
added=timezone.now() - timedelta(days=7, hours=1, minutes=1),
|
added=timezone.now() - timedelta(days=7, hours=1, minutes=1),
|
||||||
)
|
)
|
||||||
with index.open_index_writer() as writer:
|
backend = get_backend()
|
||||||
index.update_document(writer, d1)
|
backend.add_or_update(d1)
|
||||||
index.update_document(writer, d2)
|
backend.add_or_update(d2)
|
||||||
index.update_document(writer, d3)
|
backend.add_or_update(d3)
|
||||||
|
|
||||||
response = self.client.get("/api/documents/?query=added:[-1 week to now]")
|
response = self.client.get("/api/documents/?query=added:[-1 week to now]")
|
||||||
results = response.data["results"]
|
results = response.data["results"]
|
||||||
@@ -348,12 +416,14 @@ class TestDocumentSearchApi(DirectoriesMixin, APITestCase):
|
|||||||
# Expect 2 documents returned
|
# Expect 2 documents returned
|
||||||
self.assertEqual(len(results), 2)
|
self.assertEqual(len(results), 2)
|
||||||
|
|
||||||
for idx, subset in enumerate(
|
result_map = {r["id"]: r for r in results}
|
||||||
[{"id": 1, "title": "invoice"}, {"id": 2, "title": "bank statement 1"}],
|
self.assertEqual(set(result_map.keys()), {1, 2})
|
||||||
):
|
for subset in [
|
||||||
result = results[idx]
|
{"id": 1, "title": "invoice"},
|
||||||
# Assert subset in results
|
{"id": 2, "title": "bank statement 1"},
|
||||||
self.assertDictEqual(result, {**result, **subset})
|
]:
|
||||||
|
r = result_map[subset["id"]]
|
||||||
|
self.assertDictEqual(r, {**r, **subset})
|
||||||
|
|
||||||
def test_search_added_in_last_month(self) -> None:
|
def test_search_added_in_last_month(self) -> None:
|
||||||
"""
|
"""
|
||||||
@@ -389,10 +459,10 @@ class TestDocumentSearchApi(DirectoriesMixin, APITestCase):
|
|||||||
added=timezone.now() - timedelta(days=7, hours=1, minutes=1),
|
added=timezone.now() - timedelta(days=7, hours=1, minutes=1),
|
||||||
)
|
)
|
||||||
|
|
||||||
with index.open_index_writer() as writer:
|
backend = get_backend()
|
||||||
index.update_document(writer, d1)
|
backend.add_or_update(d1)
|
||||||
index.update_document(writer, d2)
|
backend.add_or_update(d2)
|
||||||
index.update_document(writer, d3)
|
backend.add_or_update(d3)
|
||||||
|
|
||||||
response = self.client.get("/api/documents/?query=added:[-1 month to now]")
|
response = self.client.get("/api/documents/?query=added:[-1 month to now]")
|
||||||
results = response.data["results"]
|
results = response.data["results"]
|
||||||
@@ -400,12 +470,14 @@ class TestDocumentSearchApi(DirectoriesMixin, APITestCase):
|
|||||||
# Expect 2 documents returned
|
# Expect 2 documents returned
|
||||||
self.assertEqual(len(results), 2)
|
self.assertEqual(len(results), 2)
|
||||||
|
|
||||||
for idx, subset in enumerate(
|
result_map = {r["id"]: r for r in results}
|
||||||
[{"id": 1, "title": "invoice"}, {"id": 3, "title": "bank statement 3"}],
|
self.assertEqual(set(result_map.keys()), {1, 3})
|
||||||
):
|
for subset in [
|
||||||
result = results[idx]
|
{"id": 1, "title": "invoice"},
|
||||||
# Assert subset in results
|
{"id": 3, "title": "bank statement 3"},
|
||||||
self.assertDictEqual(result, {**result, **subset})
|
]:
|
||||||
|
r = result_map[subset["id"]]
|
||||||
|
self.assertDictEqual(r, {**r, **subset})
|
||||||
|
|
||||||
@override_settings(
|
@override_settings(
|
||||||
TIME_ZONE="America/Denver",
|
TIME_ZONE="America/Denver",
|
||||||
@@ -445,10 +517,10 @@ class TestDocumentSearchApi(DirectoriesMixin, APITestCase):
|
|||||||
added=timezone.now() - timedelta(days=7, hours=1, minutes=1),
|
added=timezone.now() - timedelta(days=7, hours=1, minutes=1),
|
||||||
)
|
)
|
||||||
|
|
||||||
with index.open_index_writer() as writer:
|
backend = get_backend()
|
||||||
index.update_document(writer, d1)
|
backend.add_or_update(d1)
|
||||||
index.update_document(writer, d2)
|
backend.add_or_update(d2)
|
||||||
index.update_document(writer, d3)
|
backend.add_or_update(d3)
|
||||||
|
|
||||||
response = self.client.get("/api/documents/?query=added:[-1 month to now]")
|
response = self.client.get("/api/documents/?query=added:[-1 month to now]")
|
||||||
results = response.data["results"]
|
results = response.data["results"]
|
||||||
@@ -456,12 +528,14 @@ class TestDocumentSearchApi(DirectoriesMixin, APITestCase):
|
|||||||
# Expect 2 documents returned
|
# Expect 2 documents returned
|
||||||
self.assertEqual(len(results), 2)
|
self.assertEqual(len(results), 2)
|
||||||
|
|
||||||
for idx, subset in enumerate(
|
result_map = {r["id"]: r for r in results}
|
||||||
[{"id": 1, "title": "invoice"}, {"id": 3, "title": "bank statement 3"}],
|
self.assertEqual(set(result_map.keys()), {1, 3})
|
||||||
):
|
for subset in [
|
||||||
result = results[idx]
|
{"id": 1, "title": "invoice"},
|
||||||
# Assert subset in results
|
{"id": 3, "title": "bank statement 3"},
|
||||||
self.assertDictEqual(result, {**result, **subset})
|
]:
|
||||||
|
r = result_map[subset["id"]]
|
||||||
|
self.assertDictEqual(r, {**r, **subset})
|
||||||
|
|
||||||
@override_settings(
|
@override_settings(
|
||||||
TIME_ZONE="Europe/Sofia",
|
TIME_ZONE="Europe/Sofia",
|
||||||
@@ -501,10 +575,10 @@ class TestDocumentSearchApi(DirectoriesMixin, APITestCase):
|
|||||||
# Django converts dates to UTC
|
# Django converts dates to UTC
|
||||||
d3.refresh_from_db()
|
d3.refresh_from_db()
|
||||||
|
|
||||||
with index.open_index_writer() as writer:
|
backend = get_backend()
|
||||||
index.update_document(writer, d1)
|
backend.add_or_update(d1)
|
||||||
index.update_document(writer, d2)
|
backend.add_or_update(d2)
|
||||||
index.update_document(writer, d3)
|
backend.add_or_update(d3)
|
||||||
|
|
||||||
response = self.client.get("/api/documents/?query=added:20231201")
|
response = self.client.get("/api/documents/?query=added:20231201")
|
||||||
results = response.data["results"]
|
results = response.data["results"]
|
||||||
@@ -512,12 +586,8 @@ class TestDocumentSearchApi(DirectoriesMixin, APITestCase):
|
|||||||
# Expect 1 document returned
|
# Expect 1 document returned
|
||||||
self.assertEqual(len(results), 1)
|
self.assertEqual(len(results), 1)
|
||||||
|
|
||||||
for idx, subset in enumerate(
|
self.assertEqual(results[0]["id"], 3)
|
||||||
[{"id": 3, "title": "bank statement 3"}],
|
self.assertEqual(results[0]["title"], "bank statement 3")
|
||||||
):
|
|
||||||
result = results[idx]
|
|
||||||
# Assert subset in results
|
|
||||||
self.assertDictEqual(result, {**result, **subset})
|
|
||||||
|
|
||||||
def test_search_added_invalid_date(self) -> None:
|
def test_search_added_invalid_date(self) -> None:
|
||||||
"""
|
"""
|
||||||
@@ -526,7 +596,7 @@ class TestDocumentSearchApi(DirectoriesMixin, APITestCase):
|
|||||||
WHEN:
|
WHEN:
|
||||||
- Query with invalid added date
|
- Query with invalid added date
|
||||||
THEN:
|
THEN:
|
||||||
- No documents returned
|
- 400 Bad Request returned (Tantivy rejects invalid date field syntax)
|
||||||
"""
|
"""
|
||||||
d1 = Document.objects.create(
|
d1 = Document.objects.create(
|
||||||
title="invoice",
|
title="invoice",
|
||||||
@@ -535,16 +605,14 @@ class TestDocumentSearchApi(DirectoriesMixin, APITestCase):
|
|||||||
pk=1,
|
pk=1,
|
||||||
)
|
)
|
||||||
|
|
||||||
with index.open_index_writer() as writer:
|
get_backend().add_or_update(d1)
|
||||||
index.update_document(writer, d1)
|
|
||||||
|
|
||||||
response = self.client.get("/api/documents/?query=added:invalid-date")
|
response = self.client.get("/api/documents/?query=added:invalid-date")
|
||||||
results = response.data["results"]
|
|
||||||
|
|
||||||
# Expect 0 document returned
|
# Tantivy rejects unparsable field queries with a 400
|
||||||
self.assertEqual(len(results), 0)
|
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
|
||||||
|
|
||||||
@mock.patch("documents.index.autocomplete")
|
@mock.patch("documents.search._backend.TantivyBackend.autocomplete")
|
||||||
def test_search_autocomplete_limits(self, m) -> None:
|
def test_search_autocomplete_limits(self, m) -> None:
|
||||||
"""
|
"""
|
||||||
GIVEN:
|
GIVEN:
|
||||||
@@ -556,7 +624,7 @@ class TestDocumentSearchApi(DirectoriesMixin, APITestCase):
|
|||||||
- Limit requests are obeyed
|
- Limit requests are obeyed
|
||||||
"""
|
"""
|
||||||
|
|
||||||
m.side_effect = lambda ix, term, limit, user: [term for _ in range(limit)]
|
m.side_effect = lambda term, limit, user=None: [term for _ in range(limit)]
|
||||||
|
|
||||||
response = self.client.get("/api/search/autocomplete/?term=test")
|
response = self.client.get("/api/search/autocomplete/?term=test")
|
||||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||||
@@ -609,32 +677,29 @@ class TestDocumentSearchApi(DirectoriesMixin, APITestCase):
|
|||||||
owner=u1,
|
owner=u1,
|
||||||
)
|
)
|
||||||
|
|
||||||
with AsyncWriter(index.open_index()) as writer:
|
backend = get_backend()
|
||||||
index.update_document(writer, d1)
|
backend.add_or_update(d1)
|
||||||
index.update_document(writer, d2)
|
backend.add_or_update(d2)
|
||||||
index.update_document(writer, d3)
|
backend.add_or_update(d3)
|
||||||
|
|
||||||
response = self.client.get("/api/search/autocomplete/?term=app")
|
response = self.client.get("/api/search/autocomplete/?term=app")
|
||||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||||
self.assertEqual(response.data, [b"apples", b"applebaum", b"appletini"])
|
self.assertEqual(response.data, ["applebaum", "apples", "appletini"])
|
||||||
|
|
||||||
d3.owner = u2
|
d3.owner = u2
|
||||||
|
d3.save()
|
||||||
with AsyncWriter(index.open_index()) as writer:
|
backend.add_or_update(d3)
|
||||||
index.update_document(writer, d3)
|
|
||||||
|
|
||||||
response = self.client.get("/api/search/autocomplete/?term=app")
|
response = self.client.get("/api/search/autocomplete/?term=app")
|
||||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||||
self.assertEqual(response.data, [b"apples", b"applebaum"])
|
self.assertEqual(response.data, ["applebaum", "apples"])
|
||||||
|
|
||||||
assign_perm("view_document", u1, d3)
|
assign_perm("view_document", u1, d3)
|
||||||
|
backend.add_or_update(d3)
|
||||||
with AsyncWriter(index.open_index()) as writer:
|
|
||||||
index.update_document(writer, d3)
|
|
||||||
|
|
||||||
response = self.client.get("/api/search/autocomplete/?term=app")
|
response = self.client.get("/api/search/autocomplete/?term=app")
|
||||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||||
self.assertEqual(response.data, [b"apples", b"applebaum", b"appletini"])
|
self.assertEqual(response.data, ["applebaum", "apples", "appletini"])
|
||||||
|
|
||||||
def test_search_autocomplete_field_name_match(self) -> None:
|
def test_search_autocomplete_field_name_match(self) -> None:
|
||||||
"""
|
"""
|
||||||
@@ -652,8 +717,7 @@ class TestDocumentSearchApi(DirectoriesMixin, APITestCase):
|
|||||||
checksum="1",
|
checksum="1",
|
||||||
)
|
)
|
||||||
|
|
||||||
with AsyncWriter(index.open_index()) as writer:
|
get_backend().add_or_update(d1)
|
||||||
index.update_document(writer, d1)
|
|
||||||
|
|
||||||
response = self.client.get("/api/search/autocomplete/?term=created:2023")
|
response = self.client.get("/api/search/autocomplete/?term=created:2023")
|
||||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||||
@@ -674,33 +738,36 @@ class TestDocumentSearchApi(DirectoriesMixin, APITestCase):
|
|||||||
checksum="1",
|
checksum="1",
|
||||||
)
|
)
|
||||||
|
|
||||||
with AsyncWriter(index.open_index()) as writer:
|
get_backend().add_or_update(d1)
|
||||||
index.update_document(writer, d1)
|
|
||||||
|
|
||||||
response = self.client.get("/api/search/autocomplete/?term=auto")
|
response = self.client.get("/api/search/autocomplete/?term=auto")
|
||||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||||
self.assertEqual(response.data[0], b"auto")
|
self.assertEqual(response.data[0], "auto")
|
||||||
|
|
||||||
def test_search_spelling_suggestion(self) -> None:
|
def test_search_no_spelling_suggestion(self) -> None:
|
||||||
with AsyncWriter(index.open_index()) as writer:
|
"""
|
||||||
for i in range(55):
|
GIVEN:
|
||||||
doc = Document.objects.create(
|
- Documents exist with various terms
|
||||||
checksum=str(i),
|
WHEN:
|
||||||
pk=i + 1,
|
- Query for documents with any term
|
||||||
title=f"Document {i + 1}",
|
THEN:
|
||||||
content=f"Things document {i + 1}",
|
- corrected_query is always None (Tantivy has no spell correction)
|
||||||
)
|
"""
|
||||||
index.update_document(writer, doc)
|
backend = get_backend()
|
||||||
|
for i in range(5):
|
||||||
|
doc = Document.objects.create(
|
||||||
|
checksum=str(i),
|
||||||
|
pk=i + 1,
|
||||||
|
title=f"Document {i + 1}",
|
||||||
|
content=f"Things document {i + 1}",
|
||||||
|
)
|
||||||
|
backend.add_or_update(doc)
|
||||||
|
|
||||||
response = self.client.get("/api/documents/?query=thing")
|
response = self.client.get("/api/documents/?query=thing")
|
||||||
correction = response.data["corrected_query"]
|
self.assertIsNone(response.data["corrected_query"])
|
||||||
|
|
||||||
self.assertEqual(correction, "things")
|
|
||||||
|
|
||||||
response = self.client.get("/api/documents/?query=things")
|
response = self.client.get("/api/documents/?query=things")
|
||||||
correction = response.data["corrected_query"]
|
self.assertIsNone(response.data["corrected_query"])
|
||||||
|
|
||||||
self.assertEqual(correction, None)
|
|
||||||
|
|
||||||
def test_search_spelling_suggestion_suppressed_for_private_terms(self):
|
def test_search_spelling_suggestion_suppressed_for_private_terms(self):
|
||||||
owner = User.objects.create_user("owner")
|
owner = User.objects.create_user("owner")
|
||||||
@@ -709,24 +776,24 @@ class TestDocumentSearchApi(DirectoriesMixin, APITestCase):
|
|||||||
Permission.objects.get(codename="view_document"),
|
Permission.objects.get(codename="view_document"),
|
||||||
)
|
)
|
||||||
|
|
||||||
with AsyncWriter(index.open_index()) as writer:
|
backend = get_backend()
|
||||||
for i in range(55):
|
for i in range(5):
|
||||||
private_doc = Document.objects.create(
|
private_doc = Document.objects.create(
|
||||||
checksum=f"p{i}",
|
checksum=f"p{i}",
|
||||||
pk=100 + i,
|
pk=100 + i,
|
||||||
title=f"Private Document {i + 1}",
|
title=f"Private Document {i + 1}",
|
||||||
content=f"treasury document {i + 1}",
|
content=f"treasury document {i + 1}",
|
||||||
owner=owner,
|
owner=owner,
|
||||||
)
|
)
|
||||||
visible_doc = Document.objects.create(
|
visible_doc = Document.objects.create(
|
||||||
checksum=f"v{i}",
|
checksum=f"v{i}",
|
||||||
pk=200 + i,
|
pk=200 + i,
|
||||||
title=f"Visible Document {i + 1}",
|
title=f"Visible Document {i + 1}",
|
||||||
content=f"public ledger {i + 1}",
|
content=f"public ledger {i + 1}",
|
||||||
owner=attacker,
|
owner=attacker,
|
||||||
)
|
)
|
||||||
index.update_document(writer, private_doc)
|
backend.add_or_update(private_doc)
|
||||||
index.update_document(writer, visible_doc)
|
backend.add_or_update(visible_doc)
|
||||||
|
|
||||||
self.client.force_authenticate(user=attacker)
|
self.client.force_authenticate(user=attacker)
|
||||||
|
|
||||||
@@ -736,26 +803,6 @@ class TestDocumentSearchApi(DirectoriesMixin, APITestCase):
|
|||||||
self.assertEqual(response.data["count"], 0)
|
self.assertEqual(response.data["count"], 0)
|
||||||
self.assertIsNone(response.data["corrected_query"])
|
self.assertIsNone(response.data["corrected_query"])
|
||||||
|
|
||||||
@mock.patch(
|
|
||||||
"whoosh.searching.Searcher.correct_query",
|
|
||||||
side_effect=Exception("Test error"),
|
|
||||||
)
|
|
||||||
def test_corrected_query_error(self, mock_correct_query) -> None:
|
|
||||||
"""
|
|
||||||
GIVEN:
|
|
||||||
- A query that raises an error on correction
|
|
||||||
WHEN:
|
|
||||||
- API request for search with that query
|
|
||||||
THEN:
|
|
||||||
- The error is logged and the search proceeds
|
|
||||||
"""
|
|
||||||
with self.assertLogs("paperless.index", level="INFO") as cm:
|
|
||||||
response = self.client.get("/api/documents/?query=2025-06-04")
|
|
||||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
|
||||||
error_str = cm.output[0]
|
|
||||||
expected_str = "Error while correcting query '2025-06-04': Test error"
|
|
||||||
self.assertIn(expected_str, error_str)
|
|
||||||
|
|
||||||
def test_search_more_like(self) -> None:
|
def test_search_more_like(self) -> None:
|
||||||
"""
|
"""
|
||||||
GIVEN:
|
GIVEN:
|
||||||
@@ -785,16 +832,16 @@ class TestDocumentSearchApi(DirectoriesMixin, APITestCase):
|
|||||||
checksum="C",
|
checksum="C",
|
||||||
)
|
)
|
||||||
d4 = Document.objects.create(
|
d4 = Document.objects.create(
|
||||||
title="Monty Python & the Holy Grail",
|
title="Quarterly Report",
|
||||||
content="And now for something completely different",
|
content="quarterly revenue profit margin earnings growth",
|
||||||
pk=4,
|
pk=4,
|
||||||
checksum="ABC",
|
checksum="ABC",
|
||||||
)
|
)
|
||||||
with AsyncWriter(index.open_index()) as writer:
|
backend = get_backend()
|
||||||
index.update_document(writer, d1)
|
backend.add_or_update(d1)
|
||||||
index.update_document(writer, d2)
|
backend.add_or_update(d2)
|
||||||
index.update_document(writer, d3)
|
backend.add_or_update(d3)
|
||||||
index.update_document(writer, d4)
|
backend.add_or_update(d4)
|
||||||
|
|
||||||
response = self.client.get(f"/api/documents/?more_like_id={d2.id}")
|
response = self.client.get(f"/api/documents/?more_like_id={d2.id}")
|
||||||
|
|
||||||
@@ -802,9 +849,10 @@ class TestDocumentSearchApi(DirectoriesMixin, APITestCase):
|
|||||||
|
|
||||||
results = response.data["results"]
|
results = response.data["results"]
|
||||||
|
|
||||||
self.assertEqual(len(results), 2)
|
self.assertGreaterEqual(len(results), 1)
|
||||||
self.assertEqual(results[0]["id"], d3.id)
|
result_ids = [r["id"] for r in results]
|
||||||
self.assertEqual(results[1]["id"], d1.id)
|
self.assertIn(d3.id, result_ids)
|
||||||
|
self.assertNotIn(d4.id, result_ids)
|
||||||
|
|
||||||
def test_search_more_like_requires_view_permission_on_seed_document(
|
def test_search_more_like_requires_view_permission_on_seed_document(
|
||||||
self,
|
self,
|
||||||
@@ -846,10 +894,10 @@ class TestDocumentSearchApi(DirectoriesMixin, APITestCase):
|
|||||||
pk=12,
|
pk=12,
|
||||||
)
|
)
|
||||||
|
|
||||||
with AsyncWriter(index.open_index()) as writer:
|
backend = get_backend()
|
||||||
index.update_document(writer, private_seed)
|
backend.add_or_update(private_seed)
|
||||||
index.update_document(writer, visible_doc)
|
backend.add_or_update(visible_doc)
|
||||||
index.update_document(writer, other_doc)
|
backend.add_or_update(other_doc)
|
||||||
|
|
||||||
self.client.force_authenticate(user=attacker)
|
self.client.force_authenticate(user=attacker)
|
||||||
|
|
||||||
@@ -923,9 +971,9 @@ class TestDocumentSearchApi(DirectoriesMixin, APITestCase):
|
|||||||
value_text="foobard4",
|
value_text="foobard4",
|
||||||
)
|
)
|
||||||
|
|
||||||
with AsyncWriter(index.open_index()) as writer:
|
backend = get_backend()
|
||||||
for doc in Document.objects.all():
|
for doc in Document.objects.all():
|
||||||
index.update_document(writer, doc)
|
backend.add_or_update(doc)
|
||||||
|
|
||||||
def search_query(q):
|
def search_query(q):
|
||||||
r = self.client.get("/api/documents/?query=test" + q)
|
r = self.client.get("/api/documents/?query=test" + q)
|
||||||
@@ -1141,9 +1189,9 @@ class TestDocumentSearchApi(DirectoriesMixin, APITestCase):
|
|||||||
Document.objects.create(checksum="3", content="test 3", owner=u2)
|
Document.objects.create(checksum="3", content="test 3", owner=u2)
|
||||||
Document.objects.create(checksum="4", content="test 4")
|
Document.objects.create(checksum="4", content="test 4")
|
||||||
|
|
||||||
with AsyncWriter(index.open_index()) as writer:
|
backend = get_backend()
|
||||||
for doc in Document.objects.all():
|
for doc in Document.objects.all():
|
||||||
index.update_document(writer, doc)
|
backend.add_or_update(doc)
|
||||||
|
|
||||||
self.client.force_authenticate(user=u1)
|
self.client.force_authenticate(user=u1)
|
||||||
r = self.client.get("/api/documents/?query=test")
|
r = self.client.get("/api/documents/?query=test")
|
||||||
@@ -1194,9 +1242,9 @@ class TestDocumentSearchApi(DirectoriesMixin, APITestCase):
|
|||||||
d3 = Document.objects.create(checksum="3", content="test 3", owner=u2)
|
d3 = Document.objects.create(checksum="3", content="test 3", owner=u2)
|
||||||
Document.objects.create(checksum="4", content="test 4")
|
Document.objects.create(checksum="4", content="test 4")
|
||||||
|
|
||||||
with AsyncWriter(index.open_index()) as writer:
|
backend = get_backend()
|
||||||
for doc in Document.objects.all():
|
for doc in Document.objects.all():
|
||||||
index.update_document(writer, doc)
|
backend.add_or_update(doc)
|
||||||
|
|
||||||
self.client.force_authenticate(user=u1)
|
self.client.force_authenticate(user=u1)
|
||||||
r = self.client.get("/api/documents/?query=test")
|
r = self.client.get("/api/documents/?query=test")
|
||||||
@@ -1216,9 +1264,9 @@ class TestDocumentSearchApi(DirectoriesMixin, APITestCase):
|
|||||||
assign_perm("view_document", u1, d3)
|
assign_perm("view_document", u1, d3)
|
||||||
assign_perm("view_document", u2, d1)
|
assign_perm("view_document", u2, d1)
|
||||||
|
|
||||||
with AsyncWriter(index.open_index()) as writer:
|
backend.add_or_update(d1)
|
||||||
for doc in [d1, d2, d3]:
|
backend.add_or_update(d2)
|
||||||
index.update_document(writer, doc)
|
backend.add_or_update(d3)
|
||||||
|
|
||||||
self.client.force_authenticate(user=u1)
|
self.client.force_authenticate(user=u1)
|
||||||
r = self.client.get("/api/documents/?query=test")
|
r = self.client.get("/api/documents/?query=test")
|
||||||
@@ -1281,9 +1329,9 @@ class TestDocumentSearchApi(DirectoriesMixin, APITestCase):
|
|||||||
user=u1,
|
user=u1,
|
||||||
)
|
)
|
||||||
|
|
||||||
with AsyncWriter(index.open_index()) as writer:
|
backend = get_backend()
|
||||||
for doc in Document.objects.all():
|
for doc in Document.objects.all():
|
||||||
index.update_document(writer, doc)
|
backend.add_or_update(doc)
|
||||||
|
|
||||||
def search_query(q):
|
def search_query(q):
|
||||||
r = self.client.get("/api/documents/?query=test" + q)
|
r = self.client.get("/api/documents/?query=test" + q)
|
||||||
@@ -1316,13 +1364,14 @@ class TestDocumentSearchApi(DirectoriesMixin, APITestCase):
|
|||||||
search_query("&ordering=-num_notes"),
|
search_query("&ordering=-num_notes"),
|
||||||
[d1.id, d3.id, d2.id],
|
[d1.id, d3.id, d2.id],
|
||||||
)
|
)
|
||||||
|
# owner sort: ORM orders by owner_id (integer); NULLs first in SQLite ASC
|
||||||
self.assertListEqual(
|
self.assertListEqual(
|
||||||
search_query("&ordering=owner"),
|
search_query("&ordering=owner"),
|
||||||
[d1.id, d2.id, d3.id],
|
[d3.id, d1.id, d2.id],
|
||||||
)
|
)
|
||||||
self.assertListEqual(
|
self.assertListEqual(
|
||||||
search_query("&ordering=-owner"),
|
search_query("&ordering=-owner"),
|
||||||
[d3.id, d2.id, d1.id],
|
[d2.id, d1.id, d3.id],
|
||||||
)
|
)
|
||||||
|
|
||||||
@mock.patch("documents.bulk_edit.bulk_update_documents")
|
@mock.patch("documents.bulk_edit.bulk_update_documents")
|
||||||
@@ -1379,12 +1428,12 @@ class TestDocumentSearchApi(DirectoriesMixin, APITestCase):
|
|||||||
)
|
)
|
||||||
set_permissions([4, 5], set_permissions={}, owner=user2, merge=False)
|
set_permissions([4, 5], set_permissions={}, owner=user2, merge=False)
|
||||||
|
|
||||||
with index.open_index_writer() as writer:
|
backend = get_backend()
|
||||||
index.update_document(writer, d1)
|
backend.add_or_update(d1)
|
||||||
index.update_document(writer, d2)
|
backend.add_or_update(d2)
|
||||||
index.update_document(writer, d3)
|
backend.add_or_update(d3)
|
||||||
index.update_document(writer, d4)
|
backend.add_or_update(d4)
|
||||||
index.update_document(writer, d5)
|
backend.add_or_update(d5)
|
||||||
|
|
||||||
correspondent1 = Correspondent.objects.create(name="bank correspondent 1")
|
correspondent1 = Correspondent.objects.create(name="bank correspondent 1")
|
||||||
Correspondent.objects.create(name="correspondent 2")
|
Correspondent.objects.create(name="correspondent 2")
|
||||||
|
|||||||
@@ -191,40 +191,42 @@ class TestSystemStatus(APITestCase):
|
|||||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||||
self.assertEqual(response.data["tasks"]["celery_status"], "OK")
|
self.assertEqual(response.data["tasks"]["celery_status"], "OK")
|
||||||
|
|
||||||
@override_settings(INDEX_DIR=Path("/tmp/index"))
|
@mock.patch("documents.search.get_backend")
|
||||||
@mock.patch("whoosh.index.FileIndex.last_modified")
|
def test_system_status_index_ok(self, mock_get_backend) -> None:
|
||||||
def test_system_status_index_ok(self, mock_last_modified) -> None:
|
|
||||||
"""
|
"""
|
||||||
GIVEN:
|
GIVEN:
|
||||||
- The index last modified time is set
|
- The index is accessible
|
||||||
WHEN:
|
WHEN:
|
||||||
- The user requests the system status
|
- The user requests the system status
|
||||||
THEN:
|
THEN:
|
||||||
- The response contains the correct index status
|
- The response contains the correct index status
|
||||||
"""
|
"""
|
||||||
mock_last_modified.return_value = 1707839087
|
mock_get_backend.return_value = mock.MagicMock()
|
||||||
self.client.force_login(self.user)
|
# Use the temp dir created in setUp (self.tmp_dir) as a real INDEX_DIR
|
||||||
response = self.client.get(self.ENDPOINT)
|
# with a real file so the mtime lookup works
|
||||||
|
sentinel = self.tmp_dir / "sentinel.txt"
|
||||||
|
sentinel.write_text("ok")
|
||||||
|
with self.settings(INDEX_DIR=self.tmp_dir):
|
||||||
|
self.client.force_login(self.user)
|
||||||
|
response = self.client.get(self.ENDPOINT)
|
||||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||||
self.assertEqual(response.data["tasks"]["index_status"], "OK")
|
self.assertEqual(response.data["tasks"]["index_status"], "OK")
|
||||||
self.assertIsNotNone(response.data["tasks"]["index_last_modified"])
|
self.assertIsNotNone(response.data["tasks"]["index_last_modified"])
|
||||||
|
|
||||||
@override_settings(INDEX_DIR=Path("/tmp/index/"))
|
@mock.patch("documents.search.get_backend")
|
||||||
@mock.patch("documents.index.open_index", autospec=True)
|
def test_system_status_index_error(self, mock_get_backend) -> None:
|
||||||
def test_system_status_index_error(self, mock_open_index) -> None:
|
|
||||||
"""
|
"""
|
||||||
GIVEN:
|
GIVEN:
|
||||||
- The index is not found
|
- The index cannot be opened
|
||||||
WHEN:
|
WHEN:
|
||||||
- The user requests the system status
|
- The user requests the system status
|
||||||
THEN:
|
THEN:
|
||||||
- The response contains the correct index status
|
- The response contains the correct index status
|
||||||
"""
|
"""
|
||||||
mock_open_index.return_value = None
|
mock_get_backend.side_effect = Exception("Index error")
|
||||||
mock_open_index.side_effect = Exception("Index error")
|
|
||||||
self.client.force_login(self.user)
|
self.client.force_login(self.user)
|
||||||
response = self.client.get(self.ENDPOINT)
|
response = self.client.get(self.ENDPOINT)
|
||||||
mock_open_index.assert_called_once()
|
mock_get_backend.assert_called_once()
|
||||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||||
self.assertEqual(response.data["tasks"]["index_status"], "ERROR")
|
self.assertEqual(response.data["tasks"]["index_status"], "ERROR")
|
||||||
self.assertIsNotNone(response.data["tasks"]["index_error"])
|
self.assertIsNotNone(response.data["tasks"]["index_error"])
|
||||||
|
|||||||
@@ -1,58 +0,0 @@
|
|||||||
from django.test import TestCase
|
|
||||||
from whoosh import query
|
|
||||||
|
|
||||||
from documents.index import get_permissions_criterias
|
|
||||||
from documents.models import User
|
|
||||||
|
|
||||||
|
|
||||||
class TestDelayedQuery(TestCase):
|
|
||||||
def setUp(self) -> None:
|
|
||||||
super().setUp()
|
|
||||||
# all tests run without permission criteria, so has_no_owner query will always
|
|
||||||
# be appended.
|
|
||||||
self.has_no_owner = query.Or([query.Term("has_owner", text=False)])
|
|
||||||
|
|
||||||
def _get_testset__id__in(self, param, field):
|
|
||||||
return (
|
|
||||||
{f"{param}__id__in": "42,43"},
|
|
||||||
query.And(
|
|
||||||
[
|
|
||||||
query.Or(
|
|
||||||
[
|
|
||||||
query.Term(f"{field}_id", "42"),
|
|
||||||
query.Term(f"{field}_id", "43"),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
self.has_no_owner,
|
|
||||||
],
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
def _get_testset__id__none(self, param, field):
|
|
||||||
return (
|
|
||||||
{f"{param}__id__none": "42,43"},
|
|
||||||
query.And(
|
|
||||||
[
|
|
||||||
query.Not(query.Term(f"{field}_id", "42")),
|
|
||||||
query.Not(query.Term(f"{field}_id", "43")),
|
|
||||||
self.has_no_owner,
|
|
||||||
],
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
def test_get_permission_criteria(self) -> None:
|
|
||||||
# tests contains tuples of user instances and the expected filter
|
|
||||||
tests = (
|
|
||||||
(None, [query.Term("has_owner", text=False)]),
|
|
||||||
(User(42, username="foo", is_superuser=True), []),
|
|
||||||
(
|
|
||||||
User(42, username="foo", is_superuser=False),
|
|
||||||
[
|
|
||||||
query.Term("has_owner", text=False),
|
|
||||||
query.Term("owner_id", 42),
|
|
||||||
query.Term("viewer_id", "42"),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
)
|
|
||||||
for user, expected in tests:
|
|
||||||
self.assertEqual(get_permissions_criterias(user), expected)
|
|
||||||
@@ -1,371 +0,0 @@
|
|||||||
from datetime import datetime
|
|
||||||
from unittest import mock
|
|
||||||
|
|
||||||
from django.conf import settings
|
|
||||||
from django.contrib.auth.models import User
|
|
||||||
from django.test import SimpleTestCase
|
|
||||||
from django.test import TestCase
|
|
||||||
from django.test import override_settings
|
|
||||||
from django.utils.timezone import get_current_timezone
|
|
||||||
from django.utils.timezone import timezone
|
|
||||||
|
|
||||||
from documents import index
|
|
||||||
from documents.models import Document
|
|
||||||
from documents.tests.utils import DirectoriesMixin
|
|
||||||
|
|
||||||
|
|
||||||
class TestAutoComplete(DirectoriesMixin, TestCase):
|
|
||||||
def test_auto_complete(self) -> None:
|
|
||||||
doc1 = Document.objects.create(
|
|
||||||
title="doc1",
|
|
||||||
checksum="A",
|
|
||||||
content="test test2 test3",
|
|
||||||
)
|
|
||||||
doc2 = Document.objects.create(title="doc2", checksum="B", content="test test2")
|
|
||||||
doc3 = Document.objects.create(title="doc3", checksum="C", content="test2")
|
|
||||||
|
|
||||||
index.add_or_update_document(doc1)
|
|
||||||
index.add_or_update_document(doc2)
|
|
||||||
index.add_or_update_document(doc3)
|
|
||||||
|
|
||||||
ix = index.open_index()
|
|
||||||
|
|
||||||
self.assertListEqual(
|
|
||||||
index.autocomplete(ix, "tes"),
|
|
||||||
[b"test2", b"test", b"test3"],
|
|
||||||
)
|
|
||||||
self.assertListEqual(
|
|
||||||
index.autocomplete(ix, "tes", limit=3),
|
|
||||||
[b"test2", b"test", b"test3"],
|
|
||||||
)
|
|
||||||
self.assertListEqual(index.autocomplete(ix, "tes", limit=1), [b"test2"])
|
|
||||||
self.assertListEqual(index.autocomplete(ix, "tes", limit=0), [])
|
|
||||||
|
|
||||||
def test_archive_serial_number_ranging(self) -> None:
|
|
||||||
"""
|
|
||||||
GIVEN:
|
|
||||||
- Document with an archive serial number above schema allowed size
|
|
||||||
WHEN:
|
|
||||||
- Document is provided to the index
|
|
||||||
THEN:
|
|
||||||
- Error is logged
|
|
||||||
- Document ASN is reset to 0 for the index
|
|
||||||
"""
|
|
||||||
doc1 = Document.objects.create(
|
|
||||||
title="doc1",
|
|
||||||
checksum="A",
|
|
||||||
content="test test2 test3",
|
|
||||||
# yes, this is allowed, unless full_clean is run
|
|
||||||
# DRF does call the validators, this test won't
|
|
||||||
archive_serial_number=Document.ARCHIVE_SERIAL_NUMBER_MAX + 1,
|
|
||||||
)
|
|
||||||
with self.assertLogs("paperless.index", level="ERROR") as cm:
|
|
||||||
with mock.patch(
|
|
||||||
"documents.index.AsyncWriter.update_document",
|
|
||||||
) as mocked_update_doc:
|
|
||||||
index.add_or_update_document(doc1)
|
|
||||||
|
|
||||||
mocked_update_doc.assert_called_once()
|
|
||||||
_, kwargs = mocked_update_doc.call_args
|
|
||||||
|
|
||||||
self.assertEqual(kwargs["asn"], 0)
|
|
||||||
|
|
||||||
error_str = cm.output[0]
|
|
||||||
expected_str = "ERROR:paperless.index:Not indexing Archive Serial Number 4294967296 of document 1"
|
|
||||||
self.assertIn(expected_str, error_str)
|
|
||||||
|
|
||||||
def test_archive_serial_number_is_none(self) -> None:
|
|
||||||
"""
|
|
||||||
GIVEN:
|
|
||||||
- Document with no archive serial number
|
|
||||||
WHEN:
|
|
||||||
- Document is provided to the index
|
|
||||||
THEN:
|
|
||||||
- ASN isn't touched
|
|
||||||
"""
|
|
||||||
doc1 = Document.objects.create(
|
|
||||||
title="doc1",
|
|
||||||
checksum="A",
|
|
||||||
content="test test2 test3",
|
|
||||||
)
|
|
||||||
with mock.patch(
|
|
||||||
"documents.index.AsyncWriter.update_document",
|
|
||||||
) as mocked_update_doc:
|
|
||||||
index.add_or_update_document(doc1)
|
|
||||||
|
|
||||||
mocked_update_doc.assert_called_once()
|
|
||||||
_, kwargs = mocked_update_doc.call_args
|
|
||||||
|
|
||||||
self.assertIsNone(kwargs["asn"])
|
|
||||||
|
|
||||||
@override_settings(TIME_ZONE="Pacific/Auckland")
|
|
||||||
def test_added_today_respects_local_timezone_boundary(self) -> None:
|
|
||||||
tz = get_current_timezone()
|
|
||||||
fixed_now = datetime(2025, 7, 20, 15, 0, 0, tzinfo=tz)
|
|
||||||
|
|
||||||
# Fake a time near the local boundary (1 AM NZT = 13:00 UTC on previous UTC day)
|
|
||||||
local_dt = datetime(2025, 7, 20, 1, 0, 0).replace(tzinfo=tz)
|
|
||||||
utc_dt = local_dt.astimezone(timezone.utc)
|
|
||||||
|
|
||||||
doc = Document.objects.create(
|
|
||||||
title="Time zone",
|
|
||||||
content="Testing added:today",
|
|
||||||
checksum="edgecase123",
|
|
||||||
added=utc_dt,
|
|
||||||
)
|
|
||||||
|
|
||||||
with index.open_index_writer() as writer:
|
|
||||||
index.update_document(writer, doc)
|
|
||||||
|
|
||||||
superuser = User.objects.create_superuser(username="testuser")
|
|
||||||
self.client.force_login(superuser)
|
|
||||||
|
|
||||||
with mock.patch("documents.index.now", return_value=fixed_now):
|
|
||||||
response = self.client.get("/api/documents/?query=added:today")
|
|
||||||
results = response.json()["results"]
|
|
||||||
self.assertEqual(len(results), 1)
|
|
||||||
self.assertEqual(results[0]["id"], doc.id)
|
|
||||||
|
|
||||||
response = self.client.get("/api/documents/?query=added:yesterday")
|
|
||||||
results = response.json()["results"]
|
|
||||||
self.assertEqual(len(results), 0)
|
|
||||||
|
|
||||||
|
|
||||||
@override_settings(TIME_ZONE="UTC")
|
|
||||||
class TestRewriteNaturalDateKeywords(SimpleTestCase):
|
|
||||||
"""
|
|
||||||
Unit tests for rewrite_natural_date_keywords function.
|
|
||||||
"""
|
|
||||||
|
|
||||||
def _rewrite_with_now(self, query: str, now_dt: datetime) -> str:
|
|
||||||
with mock.patch("documents.index.now", return_value=now_dt):
|
|
||||||
return index.rewrite_natural_date_keywords(query)
|
|
||||||
|
|
||||||
def _assert_rewrite_contains(
|
|
||||||
self,
|
|
||||||
query: str,
|
|
||||||
now_dt: datetime,
|
|
||||||
*expected_fragments: str,
|
|
||||||
) -> str:
|
|
||||||
result = self._rewrite_with_now(query, now_dt)
|
|
||||||
for fragment in expected_fragments:
|
|
||||||
self.assertIn(fragment, result)
|
|
||||||
return result
|
|
||||||
|
|
||||||
def test_range_keywords(self) -> None:
|
|
||||||
"""
|
|
||||||
Test various different range keywords
|
|
||||||
"""
|
|
||||||
cases = [
|
|
||||||
(
|
|
||||||
"added:today",
|
|
||||||
datetime(2025, 7, 20, 15, 30, 45, tzinfo=timezone.utc),
|
|
||||||
("added:[20250720", "TO 20250720"),
|
|
||||||
),
|
|
||||||
(
|
|
||||||
"added:yesterday",
|
|
||||||
datetime(2025, 7, 20, 15, 30, 45, tzinfo=timezone.utc),
|
|
||||||
("added:[20250719", "TO 20250719"),
|
|
||||||
),
|
|
||||||
(
|
|
||||||
"added:this month",
|
|
||||||
datetime(2025, 7, 15, 12, 0, 0, tzinfo=timezone.utc),
|
|
||||||
("added:[20250701", "TO 20250731"),
|
|
||||||
),
|
|
||||||
(
|
|
||||||
"added:previous month",
|
|
||||||
datetime(2025, 7, 15, 12, 0, 0, tzinfo=timezone.utc),
|
|
||||||
("added:[20250601", "TO 20250630"),
|
|
||||||
),
|
|
||||||
(
|
|
||||||
"added:this year",
|
|
||||||
datetime(2025, 7, 15, 12, 0, 0, tzinfo=timezone.utc),
|
|
||||||
("added:[20250101", "TO 20251231"),
|
|
||||||
),
|
|
||||||
(
|
|
||||||
"added:previous year",
|
|
||||||
datetime(2025, 7, 15, 12, 0, 0, tzinfo=timezone.utc),
|
|
||||||
("added:[20240101", "TO 20241231"),
|
|
||||||
),
|
|
||||||
# Previous quarter from July 15, 2025 is April-June.
|
|
||||||
(
|
|
||||||
"added:previous quarter",
|
|
||||||
datetime(2025, 7, 15, 12, 0, 0, tzinfo=timezone.utc),
|
|
||||||
("added:[20250401", "TO 20250630"),
|
|
||||||
),
|
|
||||||
# July 20, 2025 is a Sunday (weekday 6) so previous week is July 7-13.
|
|
||||||
(
|
|
||||||
"added:previous week",
|
|
||||||
datetime(2025, 7, 20, 12, 0, 0, tzinfo=timezone.utc),
|
|
||||||
("added:[20250707", "TO 20250713"),
|
|
||||||
),
|
|
||||||
]
|
|
||||||
|
|
||||||
for query, now_dt, fragments in cases:
|
|
||||||
with self.subTest(query=query):
|
|
||||||
self._assert_rewrite_contains(query, now_dt, *fragments)
|
|
||||||
|
|
||||||
def test_additional_fields(self) -> None:
|
|
||||||
fixed_now = datetime(2025, 7, 20, 15, 30, 45, tzinfo=timezone.utc)
|
|
||||||
# created
|
|
||||||
self._assert_rewrite_contains("created:today", fixed_now, "created:[20250720")
|
|
||||||
# modified
|
|
||||||
self._assert_rewrite_contains("modified:today", fixed_now, "modified:[20250720")
|
|
||||||
|
|
||||||
def test_basic_syntax_variants(self) -> None:
|
|
||||||
"""
|
|
||||||
Test that quoting, casing, and multi-clause queries are parsed.
|
|
||||||
"""
|
|
||||||
fixed_now = datetime(2025, 7, 20, 15, 30, 45, tzinfo=timezone.utc)
|
|
||||||
|
|
||||||
# quoted keywords
|
|
||||||
result1 = self._rewrite_with_now('added:"today"', fixed_now)
|
|
||||||
result2 = self._rewrite_with_now("added:'today'", fixed_now)
|
|
||||||
self.assertIn("added:[20250720", result1)
|
|
||||||
self.assertIn("added:[20250720", result2)
|
|
||||||
|
|
||||||
# case insensitivity
|
|
||||||
for query in ("added:TODAY", "added:Today", "added:ToDaY"):
|
|
||||||
with self.subTest(case_variant=query):
|
|
||||||
self._assert_rewrite_contains(query, fixed_now, "added:[20250720")
|
|
||||||
|
|
||||||
# multiple clauses
|
|
||||||
result = self._rewrite_with_now("added:today created:yesterday", fixed_now)
|
|
||||||
self.assertIn("added:[20250720", result)
|
|
||||||
self.assertIn("created:[20250719", result)
|
|
||||||
|
|
||||||
def test_no_match(self) -> None:
|
|
||||||
"""
|
|
||||||
Test that queries without keywords are unchanged.
|
|
||||||
"""
|
|
||||||
query = "title:test content:example"
|
|
||||||
result = index.rewrite_natural_date_keywords(query)
|
|
||||||
self.assertEqual(query, result)
|
|
||||||
|
|
||||||
@override_settings(TIME_ZONE="Pacific/Auckland")
|
|
||||||
def test_timezone_awareness(self) -> None:
|
|
||||||
"""
|
|
||||||
Test timezone conversion.
|
|
||||||
"""
|
|
||||||
# July 20, 2025 1:00 AM NZST = July 19, 2025 13:00 UTC
|
|
||||||
fixed_now = datetime(2025, 7, 20, 1, 0, 0, tzinfo=get_current_timezone())
|
|
||||||
result = self._rewrite_with_now("added:today", fixed_now)
|
|
||||||
# Should convert to UTC properly
|
|
||||||
self.assertIn("added:[20250719", result)
|
|
||||||
|
|
||||||
|
|
||||||
class TestIndexResilience(DirectoriesMixin, SimpleTestCase):
|
|
||||||
def _assert_recreate_called(self, mock_create_in) -> None:
|
|
||||||
mock_create_in.assert_called_once()
|
|
||||||
path_arg, schema_arg = mock_create_in.call_args.args
|
|
||||||
self.assertEqual(path_arg, settings.INDEX_DIR)
|
|
||||||
self.assertEqual(schema_arg.__class__.__name__, "Schema")
|
|
||||||
|
|
||||||
def test_transient_missing_segment_does_not_force_recreate(self) -> None:
|
|
||||||
"""
|
|
||||||
GIVEN:
|
|
||||||
- Index directory exists
|
|
||||||
WHEN:
|
|
||||||
- open_index is called
|
|
||||||
- Opening the index raises FileNotFoundError once due to a
|
|
||||||
transient missing segment
|
|
||||||
THEN:
|
|
||||||
- Index is opened successfully on retry
|
|
||||||
- Index is not recreated
|
|
||||||
"""
|
|
||||||
file_marker = settings.INDEX_DIR / "file_marker.txt"
|
|
||||||
file_marker.write_text("keep")
|
|
||||||
expected_index = object()
|
|
||||||
|
|
||||||
with (
|
|
||||||
mock.patch("documents.index.exists_in", return_value=True),
|
|
||||||
mock.patch(
|
|
||||||
"documents.index.open_dir",
|
|
||||||
side_effect=[FileNotFoundError("missing"), expected_index],
|
|
||||||
) as mock_open_dir,
|
|
||||||
mock.patch(
|
|
||||||
"documents.index.create_in",
|
|
||||||
) as mock_create_in,
|
|
||||||
mock.patch(
|
|
||||||
"documents.index.rmtree",
|
|
||||||
) as mock_rmtree,
|
|
||||||
):
|
|
||||||
ix = index.open_index()
|
|
||||||
|
|
||||||
self.assertIs(ix, expected_index)
|
|
||||||
self.assertGreaterEqual(mock_open_dir.call_count, 2)
|
|
||||||
mock_rmtree.assert_not_called()
|
|
||||||
mock_create_in.assert_not_called()
|
|
||||||
self.assertEqual(file_marker.read_text(), "keep")
|
|
||||||
|
|
||||||
def test_transient_errors_exhaust_retries_and_recreate(self) -> None:
|
|
||||||
"""
|
|
||||||
GIVEN:
|
|
||||||
- Index directory exists
|
|
||||||
WHEN:
|
|
||||||
- open_index is called
|
|
||||||
- Opening the index raises FileNotFoundError multiple times due to
|
|
||||||
transient missing segments
|
|
||||||
THEN:
|
|
||||||
- Index is recreated after retries are exhausted
|
|
||||||
"""
|
|
||||||
recreated_index = object()
|
|
||||||
|
|
||||||
with (
|
|
||||||
self.assertLogs("paperless.index", level="ERROR") as cm,
|
|
||||||
mock.patch("documents.index.exists_in", return_value=True),
|
|
||||||
mock.patch(
|
|
||||||
"documents.index.open_dir",
|
|
||||||
side_effect=FileNotFoundError("missing"),
|
|
||||||
) as mock_open_dir,
|
|
||||||
mock.patch("documents.index.rmtree") as mock_rmtree,
|
|
||||||
mock.patch(
|
|
||||||
"documents.index.create_in",
|
|
||||||
return_value=recreated_index,
|
|
||||||
) as mock_create_in,
|
|
||||||
):
|
|
||||||
ix = index.open_index()
|
|
||||||
|
|
||||||
self.assertIs(ix, recreated_index)
|
|
||||||
self.assertEqual(mock_open_dir.call_count, 4)
|
|
||||||
mock_rmtree.assert_called_once_with(settings.INDEX_DIR)
|
|
||||||
self._assert_recreate_called(mock_create_in)
|
|
||||||
self.assertIn(
|
|
||||||
"Error while opening the index after retries, recreating.",
|
|
||||||
cm.output[0],
|
|
||||||
)
|
|
||||||
|
|
||||||
def test_non_transient_error_recreates_index(self) -> None:
|
|
||||||
"""
|
|
||||||
GIVEN:
|
|
||||||
- Index directory exists
|
|
||||||
WHEN:
|
|
||||||
- open_index is called
|
|
||||||
- Opening the index raises a "non-transient" error
|
|
||||||
THEN:
|
|
||||||
- Index is recreated
|
|
||||||
"""
|
|
||||||
recreated_index = object()
|
|
||||||
|
|
||||||
with (
|
|
||||||
self.assertLogs("paperless.index", level="ERROR") as cm,
|
|
||||||
mock.patch("documents.index.exists_in", return_value=True),
|
|
||||||
mock.patch(
|
|
||||||
"documents.index.open_dir",
|
|
||||||
side_effect=RuntimeError("boom"),
|
|
||||||
),
|
|
||||||
mock.patch("documents.index.rmtree") as mock_rmtree,
|
|
||||||
mock.patch(
|
|
||||||
"documents.index.create_in",
|
|
||||||
return_value=recreated_index,
|
|
||||||
) as mock_create_in,
|
|
||||||
):
|
|
||||||
ix = index.open_index()
|
|
||||||
|
|
||||||
self.assertIs(ix, recreated_index)
|
|
||||||
mock_rmtree.assert_called_once_with(settings.INDEX_DIR)
|
|
||||||
self._assert_recreate_called(mock_create_in)
|
|
||||||
self.assertIn(
|
|
||||||
"Error while opening the index, recreating.",
|
|
||||||
cm.output[0],
|
|
||||||
)
|
|
||||||
@@ -103,16 +103,75 @@ class TestArchiver(DirectoriesMixin, FileSystemAssertsMixin, TestCase):
|
|||||||
|
|
||||||
|
|
||||||
@pytest.mark.management
|
@pytest.mark.management
|
||||||
class TestMakeIndex(TestCase):
|
@pytest.mark.django_db
|
||||||
@mock.patch("documents.management.commands.document_index.index_reindex")
|
class TestMakeIndex:
|
||||||
def test_reindex(self, m) -> None:
|
def test_reindex(self, mocker: MockerFixture) -> None:
|
||||||
|
"""Reindex command must call the backend rebuild method to recreate the index."""
|
||||||
|
mock_get_backend = mocker.patch(
|
||||||
|
"documents.management.commands.document_index.get_backend",
|
||||||
|
)
|
||||||
call_command("document_index", "reindex", skip_checks=True)
|
call_command("document_index", "reindex", skip_checks=True)
|
||||||
m.assert_called_once()
|
mock_get_backend.return_value.rebuild.assert_called_once()
|
||||||
|
|
||||||
@mock.patch("documents.management.commands.document_index.index_optimize")
|
def test_optimize(self) -> None:
|
||||||
def test_optimize(self, m) -> None:
|
"""Optimize command must execute without error (Tantivy handles optimization automatically)."""
|
||||||
call_command("document_index", "optimize", skip_checks=True)
|
call_command("document_index", "optimize", skip_checks=True)
|
||||||
m.assert_called_once()
|
|
||||||
|
def test_reindex_recreate_wipes_index(self, mocker: MockerFixture) -> None:
|
||||||
|
"""Reindex with --recreate must wipe the index before rebuilding."""
|
||||||
|
mock_wipe = mocker.patch(
|
||||||
|
"documents.management.commands.document_index.wipe_index",
|
||||||
|
)
|
||||||
|
mock_get_backend = mocker.patch(
|
||||||
|
"documents.management.commands.document_index.get_backend",
|
||||||
|
)
|
||||||
|
call_command("document_index", "reindex", recreate=True, skip_checks=True)
|
||||||
|
mock_wipe.assert_called_once()
|
||||||
|
mock_get_backend.return_value.rebuild.assert_called_once()
|
||||||
|
|
||||||
|
def test_reindex_without_recreate_does_not_wipe_index(
|
||||||
|
self,
|
||||||
|
mocker: MockerFixture,
|
||||||
|
) -> None:
|
||||||
|
"""Reindex without --recreate must not wipe the index."""
|
||||||
|
mock_wipe = mocker.patch(
|
||||||
|
"documents.management.commands.document_index.wipe_index",
|
||||||
|
)
|
||||||
|
mocker.patch(
|
||||||
|
"documents.management.commands.document_index.get_backend",
|
||||||
|
)
|
||||||
|
call_command("document_index", "reindex", skip_checks=True)
|
||||||
|
mock_wipe.assert_not_called()
|
||||||
|
|
||||||
|
def test_reindex_if_needed_skips_when_up_to_date(
|
||||||
|
self,
|
||||||
|
mocker: MockerFixture,
|
||||||
|
) -> None:
|
||||||
|
"""Conditional reindex must skip rebuild when schema version and language match."""
|
||||||
|
mocker.patch(
|
||||||
|
"documents.management.commands.document_index.needs_rebuild",
|
||||||
|
return_value=False,
|
||||||
|
)
|
||||||
|
mock_get_backend = mocker.patch(
|
||||||
|
"documents.management.commands.document_index.get_backend",
|
||||||
|
)
|
||||||
|
call_command("document_index", "reindex", if_needed=True, skip_checks=True)
|
||||||
|
mock_get_backend.return_value.rebuild.assert_not_called()
|
||||||
|
|
||||||
|
def test_reindex_if_needed_runs_when_rebuild_needed(
|
||||||
|
self,
|
||||||
|
mocker: MockerFixture,
|
||||||
|
) -> None:
|
||||||
|
"""Conditional reindex must proceed with rebuild when schema version or language changed."""
|
||||||
|
mocker.patch(
|
||||||
|
"documents.management.commands.document_index.needs_rebuild",
|
||||||
|
return_value=True,
|
||||||
|
)
|
||||||
|
mock_get_backend = mocker.patch(
|
||||||
|
"documents.management.commands.document_index.get_backend",
|
||||||
|
)
|
||||||
|
call_command("document_index", "reindex", if_needed=True, skip_checks=True)
|
||||||
|
mock_get_backend.return_value.rebuild.assert_called_once()
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.management
|
@pytest.mark.management
|
||||||
|
|||||||
@@ -389,7 +389,7 @@ class TestExportImport(
|
|||||||
self.assertIsFile(
|
self.assertIsFile(
|
||||||
str(self.target / doc_from_manifest[EXPORTER_FILE_NAME]),
|
str(self.target / doc_from_manifest[EXPORTER_FILE_NAME]),
|
||||||
)
|
)
|
||||||
self.d3.delete()
|
self.d3.hard_delete()
|
||||||
|
|
||||||
manifest = self._do_export()
|
manifest = self._do_export()
|
||||||
self.assertRaises(
|
self.assertRaises(
|
||||||
@@ -868,6 +868,52 @@ class TestExportImport(
|
|||||||
for obj in manifest:
|
for obj in manifest:
|
||||||
self.assertNotEqual(obj["model"], "auditlog.logentry")
|
self.assertNotEqual(obj["model"], "auditlog.logentry")
|
||||||
|
|
||||||
|
def test_export_import_soft_deleted_document(self) -> None:
|
||||||
|
"""
|
||||||
|
GIVEN:
|
||||||
|
- A document with a note and custom field instance has been soft-deleted
|
||||||
|
WHEN:
|
||||||
|
- Export and re-import are performed
|
||||||
|
THEN:
|
||||||
|
- The soft-deleted document, note, and custom field instance
|
||||||
|
survive the round-trip with deleted_at preserved
|
||||||
|
"""
|
||||||
|
shutil.rmtree(Path(self.dirs.media_dir) / "documents")
|
||||||
|
shutil.copytree(
|
||||||
|
Path(__file__).parent / "samples" / "documents",
|
||||||
|
Path(self.dirs.media_dir) / "documents",
|
||||||
|
)
|
||||||
|
|
||||||
|
# d1 has self.note and self.cfi1 attached via setUp
|
||||||
|
self.d1.delete()
|
||||||
|
|
||||||
|
self._do_export()
|
||||||
|
|
||||||
|
with paperless_environment():
|
||||||
|
Document.global_objects.all().hard_delete()
|
||||||
|
Correspondent.objects.all().delete()
|
||||||
|
DocumentType.objects.all().delete()
|
||||||
|
Tag.objects.all().delete()
|
||||||
|
|
||||||
|
call_command(
|
||||||
|
"document_importer",
|
||||||
|
"--no-progress-bar",
|
||||||
|
self.target,
|
||||||
|
skip_checks=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(Document.global_objects.count(), 4)
|
||||||
|
reimported_doc = Document.global_objects.get(pk=self.d1.pk)
|
||||||
|
self.assertIsNotNone(reimported_doc.deleted_at)
|
||||||
|
|
||||||
|
self.assertEqual(Note.global_objects.count(), 1)
|
||||||
|
reimported_note = Note.global_objects.get(pk=self.note.pk)
|
||||||
|
self.assertIsNotNone(reimported_note.deleted_at)
|
||||||
|
|
||||||
|
self.assertEqual(CustomFieldInstance.global_objects.count(), 1)
|
||||||
|
reimported_cfi = CustomFieldInstance.global_objects.get(pk=self.cfi1.pk)
|
||||||
|
self.assertIsNotNone(reimported_cfi.deleted_at)
|
||||||
|
|
||||||
def test_export_data_only(self) -> None:
|
def test_export_data_only(self) -> None:
|
||||||
"""
|
"""
|
||||||
GIVEN:
|
GIVEN:
|
||||||
|
|||||||
@@ -452,7 +452,10 @@ class TestDocumentConsumptionFinishedSignal(TestCase):
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
def setUp(self) -> None:
|
def setUp(self) -> None:
|
||||||
|
from documents.search import reset_backend
|
||||||
|
|
||||||
TestCase.setUp(self)
|
TestCase.setUp(self)
|
||||||
|
reset_backend()
|
||||||
User.objects.create_user(username="test_consumer", password="12345")
|
User.objects.create_user(username="test_consumer", password="12345")
|
||||||
self.doc_contains = Document.objects.create(
|
self.doc_contains = Document.objects.create(
|
||||||
content="I contain the keyword.",
|
content="I contain the keyword.",
|
||||||
@@ -464,6 +467,9 @@ class TestDocumentConsumptionFinishedSignal(TestCase):
|
|||||||
override_settings(INDEX_DIR=self.index_dir).enable()
|
override_settings(INDEX_DIR=self.index_dir).enable()
|
||||||
|
|
||||||
def tearDown(self) -> None:
|
def tearDown(self) -> None:
|
||||||
|
from documents.search import reset_backend
|
||||||
|
|
||||||
|
reset_backend()
|
||||||
shutil.rmtree(self.index_dir, ignore_errors=True)
|
shutil.rmtree(self.index_dir, ignore_errors=True)
|
||||||
|
|
||||||
def test_tag_applied_any(self) -> None:
|
def test_tag_applied_any(self) -> None:
|
||||||
|
|||||||
@@ -11,10 +11,12 @@ from documents.models import WorkflowAction
|
|||||||
from documents.models import WorkflowTrigger
|
from documents.models import WorkflowTrigger
|
||||||
from documents.serialisers import TagSerializer
|
from documents.serialisers import TagSerializer
|
||||||
from documents.signals.handlers import run_workflows
|
from documents.signals.handlers import run_workflows
|
||||||
|
from documents.tests.utils import DirectoriesMixin
|
||||||
|
|
||||||
|
|
||||||
class TestTagHierarchy(APITestCase):
|
class TestTagHierarchy(DirectoriesMixin, APITestCase):
|
||||||
def setUp(self) -> None:
|
def setUp(self) -> None:
|
||||||
|
super().setUp()
|
||||||
self.user = User.objects.create_superuser(username="admin")
|
self.user = User.objects.create_superuser(username="admin")
|
||||||
self.client.force_authenticate(user=self.user)
|
self.client.force_authenticate(user=self.user)
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import uuid
|
|||||||
from unittest import mock
|
from unittest import mock
|
||||||
|
|
||||||
import celery
|
import celery
|
||||||
|
from django.contrib.auth import get_user_model
|
||||||
from django.test import TestCase
|
from django.test import TestCase
|
||||||
|
|
||||||
from documents.data_models import ConsumableDocument
|
from documents.data_models import ConsumableDocument
|
||||||
@@ -20,6 +21,11 @@ from documents.tests.utils import DirectoriesMixin
|
|||||||
|
|
||||||
@mock.patch("documents.consumer.magic.from_file", fake_magic_from_file)
|
@mock.patch("documents.consumer.magic.from_file", fake_magic_from_file)
|
||||||
class TestTaskSignalHandler(DirectoriesMixin, TestCase):
|
class TestTaskSignalHandler(DirectoriesMixin, TestCase):
|
||||||
|
@classmethod
|
||||||
|
def setUpTestData(cls) -> None:
|
||||||
|
super().setUpTestData()
|
||||||
|
cls.user = get_user_model().objects.create_user(username="testuser")
|
||||||
|
|
||||||
def util_call_before_task_publish_handler(
|
def util_call_before_task_publish_handler(
|
||||||
self,
|
self,
|
||||||
headers_to_use,
|
headers_to_use,
|
||||||
@@ -57,7 +63,7 @@ class TestTaskSignalHandler(DirectoriesMixin, TestCase):
|
|||||||
),
|
),
|
||||||
DocumentMetadataOverrides(
|
DocumentMetadataOverrides(
|
||||||
title="Hello world",
|
title="Hello world",
|
||||||
owner_id=1,
|
owner_id=self.user.id,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
# kwargs
|
# kwargs
|
||||||
@@ -75,7 +81,7 @@ class TestTaskSignalHandler(DirectoriesMixin, TestCase):
|
|||||||
self.assertEqual(headers["id"], task.task_id)
|
self.assertEqual(headers["id"], task.task_id)
|
||||||
self.assertEqual("hello-999.pdf", task.task_file_name)
|
self.assertEqual("hello-999.pdf", task.task_file_name)
|
||||||
self.assertEqual(PaperlessTask.TaskName.CONSUME_FILE, task.task_name)
|
self.assertEqual(PaperlessTask.TaskName.CONSUME_FILE, task.task_name)
|
||||||
self.assertEqual(1, task.owner_id)
|
self.assertEqual(self.user.id, task.owner_id)
|
||||||
self.assertEqual(celery.states.PENDING, task.status)
|
self.assertEqual(celery.states.PENDING, task.status)
|
||||||
|
|
||||||
def test_task_prerun_handler(self) -> None:
|
def test_task_prerun_handler(self) -> None:
|
||||||
@@ -208,10 +214,12 @@ class TestTaskSignalHandler(DirectoriesMixin, TestCase):
|
|||||||
mime_type="application/pdf",
|
mime_type="application/pdf",
|
||||||
)
|
)
|
||||||
|
|
||||||
with mock.patch("documents.index.add_or_update_document") as add:
|
with mock.patch("documents.search.get_backend") as mock_get_backend:
|
||||||
|
mock_backend = mock.MagicMock()
|
||||||
|
mock_get_backend.return_value = mock_backend
|
||||||
add_to_index(sender=None, document=root)
|
add_to_index(sender=None, document=root)
|
||||||
|
|
||||||
add.assert_called_once_with(root)
|
mock_backend.add_or_update.assert_called_once_with(root, effective_content="")
|
||||||
|
|
||||||
def test_add_to_index_reindexes_root_for_version_documents(self) -> None:
|
def test_add_to_index_reindexes_root_for_version_documents(self) -> None:
|
||||||
root = Document.objects.create(
|
root = Document.objects.create(
|
||||||
@@ -226,13 +234,17 @@ class TestTaskSignalHandler(DirectoriesMixin, TestCase):
|
|||||||
root_document=root,
|
root_document=root,
|
||||||
)
|
)
|
||||||
|
|
||||||
with mock.patch("documents.index.add_or_update_document") as add:
|
with mock.patch("documents.search.get_backend") as mock_get_backend:
|
||||||
|
mock_backend = mock.MagicMock()
|
||||||
|
mock_get_backend.return_value = mock_backend
|
||||||
add_to_index(sender=None, document=version)
|
add_to_index(sender=None, document=version)
|
||||||
|
|
||||||
self.assertEqual(add.call_count, 2)
|
self.assertEqual(mock_backend.add_or_update.call_count, 1)
|
||||||
self.assertEqual(add.call_args_list[0].args[0].id, version.id)
|
|
||||||
self.assertEqual(add.call_args_list[1].args[0].id, root.id)
|
|
||||||
self.assertEqual(
|
self.assertEqual(
|
||||||
add.call_args_list[1].kwargs,
|
mock_backend.add_or_update.call_args_list[0].args[0].id,
|
||||||
|
version.id,
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
mock_backend.add_or_update.call_args_list[0].kwargs,
|
||||||
{"effective_content": version.content},
|
{"effective_content": version.content},
|
||||||
)
|
)
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user