mirror of
https://github.com/paperless-ngx/paperless-ngx.git
synced 2026-08-13 22:33:19 +00:00
Compare commits
89
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2d6370e734 | ||
|
|
b91c183cae | ||
|
|
0f4f875289 | ||
|
|
99a055cf09 | ||
|
|
cba7729cd5 | ||
|
|
4d9ec9d912 | ||
|
|
2a8579f610 | ||
|
|
4789fe9a52 | ||
|
|
e150c8c7c0 | ||
|
|
879cd4a30a | ||
|
|
6a02b87dde | ||
|
|
59a2651804 | ||
|
|
a99f63e059 | ||
|
|
939cb52f6e | ||
|
|
855669ddf9 | ||
|
|
62089df2d8 | ||
|
|
5e5f6a88a3 | ||
|
|
02e6c49c62 | ||
|
|
3be64da4cb | ||
|
|
c28c532bef | ||
|
|
1d61f7fc62 | ||
|
|
aa67fd3aef | ||
|
|
17dc482872 | ||
|
|
b0e0e8a353 | ||
|
|
fc242bb570 | ||
|
|
b192a419fd | ||
|
|
3986150f95 | ||
|
|
ee5588ade3 | ||
|
|
2635a12281 | ||
|
|
1f396e51f3 | ||
|
|
3eb784b34d | ||
|
|
cb03a0b33e | ||
|
|
a96d0b15b8 | ||
|
|
376b61938f | ||
|
|
d1f5eb0335 | ||
|
|
d283205f57 | ||
|
|
42908ad2b9 | ||
|
|
db6842e710 | ||
|
|
d5f8cd59fb | ||
|
|
91d6741b4f | ||
|
|
972758fc72 | ||
|
|
15d9829b6d | ||
|
|
5ad34dfe03 | ||
|
|
52a0484f74 | ||
|
|
71e2f86f70 | ||
|
|
1824e5fafd | ||
|
|
7b9e56ef22 | ||
|
|
2b9bed749d | ||
|
|
ef49414162 | ||
|
|
a488ba6f90 | ||
|
|
47727de116 | ||
|
|
e86adb6c36 | ||
|
|
74aa10f31f | ||
|
|
2ea4a792fe | ||
|
|
3f5c770af4 | ||
|
|
765313926f | ||
|
|
1f1725ba56 | ||
|
|
c6cecd3c4e | ||
|
|
5e54259db9 | ||
|
|
3a484a3ff2 | ||
|
|
92a1b24583 | ||
|
|
8709dd807b | ||
|
|
5a5a0ffd21 | ||
|
|
d7aa0cb862 | ||
|
|
eafb494066 | ||
|
|
4ca1863dee | ||
|
|
d5d22c2c0f | ||
|
|
991d99bb7c | ||
|
|
30f11814ff | ||
|
|
0ca5c66ca7 | ||
|
|
dd99c2289d | ||
|
|
23c11f49d0 | ||
|
|
cfa1d3b058 | ||
|
|
68bd8f8f63 | ||
|
|
41d5d86637 | ||
|
|
2a50425902 | ||
|
|
b67ac5a7d7 | ||
|
|
b48c434266 | ||
|
|
92baa89638 | ||
|
|
4c7b3dd307 | ||
|
|
fe48752a6f | ||
|
|
4692de5595 | ||
|
|
141d74464b | ||
|
|
c05d069e97 | ||
|
|
f5e47a86c3 | ||
|
|
fe196eaf0e | ||
|
|
7fda0e877b | ||
|
|
5b8f4db1ca | ||
|
|
08ab98f3fc |
+37
-35
@@ -1,49 +1,51 @@
|
|||||||
categories:
|
categories:
|
||||||
|
- type: 'pre-include'
|
||||||
|
when:
|
||||||
|
- label: 'enhancement'
|
||||||
|
- label: 'bug'
|
||||||
|
- label: 'chore'
|
||||||
|
- label: 'deployment'
|
||||||
|
- label: 'translation'
|
||||||
|
- label: 'dependencies'
|
||||||
|
- label: 'documentation'
|
||||||
|
- label: 'frontend'
|
||||||
|
- label: 'backend'
|
||||||
|
- label: 'ci-cd'
|
||||||
|
- label: 'breaking-change'
|
||||||
|
- label: 'notable'
|
||||||
|
- type: 'pre-exclude'
|
||||||
|
when:
|
||||||
|
- label: 'skip-changelog'
|
||||||
- title: 'Breaking Changes'
|
- title: 'Breaking Changes'
|
||||||
labels:
|
when:
|
||||||
- 'breaking-change'
|
- label: 'breaking-change'
|
||||||
- title: 'Notable Changes'
|
- title: 'Notable Changes'
|
||||||
labels:
|
when:
|
||||||
- 'notable'
|
- label: 'notable'
|
||||||
- title: 'Features / Enhancements'
|
- title: 'Features / Enhancements'
|
||||||
labels:
|
when:
|
||||||
- 'enhancement'
|
- label: 'enhancement'
|
||||||
- title: 'Bug Fixes'
|
- title: 'Bug Fixes'
|
||||||
labels:
|
when:
|
||||||
- 'bug'
|
- label: 'bug'
|
||||||
- title: 'Documentation'
|
- title: 'Documentation'
|
||||||
labels:
|
when:
|
||||||
- 'documentation'
|
- label: 'documentation'
|
||||||
- title: 'Maintenance'
|
- title: 'Maintenance'
|
||||||
labels:
|
when:
|
||||||
- 'chore'
|
- label: 'chore'
|
||||||
- 'deployment'
|
- label: 'deployment'
|
||||||
- 'translation'
|
- label: 'translation'
|
||||||
- 'ci-cd'
|
- label: 'ci-cd'
|
||||||
- title: 'Dependencies'
|
- title: 'Dependencies'
|
||||||
collapse-after: 3
|
collapse-after: 3
|
||||||
labels:
|
when:
|
||||||
- 'dependencies'
|
- label: 'dependencies'
|
||||||
- title: 'All App Changes'
|
- title: 'All App Changes'
|
||||||
labels:
|
|
||||||
- 'frontend'
|
|
||||||
- 'backend'
|
|
||||||
collapse-after: 1
|
collapse-after: 1
|
||||||
include-labels:
|
when:
|
||||||
- 'enhancement'
|
- label: 'frontend'
|
||||||
- 'bug'
|
- label: 'backend'
|
||||||
- 'chore'
|
|
||||||
- 'deployment'
|
|
||||||
- 'translation'
|
|
||||||
- 'dependencies'
|
|
||||||
- 'documentation'
|
|
||||||
- 'frontend'
|
|
||||||
- 'backend'
|
|
||||||
- 'ci-cd'
|
|
||||||
- 'breaking-change'
|
|
||||||
- 'notable'
|
|
||||||
exclude-labels:
|
|
||||||
- 'skip-changelog'
|
|
||||||
filter-by-commitish: true
|
filter-by-commitish: true
|
||||||
category-template: '### $TITLE'
|
category-template: '### $TITLE'
|
||||||
change-template: '- $TITLE @$AUTHOR ([#$NUMBER]($URL))'
|
change-template: '- $TITLE @$AUTHOR ([#$NUMBER]($URL))'
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ jobs:
|
|||||||
backend_changed: ${{ steps.force.outputs.run_all == 'true' || steps.filter.outputs.backend == 'true' }}
|
backend_changed: ${{ steps.force.outputs.run_all == 'true' || steps.filter.outputs.backend == 'true' }}
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||||
with:
|
with:
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
persist-credentials: false
|
persist-credentials: false
|
||||||
@@ -63,7 +63,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@fbd0ab8f3e69293af611ebaee6363fc25e6d187d # v4.0.1
|
uses: dorny/paths-filter@7b450fff21473bca461d4b92ce414b9d0420d706 # v4.0.2
|
||||||
with:
|
with:
|
||||||
base: ${{ steps.range.outputs.base }}
|
base: ${{ steps.range.outputs.base }}
|
||||||
ref: ${{ steps.range.outputs.ref }}
|
ref: ${{ steps.range.outputs.ref }}
|
||||||
@@ -87,7 +87,7 @@ jobs:
|
|||||||
fail-fast: false
|
fail-fast: false
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||||
with:
|
with:
|
||||||
persist-credentials: false
|
persist-credentials: false
|
||||||
- name: Start containers
|
- name: Start containers
|
||||||
@@ -96,11 +96,11 @@ jobs:
|
|||||||
docker compose --file docker/compose/docker-compose.ci-test.yml up --detach
|
docker compose --file docker/compose/docker-compose.ci-test.yml up --detach
|
||||||
- name: Set up Python
|
- name: Set up Python
|
||||||
id: setup-python
|
id: setup-python
|
||||||
uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0
|
uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
|
||||||
with:
|
with:
|
||||||
python-version: "${{ matrix.python-version }}"
|
python-version: "${{ matrix.python-version }}"
|
||||||
- name: Install uv
|
- name: Install uv
|
||||||
uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0
|
uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0
|
||||||
with:
|
with:
|
||||||
version: ${{ env.DEFAULT_UV_VERSION }}
|
version: ${{ env.DEFAULT_UV_VERSION }}
|
||||||
enable-cache: true
|
enable-cache: true
|
||||||
@@ -169,16 +169,16 @@ jobs:
|
|||||||
PAPERLESS_SECRET_KEY: "ci-typing-not-a-real-secret"
|
PAPERLESS_SECRET_KEY: "ci-typing-not-a-real-secret"
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||||
with:
|
with:
|
||||||
persist-credentials: false
|
persist-credentials: false
|
||||||
- name: Set up Python
|
- name: Set up Python
|
||||||
id: setup-python
|
id: setup-python
|
||||||
uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0
|
uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
|
||||||
with:
|
with:
|
||||||
python-version: "${{ env.DEFAULT_PYTHON }}"
|
python-version: "${{ env.DEFAULT_PYTHON }}"
|
||||||
- name: Install uv
|
- name: Install uv
|
||||||
uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0
|
uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0
|
||||||
with:
|
with:
|
||||||
version: ${{ env.DEFAULT_UV_VERSION }}
|
version: ${{ env.DEFAULT_UV_VERSION }}
|
||||||
enable-cache: true
|
enable-cache: true
|
||||||
|
|||||||
@@ -41,7 +41,7 @@ jobs:
|
|||||||
ref-name: ${{ steps.ref.outputs.name }}
|
ref-name: ${{ steps.ref.outputs.name }}
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||||
with:
|
with:
|
||||||
persist-credentials: false
|
persist-credentials: false
|
||||||
- name: Determine ref name
|
- name: Determine ref name
|
||||||
@@ -106,9 +106,9 @@ jobs:
|
|||||||
echo "repository=${repo_name}"
|
echo "repository=${repo_name}"
|
||||||
echo "name=${repo_name}" >> $GITHUB_OUTPUT
|
echo "name=${repo_name}" >> $GITHUB_OUTPUT
|
||||||
- name: Set up Docker Buildx
|
- name: Set up Docker Buildx
|
||||||
uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4.1.0
|
uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0
|
||||||
- name: Login to GitHub Container Registry
|
- name: Login to GitHub Container Registry
|
||||||
uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0
|
uses: docker/login-action@abd2ef45e78c5afb21d64d4ca52ee8550d9572c7 # v4.5.1
|
||||||
with:
|
with:
|
||||||
registry: ${{ env.REGISTRY }}
|
registry: ${{ env.REGISTRY }}
|
||||||
username: ${{ github.actor }}
|
username: ${{ github.actor }}
|
||||||
@@ -121,7 +121,7 @@ jobs:
|
|||||||
sudo rm -rf "$AGENT_TOOLSDIRECTORY"
|
sudo rm -rf "$AGENT_TOOLSDIRECTORY"
|
||||||
- name: Docker metadata
|
- name: Docker metadata
|
||||||
id: docker-meta
|
id: docker-meta
|
||||||
uses: docker/metadata-action@80c7e94dd9b9319bd5eb7a0e0fe9291e23a2a2e9 # v6.1.0
|
uses: docker/metadata-action@dc802804100637a589fabce1cb79ff13a1411302 # v6.2.0
|
||||||
with:
|
with:
|
||||||
images: |
|
images: |
|
||||||
${{ env.REGISTRY }}/${{ steps.repo.outputs.name }}
|
${{ env.REGISTRY }}/${{ steps.repo.outputs.name }}
|
||||||
@@ -132,7 +132,7 @@ jobs:
|
|||||||
type=semver,pattern={{major}}.{{minor}}
|
type=semver,pattern={{major}}.{{minor}}
|
||||||
- name: Build and push by digest
|
- name: Build and push by digest
|
||||||
id: build
|
id: build
|
||||||
uses: docker/build-push-action@f9f3042f7e2789586610d6e8b85c8f03e5195baf # v7.2.0
|
uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0
|
||||||
with:
|
with:
|
||||||
context: .
|
context: .
|
||||||
file: ./Dockerfile
|
file: ./Dockerfile
|
||||||
@@ -182,29 +182,29 @@ jobs:
|
|||||||
echo "Downloaded digests:"
|
echo "Downloaded digests:"
|
||||||
ls -la /tmp/digests/
|
ls -la /tmp/digests/
|
||||||
- name: Set up Docker Buildx
|
- name: Set up Docker Buildx
|
||||||
uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4.1.0
|
uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0
|
||||||
- name: Login to GitHub Container Registry
|
- name: Login to GitHub Container Registry
|
||||||
uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0
|
uses: docker/login-action@abd2ef45e78c5afb21d64d4ca52ee8550d9572c7 # v4.5.1
|
||||||
with:
|
with:
|
||||||
registry: ${{ env.REGISTRY }}
|
registry: ${{ env.REGISTRY }}
|
||||||
username: ${{ github.actor }}
|
username: ${{ github.actor }}
|
||||||
password: ${{ secrets.GITHUB_TOKEN }}
|
password: ${{ secrets.GITHUB_TOKEN }}
|
||||||
- name: Login to Docker Hub
|
- name: Login to Docker Hub
|
||||||
if: needs.build-arch.outputs.push-external == 'true'
|
if: needs.build-arch.outputs.push-external == 'true'
|
||||||
uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0
|
uses: docker/login-action@abd2ef45e78c5afb21d64d4ca52ee8550d9572c7 # v4.5.1
|
||||||
with:
|
with:
|
||||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||||
- name: Login to Quay.io
|
- name: Login to Quay.io
|
||||||
if: needs.build-arch.outputs.push-external == 'true'
|
if: needs.build-arch.outputs.push-external == 'true'
|
||||||
uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0
|
uses: docker/login-action@abd2ef45e78c5afb21d64d4ca52ee8550d9572c7 # v4.5.1
|
||||||
with:
|
with:
|
||||||
registry: quay.io
|
registry: quay.io
|
||||||
username: ${{ secrets.QUAY_USERNAME }}
|
username: ${{ secrets.QUAY_USERNAME }}
|
||||||
password: ${{ secrets.QUAY_ROBOT_TOKEN }}
|
password: ${{ secrets.QUAY_ROBOT_TOKEN }}
|
||||||
- name: Docker metadata
|
- name: Docker metadata
|
||||||
id: docker-meta
|
id: docker-meta
|
||||||
uses: docker/metadata-action@80c7e94dd9b9319bd5eb7a0e0fe9291e23a2a2e9 # v6.1.0
|
uses: docker/metadata-action@dc802804100637a589fabce1cb79ff13a1411302 # v6.2.0
|
||||||
with:
|
with:
|
||||||
images: |
|
images: |
|
||||||
${{ env.REGISTRY }}/${{ needs.build-arch.outputs.repository }}
|
${{ env.REGISTRY }}/${{ needs.build-arch.outputs.repository }}
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ jobs:
|
|||||||
docs_changed: ${{ steps.force.outputs.run_all == 'true' || steps.filter.outputs.docs == 'true' }}
|
docs_changed: ${{ steps.force.outputs.run_all == 'true' || steps.filter.outputs.docs == 'true' }}
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||||
with:
|
with:
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
persist-credentials: false
|
persist-credentials: false
|
||||||
@@ -50,7 +50,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@fbd0ab8f3e69293af611ebaee6363fc25e6d187d # v4.0.1
|
uses: dorny/paths-filter@7b450fff21473bca461d4b92ce414b9d0420d706 # v4.0.2
|
||||||
with:
|
with:
|
||||||
base: ${{ steps.range.outputs.base }}
|
base: ${{ steps.range.outputs.base }}
|
||||||
ref: ${{ steps.range.outputs.ref }}
|
ref: ${{ steps.range.outputs.ref }}
|
||||||
@@ -69,16 +69,16 @@ jobs:
|
|||||||
steps:
|
steps:
|
||||||
- uses: actions/configure-pages@45bfe0192ca1faeb007ade9deae92b16b8254a0d # v6.0.0
|
- uses: actions/configure-pages@45bfe0192ca1faeb007ade9deae92b16b8254a0d # v6.0.0
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||||
with:
|
with:
|
||||||
persist-credentials: false
|
persist-credentials: false
|
||||||
- name: Set up Python
|
- name: Set up Python
|
||||||
id: setup-python
|
id: setup-python
|
||||||
uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0
|
uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
|
||||||
with:
|
with:
|
||||||
python-version: ${{ env.DEFAULT_PYTHON_VERSION }}
|
python-version: ${{ env.DEFAULT_PYTHON_VERSION }}
|
||||||
- name: Install uv
|
- name: Install uv
|
||||||
uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0
|
uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0
|
||||||
with:
|
with:
|
||||||
version: ${{ env.DEFAULT_UV_VERSION }}
|
version: ${{ env.DEFAULT_UV_VERSION }}
|
||||||
enable-cache: true
|
enable-cache: true
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ jobs:
|
|||||||
frontend_changed: ${{ steps.force.outputs.run_all == 'true' || steps.filter.outputs.frontend == 'true' }}
|
frontend_changed: ${{ steps.force.outputs.run_all == 'true' || steps.filter.outputs.frontend == 'true' }}
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||||
with:
|
with:
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
persist-credentials: false
|
persist-credentials: false
|
||||||
@@ -60,7 +60,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@fbd0ab8f3e69293af611ebaee6363fc25e6d187d # v4.0.1
|
uses: dorny/paths-filter@7b450fff21473bca461d4b92ce414b9d0420d706 # v4.0.2
|
||||||
with:
|
with:
|
||||||
base: ${{ steps.range.outputs.base }}
|
base: ${{ steps.range.outputs.base }}
|
||||||
ref: ${{ steps.range.outputs.ref }}
|
ref: ${{ steps.range.outputs.ref }}
|
||||||
@@ -77,7 +77,7 @@ jobs:
|
|||||||
contents: read
|
contents: read
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||||
with:
|
with:
|
||||||
persist-credentials: false
|
persist-credentials: false
|
||||||
- name: Install pnpm
|
- name: Install pnpm
|
||||||
@@ -85,7 +85,7 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
version: 10
|
version: 10
|
||||||
- name: Use Node.js 24
|
- name: Use Node.js 24
|
||||||
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
|
||||||
with:
|
with:
|
||||||
node-version: 24.x
|
node-version: 24.x
|
||||||
cache: 'pnpm'
|
cache: 'pnpm'
|
||||||
@@ -109,7 +109,7 @@ jobs:
|
|||||||
contents: read
|
contents: read
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||||
with:
|
with:
|
||||||
persist-credentials: false
|
persist-credentials: false
|
||||||
- name: Install pnpm
|
- name: Install pnpm
|
||||||
@@ -117,7 +117,7 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
version: 10
|
version: 10
|
||||||
- name: Use Node.js 24
|
- name: Use Node.js 24
|
||||||
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
|
||||||
with:
|
with:
|
||||||
node-version: 24.x
|
node-version: 24.x
|
||||||
cache: 'pnpm'
|
cache: 'pnpm'
|
||||||
@@ -129,8 +129,8 @@ jobs:
|
|||||||
~/.pnpm-store
|
~/.pnpm-store
|
||||||
~/.cache
|
~/.cache
|
||||||
key: ${{ runner.os }}-frontend-${{ hashFiles('src-ui/pnpm-lock.yaml') }}
|
key: ${{ runner.os }}-frontend-${{ hashFiles('src-ui/pnpm-lock.yaml') }}
|
||||||
- name: Re-link Angular CLI
|
- name: Install dependencies
|
||||||
run: cd src-ui && pnpm link @angular/cli
|
run: cd src-ui && pnpm install --frozen-lockfile
|
||||||
- name: Run lint
|
- name: Run lint
|
||||||
run: cd src-ui && pnpm run lint
|
run: cd src-ui && pnpm run lint
|
||||||
unit-tests:
|
unit-tests:
|
||||||
@@ -148,7 +148,7 @@ jobs:
|
|||||||
shard-count: [4]
|
shard-count: [4]
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||||
with:
|
with:
|
||||||
persist-credentials: false
|
persist-credentials: false
|
||||||
- name: Install pnpm
|
- name: Install pnpm
|
||||||
@@ -156,7 +156,7 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
version: 10
|
version: 10
|
||||||
- name: Use Node.js 24
|
- name: Use Node.js 24
|
||||||
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
|
||||||
with:
|
with:
|
||||||
node-version: 24.x
|
node-version: 24.x
|
||||||
cache: 'pnpm'
|
cache: 'pnpm'
|
||||||
@@ -168,8 +168,8 @@ jobs:
|
|||||||
~/.pnpm-store
|
~/.pnpm-store
|
||||||
~/.cache
|
~/.cache
|
||||||
key: ${{ runner.os }}-frontend-${{ hashFiles('src-ui/pnpm-lock.yaml') }}
|
key: ${{ runner.os }}-frontend-${{ hashFiles('src-ui/pnpm-lock.yaml') }}
|
||||||
- name: Re-link Angular CLI
|
- name: Install dependencies
|
||||||
run: cd src-ui && pnpm link @angular/cli
|
run: cd src-ui && pnpm install --frozen-lockfile
|
||||||
- name: Run Jest unit tests
|
- name: Run Jest unit tests
|
||||||
run: cd src-ui && pnpm run test --max-workers=2 --shard=${{ matrix.shard-index }}/${{ matrix.shard-count }}
|
run: cd src-ui && pnpm run test --max-workers=2 --shard=${{ matrix.shard-index }}/${{ matrix.shard-count }}
|
||||||
- name: Upload test results to Codecov
|
- name: Upload test results to Codecov
|
||||||
@@ -191,7 +191,7 @@ jobs:
|
|||||||
runs-on: ubuntu-24.04
|
runs-on: ubuntu-24.04
|
||||||
permissions:
|
permissions:
|
||||||
contents: read
|
contents: read
|
||||||
container: mcr.microsoft.com/playwright:v1.61.1-noble
|
container: mcr.microsoft.com/playwright:v1.62.0-noble
|
||||||
env:
|
env:
|
||||||
PLAYWRIGHT_BROWSERS_PATH: /ms-playwright
|
PLAYWRIGHT_BROWSERS_PATH: /ms-playwright
|
||||||
PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD: 1
|
PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD: 1
|
||||||
@@ -203,7 +203,7 @@ jobs:
|
|||||||
shard-count: [2]
|
shard-count: [2]
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||||
with:
|
with:
|
||||||
persist-credentials: false
|
persist-credentials: false
|
||||||
- name: Install pnpm
|
- name: Install pnpm
|
||||||
@@ -211,7 +211,7 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
version: 10
|
version: 10
|
||||||
- name: Use Node.js 24
|
- name: Use Node.js 24
|
||||||
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
|
||||||
with:
|
with:
|
||||||
node-version: 24.x
|
node-version: 24.x
|
||||||
cache: 'pnpm'
|
cache: 'pnpm'
|
||||||
@@ -223,23 +223,20 @@ jobs:
|
|||||||
~/.pnpm-store
|
~/.pnpm-store
|
||||||
~/.cache
|
~/.cache
|
||||||
key: ${{ runner.os }}-frontend-${{ hashFiles('src-ui/pnpm-lock.yaml') }}
|
key: ${{ runner.os }}-frontend-${{ hashFiles('src-ui/pnpm-lock.yaml') }}
|
||||||
- name: Re-link Angular CLI
|
|
||||||
run: cd src-ui && pnpm link @angular/cli
|
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: cd src-ui && pnpm install --no-frozen-lockfile
|
run: cd src-ui && pnpm install --frozen-lockfile
|
||||||
- name: Run Playwright E2E tests
|
- name: Run Playwright E2E tests
|
||||||
run: cd src-ui && pnpm exec playwright test --shard ${{ matrix.shard-index }}/${{ matrix.shard-count }}
|
run: cd src-ui && pnpm exec playwright test --shard ${{ matrix.shard-index }}/${{ matrix.shard-count }}
|
||||||
bundle-analysis:
|
frontend-build:
|
||||||
name: Bundle Analysis
|
name: Frontend Build
|
||||||
needs: [changes, unit-tests, e2e-tests]
|
needs: [changes, unit-tests, e2e-tests]
|
||||||
if: needs.changes.outputs.frontend_changed == 'true'
|
if: needs.changes.outputs.frontend_changed == 'true'
|
||||||
runs-on: ubuntu-24.04
|
runs-on: ubuntu-24.04
|
||||||
environment: bundle-analysis
|
|
||||||
permissions:
|
permissions:
|
||||||
contents: read
|
contents: read
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||||
with:
|
with:
|
||||||
fetch-depth: 2
|
fetch-depth: 2
|
||||||
persist-credentials: false
|
persist-credentials: false
|
||||||
@@ -248,7 +245,7 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
version: 10
|
version: 10
|
||||||
- name: Use Node.js 24
|
- name: Use Node.js 24
|
||||||
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
|
||||||
with:
|
with:
|
||||||
node-version: 24.x
|
node-version: 24.x
|
||||||
cache: 'pnpm'
|
cache: 'pnpm'
|
||||||
@@ -260,21 +257,19 @@ jobs:
|
|||||||
~/.pnpm-store
|
~/.pnpm-store
|
||||||
~/.cache
|
~/.cache
|
||||||
key: ${{ runner.os }}-frontend-${{ hashFiles('src-ui/pnpm-lock.yaml') }}
|
key: ${{ runner.os }}-frontend-${{ hashFiles('src-ui/pnpm-lock.yaml') }}
|
||||||
- name: Re-link Angular CLI
|
- name: Install dependencies
|
||||||
run: cd src-ui && pnpm link @angular/cli
|
run: cd src-ui && pnpm install --frozen-lockfile
|
||||||
- name: Build and analyze
|
- name: Build
|
||||||
env:
|
|
||||||
CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }}
|
|
||||||
run: cd src-ui && pnpm run build --configuration=production
|
run: cd src-ui && pnpm run build --configuration=production
|
||||||
gate:
|
gate:
|
||||||
name: Frontend CI Gate
|
name: Frontend CI Gate
|
||||||
needs: [changes, install-dependencies, lint, unit-tests, e2e-tests, bundle-analysis]
|
needs: [changes, install-dependencies, lint, unit-tests, e2e-tests, frontend-build]
|
||||||
if: always()
|
if: always()
|
||||||
runs-on: ubuntu-slim
|
runs-on: ubuntu-slim
|
||||||
steps:
|
steps:
|
||||||
- name: Check gate
|
- name: Check gate
|
||||||
env:
|
env:
|
||||||
BUNDLE_ANALYSIS_RESULT: ${{ needs['bundle-analysis'].result }}
|
BUILD_RESULT: ${{ needs['frontend-build'].result }}
|
||||||
E2E_RESULT: ${{ needs['e2e-tests'].result }}
|
E2E_RESULT: ${{ needs['e2e-tests'].result }}
|
||||||
FRONTEND_CHANGED: ${{ needs.changes.outputs.frontend_changed }}
|
FRONTEND_CHANGED: ${{ needs.changes.outputs.frontend_changed }}
|
||||||
INSTALL_RESULT: ${{ needs['install-dependencies'].result }}
|
INSTALL_RESULT: ${{ needs['install-dependencies'].result }}
|
||||||
@@ -306,8 +301,8 @@ jobs:
|
|||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
if [[ "${BUNDLE_ANALYSIS_RESULT}" != "success" ]]; then
|
if [[ "${BUILD_RESULT}" != "success" ]]; then
|
||||||
echo "::error::Frontend bundle-analysis job result: ${BUNDLE_ANALYSIS_RESULT}"
|
echo "::error::Frontend build job result: ${BUILD_RESULT}"
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
|||||||
@@ -17,12 +17,12 @@ jobs:
|
|||||||
runs-on: ubuntu-slim
|
runs-on: ubuntu-slim
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||||
with:
|
with:
|
||||||
persist-credentials: false
|
persist-credentials: false
|
||||||
- name: Install Python
|
- name: Install Python
|
||||||
uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0
|
uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
|
||||||
with:
|
with:
|
||||||
python-version: "3.14"
|
python-version: "3.14"
|
||||||
- name: Run prek
|
- name: Run prek
|
||||||
uses: j178/prek-action@bdca6f102f98e2b4c7029491a53dfd366469e33d # v2.0.4
|
uses: j178/prek-action@5337cb91e0fa35a7ff31b9ca345126d8bbbcdf16 # v2.0.6
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ jobs:
|
|||||||
statuses: read
|
statuses: read
|
||||||
steps:
|
steps:
|
||||||
- name: Wait for Docker build
|
- name: Wait for Docker build
|
||||||
uses: lewagon/wait-on-check-action@96d9100b431964d10e0136aff8b9ccb92470505e # v1.8.0
|
uses: lewagon/wait-on-check-action@2271c86c146b96545b4e871b855e10ffa6f50773 # v1.9.0
|
||||||
with:
|
with:
|
||||||
ref: ${{ github.sha }}
|
ref: ${{ github.sha }}
|
||||||
check-name: 'Merge and Push Manifest'
|
check-name: 'Merge and Push Manifest'
|
||||||
@@ -35,7 +35,7 @@ jobs:
|
|||||||
contents: read
|
contents: read
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||||
with:
|
with:
|
||||||
persist-credentials: false
|
persist-credentials: false
|
||||||
# ---- Frontend Build ----
|
# ---- Frontend Build ----
|
||||||
@@ -44,7 +44,7 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
version: 10
|
version: 10
|
||||||
- name: Use Node.js 24
|
- name: Use Node.js 24
|
||||||
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
|
||||||
with:
|
with:
|
||||||
node-version: 24.x
|
node-version: 24.x
|
||||||
package-manager-cache: false
|
package-manager-cache: false
|
||||||
@@ -55,11 +55,11 @@ jobs:
|
|||||||
# ---- Backend Setup ----
|
# ---- Backend Setup ----
|
||||||
- name: Set up Python
|
- name: Set up Python
|
||||||
id: setup-python
|
id: setup-python
|
||||||
uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0
|
uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
|
||||||
with:
|
with:
|
||||||
python-version: ${{ env.DEFAULT_PYTHON_VERSION }}
|
python-version: ${{ env.DEFAULT_PYTHON_VERSION }}
|
||||||
- name: Install uv
|
- name: Install uv
|
||||||
uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0
|
uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0
|
||||||
with:
|
with:
|
||||||
version: ${{ env.DEFAULT_UV_VERSION }}
|
version: ${{ env.DEFAULT_UV_VERSION }}
|
||||||
enable-cache: false
|
enable-cache: false
|
||||||
@@ -171,7 +171,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@4d75298e00d9e34c483e5ff8c68d0ea1c1940c1e # v7.5.1
|
uses: release-drafter/release-drafter@eada3c96a64734dd381cfbda23511034e328ddb0 # v7.6.0
|
||||||
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 }}
|
||||||
@@ -202,17 +202,17 @@ jobs:
|
|||||||
pull-requests: write
|
pull-requests: write
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||||
with:
|
with:
|
||||||
ref: main
|
ref: main
|
||||||
persist-credentials: true # for pushing changelog branch
|
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@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0
|
uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
|
||||||
with:
|
with:
|
||||||
python-version: ${{ env.DEFAULT_PYTHON_VERSION }}
|
python-version: ${{ env.DEFAULT_PYTHON_VERSION }}
|
||||||
- name: Install uv
|
- name: Install uv
|
||||||
uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0
|
uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0
|
||||||
with:
|
with:
|
||||||
version: ${{ env.DEFAULT_UV_VERSION }}
|
version: ${{ env.DEFAULT_UV_VERSION }}
|
||||||
enable-cache: false
|
enable-cache: false
|
||||||
|
|||||||
@@ -22,11 +22,11 @@ jobs:
|
|||||||
security-events: write
|
security-events: write
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||||
with:
|
with:
|
||||||
persist-credentials: false
|
persist-credentials: false
|
||||||
- name: Run zizmor
|
- name: Run zizmor
|
||||||
uses: zizmorcore/zizmor-action@192e21d79ab29983730a13d1382995c2307fbcaa # v0.5.7
|
uses: zizmorcore/zizmor-action@6fc4b006235f201fdab3722e17240ab420d580e5 # v0.6.1
|
||||||
semgrep:
|
semgrep:
|
||||||
name: Semgrep CE
|
name: Semgrep CE
|
||||||
runs-on: ubuntu-24.04
|
runs-on: ubuntu-24.04
|
||||||
@@ -38,13 +38,13 @@ jobs:
|
|||||||
security-events: write
|
security-events: write
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||||
with:
|
with:
|
||||||
persist-credentials: false
|
persist-credentials: false
|
||||||
- name: Run Semgrep
|
- name: Run Semgrep
|
||||||
run: semgrep scan --config auto --sarif-output results.sarif
|
run: semgrep scan --config auto --sarif-output results.sarif
|
||||||
- name: Upload results to GitHub code scanning
|
- name: Upload results to GitHub code scanning
|
||||||
uses: github/codeql-action/upload-sarif@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4.36.2
|
uses: github/codeql-action/upload-sarif@e4fba868fa4b1b91e1fdab776edc8cfbe6e9fb81 # v4.37.3
|
||||||
if: always()
|
if: always()
|
||||||
with:
|
with:
|
||||||
sarif_file: results.sarif
|
sarif_file: results.sarif
|
||||||
|
|||||||
@@ -34,12 +34,12 @@ jobs:
|
|||||||
# Learn more about CodeQL language support at https://git.io/codeql-language-support
|
# Learn more about CodeQL language support at https://git.io/codeql-language-support
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||||
with:
|
with:
|
||||||
persist-credentials: false
|
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@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4.36.2
|
uses: github/codeql-action/init@e4fba868fa4b1b91e1fdab776edc8cfbe6e9fb81 # v4.37.3
|
||||||
with:
|
with:
|
||||||
languages: ${{ matrix.language }}
|
languages: ${{ matrix.language }}
|
||||||
# If you wish to specify custom queries, you can do so here or in a config file.
|
# If you wish to specify custom queries, you can do so here or in a config file.
|
||||||
@@ -47,4 +47,4 @@ jobs:
|
|||||||
# Prefix the list here with "+" to use these queries and those in the config file.
|
# Prefix the list here with "+" to use these queries and those in the config file.
|
||||||
# queries: ./path/to/local/query, your-org/your-repo/queries@main
|
# queries: ./path/to/local/query, your-org/your-repo/queries@main
|
||||||
- name: Perform CodeQL Analysis
|
- name: Perform CodeQL Analysis
|
||||||
uses: github/codeql-action/analyze@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4.36.2
|
uses: github/codeql-action/analyze@e4fba868fa4b1b91e1fdab776edc8cfbe6e9fb81 # v4.37.3
|
||||||
|
|||||||
@@ -17,12 +17,12 @@ jobs:
|
|||||||
environment: translation-sync
|
environment: translation-sync
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||||
with:
|
with:
|
||||||
token: ${{ secrets.PNGX_BOT_PAT }}
|
token: ${{ secrets.PNGX_BOT_PAT }}
|
||||||
persist-credentials: false
|
persist-credentials: false
|
||||||
- name: crowdin action
|
- name: crowdin action
|
||||||
uses: crowdin/github-action@52aa776766211d83d975df51f3b9c53c2f8ba35f # v2.16.3
|
uses: crowdin/github-action@c7af9bc98b01694653031fef2a0dc6c7888ce9bc # v2.17.0
|
||||||
with:
|
with:
|
||||||
upload_translations: false
|
upload_translations: false
|
||||||
download_translations: true
|
download_translations: true
|
||||||
|
|||||||
@@ -31,7 +31,7 @@ jobs:
|
|||||||
steps:
|
steps:
|
||||||
- name: Label PR by file path or branch name
|
- name: Label PR by file path or branch name
|
||||||
# see .github/labeler.yml for the labeler config
|
# see .github/labeler.yml for the labeler config
|
||||||
uses: actions/labeler@f27b608878404679385c85cfa523b85ccb86e213 # v6.1.0
|
uses: actions/labeler@bf12e9b00b37c5c0ca2b87b79b2daf7891dbda13 # v7.0.0
|
||||||
with:
|
with:
|
||||||
repo-token: ${{ secrets.GITHUB_TOKEN }}
|
repo-token: ${{ secrets.GITHUB_TOKEN }}
|
||||||
- name: Label by size
|
- name: Label by size
|
||||||
|
|||||||
@@ -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@4d75298e00d9e34c483e5ff8c68d0ea1c1940c1e # v7.5.1
|
uses: release-drafter/release-drafter@eada3c96a64734dd381cfbda23511034e328ddb0 # v7.6.0
|
||||||
env:
|
env:
|
||||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ jobs:
|
|||||||
issues: write
|
issues: write
|
||||||
pull-requests: write
|
pull-requests: write
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/stale@eb5cf3af3ac0a1aa4c9c45633dd1ae542a27a899 # v10.3.0
|
- uses: actions/stale@1e223db275d687790206a7acac4d1a11bd6fe629 # v10.4.0
|
||||||
with:
|
with:
|
||||||
days-before-stale: 7
|
days-before-stale: 7
|
||||||
days-before-close: 14
|
days-before-close: 14
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ jobs:
|
|||||||
contents: write
|
contents: write
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout code
|
- name: Checkout code
|
||||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||||
env:
|
env:
|
||||||
GH_REF: ${{ github.ref }} # sonar rule:githubactions:S7630 - avoid injection
|
GH_REF: ${{ github.ref }} # sonar rule:githubactions:S7630 - avoid injection
|
||||||
with:
|
with:
|
||||||
@@ -23,13 +23,13 @@ jobs:
|
|||||||
persist-credentials: true # for pushing translation branch
|
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@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0
|
uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
|
||||||
- name: Install system dependencies
|
- name: Install system dependencies
|
||||||
run: |
|
run: |
|
||||||
sudo apt-get update -qq
|
sudo apt-get update -qq
|
||||||
sudo apt-get install -qq --no-install-recommends gettext
|
sudo apt-get install -qq --no-install-recommends gettext
|
||||||
- name: Install uv
|
- name: Install uv
|
||||||
uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0
|
uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0
|
||||||
with:
|
with:
|
||||||
version: ${{ env.DEFAULT_UV_VERSION }}
|
version: ${{ env.DEFAULT_UV_VERSION }}
|
||||||
enable-cache: true
|
enable-cache: true
|
||||||
@@ -47,7 +47,7 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
version: 10
|
version: 10
|
||||||
- name: Use Node.js 24
|
- name: Use Node.js 24
|
||||||
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
|
||||||
with:
|
with:
|
||||||
node-version: 24.x
|
node-version: 24.x
|
||||||
cache: 'pnpm'
|
cache: 'pnpm'
|
||||||
@@ -61,16 +61,13 @@ jobs:
|
|||||||
~/.cache
|
~/.cache
|
||||||
key: ${{ runner.os }}-frontenddeps-${{ hashFiles('src-ui/pnpm-lock.yaml') }}
|
key: ${{ runner.os }}-frontenddeps-${{ hashFiles('src-ui/pnpm-lock.yaml') }}
|
||||||
- name: Install frontend dependencies
|
- name: Install frontend dependencies
|
||||||
if: steps.cache-frontend-deps.outputs.cache-hit != 'true'
|
run: cd src-ui && pnpm install --frozen-lockfile
|
||||||
run: cd src-ui && pnpm install
|
|
||||||
- name: Re-link Angular cli
|
|
||||||
run: cd src-ui && pnpm link @angular/cli
|
|
||||||
- name: Generate frontend translation strings
|
- name: Generate frontend translation strings
|
||||||
run: |
|
run: |
|
||||||
cd src-ui
|
cd src-ui
|
||||||
pnpm run ng extract-i18n
|
pnpm run ng extract-i18n
|
||||||
- name: Commit changes
|
- name: Commit changes
|
||||||
uses: stefanzweifel/git-auto-commit-action@04702edda442b2e678b25b537cec683a1493fcb9 # v7.1.0
|
uses: stefanzweifel/git-auto-commit-action@4a55954c782fc1ea30b9056cd3e7a2b40ca8887d # v7.2.0
|
||||||
with:
|
with:
|
||||||
file_pattern: 'src-ui/messages.xlf src/locale/en_US/LC_MESSAGES/django.po'
|
file_pattern: 'src-ui/messages.xlf src/locale/en_US/LC_MESSAGES/django.po'
|
||||||
commit_message: "Auto translate strings"
|
commit_message: "Auto translate strings"
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ repos:
|
|||||||
- id: check-case-conflict
|
- id: check-case-conflict
|
||||||
- id: detect-private-key
|
- id: detect-private-key
|
||||||
- repo: https://github.com/codespell-project/codespell
|
- repo: https://github.com/codespell-project/codespell
|
||||||
rev: v2.4.2
|
rev: v2.4.3
|
||||||
hooks:
|
hooks:
|
||||||
- id: codespell
|
- id: codespell
|
||||||
additional_dependencies: [tomli]
|
additional_dependencies: [tomli]
|
||||||
@@ -38,7 +38,7 @@ repos:
|
|||||||
- json
|
- json
|
||||||
# See https://github.com/prettier/prettier/issues/15742 for the fork reason
|
# See https://github.com/prettier/prettier/issues/15742 for the fork reason
|
||||||
- repo: https://github.com/rbubley/mirrors-prettier
|
- repo: https://github.com/rbubley/mirrors-prettier
|
||||||
rev: 'v3.9.4'
|
rev: 'v3.9.6'
|
||||||
hooks:
|
hooks:
|
||||||
- id: prettier
|
- id: prettier
|
||||||
types_or:
|
types_or:
|
||||||
@@ -46,16 +46,16 @@ repos:
|
|||||||
- ts
|
- ts
|
||||||
- markdown
|
- markdown
|
||||||
additional_dependencies:
|
additional_dependencies:
|
||||||
- prettier@3.9.4
|
- prettier@3.9.6
|
||||||
- '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.20
|
rev: v0.16.1
|
||||||
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.25.1"
|
rev: "v2.26.0"
|
||||||
hooks:
|
hooks:
|
||||||
- id: pyproject-fmt
|
- id: pyproject-fmt
|
||||||
additional_dependencies: [tomli]
|
additional_dependencies: [tomli]
|
||||||
|
|||||||
+1
-1
@@ -30,7 +30,7 @@ RUN set -eux \
|
|||||||
# Purpose: Installs s6-overlay and rootfs
|
# Purpose: Installs s6-overlay and rootfs
|
||||||
# Comments:
|
# Comments:
|
||||||
# - Don't leave anything extra in here either
|
# - Don't leave anything extra in here either
|
||||||
FROM ghcr.io/astral-sh/uv:0.11.28-python3.12-trixie-slim AS s6-overlay-base
|
FROM ghcr.io/astral-sh/uv:0.11.32-python3.12-trixie-slim AS s6-overlay-base
|
||||||
|
|
||||||
WORKDIR /usr/src/s6
|
WORKDIR /usr/src/s6
|
||||||
|
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ services:
|
|||||||
network_mode: host
|
network_mode: host
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
greenmail:
|
greenmail:
|
||||||
image: docker.io/greenmail/standalone:2.1.9
|
image: docker.io/greenmail/standalone:2.1.11
|
||||||
hostname: greenmail
|
hostname: greenmail
|
||||||
container_name: greenmail
|
container_name: greenmail
|
||||||
environment:
|
environment:
|
||||||
@@ -35,7 +35,7 @@ services:
|
|||||||
- "3143:3143" # IMAP
|
- "3143:3143" # IMAP
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
nginx:
|
nginx:
|
||||||
image: docker.io/nginx:1.31.2-alpine
|
image: docker.io/nginx:1.31.3-alpine
|
||||||
hostname: nginx
|
hostname: nginx
|
||||||
container_name: nginx
|
container_name: nginx
|
||||||
ports:
|
ports:
|
||||||
|
|||||||
@@ -699,6 +699,7 @@ document_fuzzy_match [--ratio] [--processes N]
|
|||||||
| --ratio | No | 85.0 | a number between 0 and 100, setting how similar a document must be for it to be reported. Higher numbers mean more similarity. |
|
| --ratio | No | 85.0 | a number between 0 and 100, setting how similar a document must be for it to be reported. Higher numbers mean more similarity. |
|
||||||
| --processes | No | 1/4 of system cores | Number of processes to use for matching. Setting 1 disables multiple processes |
|
| --processes | No | 1/4 of system cores | Number of processes to use for matching. Setting 1 disables multiple processes |
|
||||||
| --delete | No | False | If provided, one document of a matched pair above the ratio will be deleted. |
|
| --delete | No | False | If provided, one document of a matched pair above the ratio will be deleted. |
|
||||||
|
| --url | No | blank | If an instance URL is provided, the output table will show URLs to each documents instead of the document ID and name. |
|
||||||
|
|
||||||
!!! warning
|
!!! warning
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,65 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
## paperless-ngx 3.0.5
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
- Fix: accept Whoosh-era abbreviated relative-date units (yrs, mos, wks, etc) in search queries [@stumpylog](https://github.com/stumpylog) ([#13486](https://github.com/paperless-ngx/paperless-ngx/pull/13486))
|
||||||
|
- Fix: fix edit dialog error change detection [@shamoon](https://github.com/shamoon) ([#13483](https://github.com/paperless-ngx/paperless-ngx/pull/13483))
|
||||||
|
- Fix: key the AI suggestion cache by model and endpoint [@lunetics](https://github.com/lunetics) ([#13449](https://github.com/paperless-ngx/paperless-ngx/pull/13449))
|
||||||
|
- Fix: validate custom field values in bulk operations [@shamoon](https://github.com/shamoon) ([#13457](https://github.com/paperless-ngx/paperless-ngx/pull/13457))
|
||||||
|
- Fixhancement: better handle empty fields from AI suggestions [@shamoon](https://github.com/shamoon) ([#13454](https://github.com/paperless-ngx/paperless-ngx/pull/13454))
|
||||||
|
- Fix: normalize monetary decimal symbol by locale [@shamoon](https://github.com/shamoon) ([#13427](https://github.com/paperless-ngx/paperless-ngx/pull/13427))
|
||||||
|
- Fix: fold overflowing path text in sanity checker [@stumpylog](https://github.com/stumpylog) ([#13426](https://github.com/paperless-ngx/paperless-ngx/pull/13426))
|
||||||
|
- Fix: correct delayed add field button for custom fields [@shamoon](https://github.com/shamoon) ([#13424](https://github.com/paperless-ngx/paperless-ngx/pull/13424))
|
||||||
|
- Fix: hide attributes collapse / show button without UI settings [@shamoon](https://github.com/shamoon) ([#13425](https://github.com/paperless-ngx/paperless-ngx/pull/13425))
|
||||||
|
- Fix: consolidate born-digital PDF detection between archive decision and OCR [@stumpylog](https://github.com/stumpylog) ([#13409](https://github.com/paperless-ngx/paperless-ngx/pull/13409))
|
||||||
|
- Fix: prevent scale vs page loop in pngx PDF viewer [@shamoon](https://github.com/shamoon) ([#13406](https://github.com/paperless-ngx/paperless-ngx/pull/13406))
|
||||||
|
- Fix: handle hidden line breaks in email subjects when sending email [@shamoon](https://github.com/shamoon) ([#13402](https://github.com/paperless-ngx/paperless-ngx/pull/13402))
|
||||||
|
- Fix: avoid NotSupportedError from document\_importer on MariaDB [@stumpylog](https://github.com/stumpylog) ([#13400](https://github.com/paperless-ngx/paperless-ngx/pull/13400))
|
||||||
|
- Fix: exclude next-period start from relative date-range filters [@stumpylog](https://github.com/stumpylog) ([#13381](https://github.com/paperless-ngx/paperless-ngx/pull/13381))
|
||||||
|
- Fix: close non-atomic db connections in before\_task\_publish [@shamoon](https://github.com/shamoon) ([#13366](https://github.com/paperless-ngx/paperless-ngx/pull/13366))
|
||||||
|
- Chore/fix: refactor frontend task service [@shamoon](https://github.com/shamoon) ([#13365](https://github.com/paperless-ngx/paperless-ngx/pull/13365))
|
||||||
|
- Fix: fix Enter selection in search autocomplete [@shamoon](https://github.com/shamoon) ([#13361](https://github.com/paperless-ngx/paperless-ngx/pull/13361))
|
||||||
|
|
||||||
|
### Documentation
|
||||||
|
|
||||||
|
- Documentation: PAPERLESS\_CONSUMER\_IGNORE\_PATTERNS clarifications [@shamoon](https://github.com/shamoon) ([#13489](https://github.com/paperless-ngx/paperless-ngx/pull/13489))
|
||||||
|
- Documentation: Add Password Removal workflow action documentation [@stumpylog](https://github.com/stumpylog) ([#13377](https://github.com/paperless-ngx/paperless-ngx/pull/13377))
|
||||||
|
|
||||||
|
### Dependencies
|
||||||
|
|
||||||
|
- Chore(deps): Bump pymdown-extensions from 10.21.3 to 11.0 in the uv group across 1 directory @[dependabot[bot]](https://github.com/apps/dependabot) ([#13378](https://github.com/paperless-ngx/paperless-ngx/pull/13378))
|
||||||
|
|
||||||
|
### All App Changes
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary>21 changes</summary>
|
||||||
|
|
||||||
|
- Fix: accept Whoosh-era abbreviated relative-date units (yrs, mos, wks, etc) in search queries [@stumpylog](https://github.com/stumpylog) ([#13486](https://github.com/paperless-ngx/paperless-ngx/pull/13486))
|
||||||
|
- Fix: fix edit dialog error change detection [@shamoon](https://github.com/shamoon) ([#13483](https://github.com/paperless-ngx/paperless-ngx/pull/13483))
|
||||||
|
- Performance: sqlite-vec point-delete for document chunks [@stumpylog](https://github.com/stumpylog) ([#13438](https://github.com/paperless-ngx/paperless-ngx/pull/13438))
|
||||||
|
- Fix: key the AI suggestion cache by model and endpoint [@lunetics](https://github.com/lunetics) ([#13449](https://github.com/paperless-ngx/paperless-ngx/pull/13449))
|
||||||
|
- Fix: validate custom field values in bulk operations [@shamoon](https://github.com/shamoon) ([#13457](https://github.com/paperless-ngx/paperless-ngx/pull/13457))
|
||||||
|
- Fixhancement: better handle empty fields from AI suggestions [@shamoon](https://github.com/shamoon) ([#13454](https://github.com/paperless-ngx/paperless-ngx/pull/13454))
|
||||||
|
- Performance: Use server side iterators during LLM index updating [@stumpylog](https://github.com/stumpylog) ([#13430](https://github.com/paperless-ngx/paperless-ngx/pull/13430))
|
||||||
|
- Fix: normalize monetary decimal symbol by locale [@shamoon](https://github.com/shamoon) ([#13427](https://github.com/paperless-ngx/paperless-ngx/pull/13427))
|
||||||
|
- Fix: fold overflowing path text in sanity checker [@stumpylog](https://github.com/stumpylog) ([#13426](https://github.com/paperless-ngx/paperless-ngx/pull/13426))
|
||||||
|
- Fix: correct delayed add field button for custom fields [@shamoon](https://github.com/shamoon) ([#13424](https://github.com/paperless-ngx/paperless-ngx/pull/13424))
|
||||||
|
- Fix: hide attributes collapse / show button without UI settings [@shamoon](https://github.com/shamoon) ([#13425](https://github.com/paperless-ngx/paperless-ngx/pull/13425))
|
||||||
|
- Fix: consolidate born-digital PDF detection between archive decision and OCR [@stumpylog](https://github.com/stumpylog) ([#13409](https://github.com/paperless-ngx/paperless-ngx/pull/13409))
|
||||||
|
- Fix: prevent scale vs page loop in pngx PDF viewer [@shamoon](https://github.com/shamoon) ([#13406](https://github.com/paperless-ngx/paperless-ngx/pull/13406))
|
||||||
|
- Fix: handle hidden line breaks in email subjects when sending email [@shamoon](https://github.com/shamoon) ([#13402](https://github.com/paperless-ngx/paperless-ngx/pull/13402))
|
||||||
|
- Fix: avoid NotSupportedError from document\_importer on MariaDB [@stumpylog](https://github.com/stumpylog) ([#13400](https://github.com/paperless-ngx/paperless-ngx/pull/13400))
|
||||||
|
- Tweak: adjust doc details button toolbar flow [@shamoon](https://github.com/shamoon) ([#13382](https://github.com/paperless-ngx/paperless-ngx/pull/13382))
|
||||||
|
- Fix: exclude next-period start from relative date-range filters [@stumpylog](https://github.com/stumpylog) ([#13381](https://github.com/paperless-ngx/paperless-ngx/pull/13381))
|
||||||
|
- Chore(deps): Bump pymdown-extensions from 10.21.3 to 11.0 in the uv group across 1 directory @[dependabot[bot]](https://github.com/apps/dependabot) ([#13378](https://github.com/paperless-ngx/paperless-ngx/pull/13378))
|
||||||
|
- Fix: close non-atomic db connections in before\_task\_publish [@shamoon](https://github.com/shamoon) ([#13366](https://github.com/paperless-ngx/paperless-ngx/pull/13366))
|
||||||
|
- Chore/fix: refactor frontend task service [@shamoon](https://github.com/shamoon) ([#13365](https://github.com/paperless-ngx/paperless-ngx/pull/13365))
|
||||||
|
- Fix: fix Enter selection in search autocomplete [@shamoon](https://github.com/shamoon) ([#13361](https://github.com/paperless-ngx/paperless-ngx/pull/13361))
|
||||||
|
|
||||||
|
</details>
|
||||||
|
|
||||||
## paperless-ngx 3.0.4
|
## paperless-ngx 3.0.4
|
||||||
|
|
||||||
### Bug Fixes
|
### Bug Fixes
|
||||||
|
|||||||
@@ -948,10 +948,11 @@ for display in the web interface.
|
|||||||
|
|
||||||
!!! note
|
!!! note
|
||||||
|
|
||||||
The **remote OCR parser** (Azure AI) always produces a searchable
|
The **remote OCR parser** (Azure AI) also honors this setting: when
|
||||||
PDF and stores it as the archive copy, regardless of this setting.
|
no archive is requested (`never`, or `auto` with a born-digital PDF),
|
||||||
`ARCHIVE_FILE_GENERATION=never` has no effect when the remote
|
the remote engine is skipped entirely and locally-extracted text is
|
||||||
parser handles a document.
|
used instead, avoiding an unnecessary API call and a duplicate text
|
||||||
|
layer.
|
||||||
|
|
||||||
#### [`PAPERLESS_OCR_CLEAN=<mode>`](#PAPERLESS_OCR_CLEAN) {#PAPERLESS_OCR_CLEAN}
|
#### [`PAPERLESS_OCR_CLEAN=<mode>`](#PAPERLESS_OCR_CLEAN) {#PAPERLESS_OCR_CLEAN}
|
||||||
|
|
||||||
|
|||||||
@@ -187,10 +187,11 @@ PAPERLESS_ARCHIVE_FILE_GENERATION=auto
|
|||||||
|
|
||||||
### Remote OCR parser
|
### Remote OCR parser
|
||||||
|
|
||||||
If you use the **remote OCR parser** (Azure AI), note that it always produces a
|
If you use the **remote OCR parser** (Azure AI), `ARCHIVE_FILE_GENERATION` is
|
||||||
searchable PDF and stores it as the archive copy. `ARCHIVE_FILE_GENERATION=never`
|
honored the same way as for the local engine: when no archive is requested
|
||||||
has no effect for documents handled by the remote parser - the archive is produced
|
(`never`, or `auto` with a born-digital PDF), the remote engine is skipped
|
||||||
unconditionally by the remote engine.
|
entirely and locally-extracted text is used instead, avoiding an unnecessary
|
||||||
|
API call and a duplicate text layer.
|
||||||
|
|
||||||
## Search Index (Whoosh -> Tantivy)
|
## Search Index (Whoosh -> Tantivy)
|
||||||
|
|
||||||
|
|||||||
+5
-2
@@ -576,7 +576,9 @@ The following workflow action types are available:
|
|||||||
- Tags, correspondent, document type and storage path
|
- Tags, correspondent, document type and storage path
|
||||||
- Document owner
|
- Document owner
|
||||||
- View and / or edit permissions to users or groups
|
- View and / or edit permissions to users or groups
|
||||||
- Custom fields. Note that no value for the field will be set
|
- Custom fields, optionally with a value. If no value is set, the field is only added to the
|
||||||
|
document and any value it may already have is left untouched. If a value is set, it will
|
||||||
|
overwrite an existing value of that field on the document.
|
||||||
|
|
||||||
##### Removal {#workflow-action-removal}
|
##### Removal {#workflow-action-removal}
|
||||||
|
|
||||||
@@ -1084,7 +1086,8 @@ Paperless-ngx supports performing OCR on documents using remote services. At the
|
|||||||
[Microsoft's Azure "Document Intelligence" service](https://azure.microsoft.com/en-us/products/ai-services/ai-document-intelligence).
|
[Microsoft's Azure "Document Intelligence" service](https://azure.microsoft.com/en-us/products/ai-services/ai-document-intelligence).
|
||||||
This is of course a paid service (with a free tier) which requires an Azure account and subscription. Azure AI is not affiliated with
|
This is of course a paid service (with a free tier) which requires an Azure account and subscription. Azure AI is not affiliated with
|
||||||
Paperless-ngx in any way. When enabled, Paperless-ngx will automatically send appropriate documents to Azure for OCR processing, bypassing
|
Paperless-ngx in any way. When enabled, Paperless-ngx will automatically send appropriate documents to Azure for OCR processing, bypassing
|
||||||
the local OCR engine. See the [configuration](configuration.md#PAPERLESS_REMOTE_OCR_ENGINE) options for more details.
|
the local OCR engine. See the [configuration](configuration.md#PAPERLESS_REMOTE_OCR_ENGINE) options for more details. These
|
||||||
|
settings can be supplied as environment variables or via **Application Configuration**.
|
||||||
|
|
||||||
Additionally, when using a commercial service with this feature, consider both potential costs as well as any associated file size
|
Additionally, when using a commercial service with this feature, consider both potential costs as well as any associated file size
|
||||||
or page limitations (e.g. with a free tier).
|
or page limitations (e.g. with a free tier).
|
||||||
|
|||||||
+24
-24
@@ -21,7 +21,7 @@ dependencies = [
|
|||||||
"channels~=4.2",
|
"channels~=4.2",
|
||||||
"channels-redis~=4.2",
|
"channels-redis~=4.2",
|
||||||
"concurrent-log-handler~=0.9.25",
|
"concurrent-log-handler~=0.9.25",
|
||||||
"dateparser~=1.2",
|
"dateparser~=1.4",
|
||||||
# WARNING: django does not use semver.
|
# WARNING: django does not use semver.
|
||||||
# Only patch versions are guaranteed to not introduce breaking changes.
|
# Only patch versions are guaranteed to not introduce breaking changes.
|
||||||
"django~=5.2.13",
|
"django~=5.2.13",
|
||||||
@@ -32,33 +32,32 @@ dependencies = [
|
|||||||
"django-cors-headers~=4.9.0",
|
"django-cors-headers~=4.9.0",
|
||||||
"django-extensions~=4.1",
|
"django-extensions~=4.1",
|
||||||
"django-filter~=25.1",
|
"django-filter~=25.1",
|
||||||
"django-guardian~=3.3.0",
|
"django-guardian~=3.3.3",
|
||||||
"django-multiselectfield~=1.0.1",
|
"django-multiselectfield~=1.0.1",
|
||||||
"django-rich~=2.2.0",
|
"django-rich~=2.2.0",
|
||||||
"django-soft-delete~=1.0.18",
|
"django-soft-delete~=1.0.18",
|
||||||
"django-treenode>=0.24",
|
"django-treenode>=0.24",
|
||||||
"djangorestframework~=3.16",
|
"djangorestframework~=3.16",
|
||||||
"djangorestframework-guardian~=0.4.0",
|
"drf-spectacular~=0.30",
|
||||||
"drf-spectacular~=0.28",
|
"drf-spectacular-sidecar~=2026.7.1",
|
||||||
"drf-spectacular-sidecar~=2026.5.1",
|
|
||||||
"drf-writable-nested~=0.7.1",
|
"drf-writable-nested~=0.7.1",
|
||||||
"filelock~=3.29.0",
|
"filelock~=3.32.0",
|
||||||
"flower~=2.0.1",
|
"flower~=2.0.1",
|
||||||
"gotenberg-client~=0.14.0",
|
"gotenberg-client~=0.14.0",
|
||||||
"httpx-oauth~=0.16",
|
"httpx-oauth~=0.17",
|
||||||
"ijson>=3.2",
|
"ijson>=3.5.1",
|
||||||
"imap-tools~=1.13.0",
|
"imap-tools~=1.14.0",
|
||||||
"jinja2~=3.1.5",
|
"jinja2~=3.1.5",
|
||||||
"langdetect~=1.0.9",
|
"langdetect~=1.0.9",
|
||||||
"llama-index-core>=0.14.22",
|
"llama-index-core>=0.14.23",
|
||||||
"llama-index-embeddings-huggingface>=0.6.1",
|
"llama-index-embeddings-huggingface>=0.6.1",
|
||||||
"llama-index-embeddings-ollama>=0.9",
|
"llama-index-embeddings-ollama>=0.9",
|
||||||
"llama-index-embeddings-openai-like>=0.2.2",
|
"llama-index-embeddings-openai-like>=0.2.2",
|
||||||
"llama-index-llms-ollama>=0.9.1",
|
"llama-index-llms-ollama>=0.9.1",
|
||||||
"llama-index-llms-openai-like>=0.7.1",
|
"llama-index-llms-openai-like>=0.7.1",
|
||||||
"nltk~=3.10.0",
|
"nltk~=3.10.0",
|
||||||
"ocrmypdf~=17.4.2",
|
"ocrmypdf~=17.7.0",
|
||||||
"openai>=2.32",
|
"openai>=2.48",
|
||||||
"pathvalidate~=3.3.1",
|
"pathvalidate~=3.3.1",
|
||||||
"pdf2image~=1.17.0",
|
"pdf2image~=1.17.0",
|
||||||
"python-dateutil~=2.9.0",
|
"python-dateutil~=2.9.0",
|
||||||
@@ -68,17 +67,17 @@ dependencies = [
|
|||||||
"python-magic~=0.4.27",
|
"python-magic~=0.4.27",
|
||||||
"rapidfuzz~=3.14.5",
|
"rapidfuzz~=3.14.5",
|
||||||
"redis[hiredis]~=5.2.1",
|
"redis[hiredis]~=5.2.1",
|
||||||
"regex>=2026.4.4",
|
"regex>=2026.7.19",
|
||||||
"scikit-learn~=1.8.0",
|
"scikit-learn~=1.9.0",
|
||||||
"sentence-transformers>=5.4.1",
|
"sentence-transformers>=5.6.1",
|
||||||
"setproctitle~=1.3.4",
|
"setproctitle~=1.3.4",
|
||||||
"sqlite-vec==0.1.9",
|
"sqlite-vec==0.1.9",
|
||||||
"tantivy~=0.26.0",
|
"tantivy~=0.26.0",
|
||||||
"tika-client~=0.11.0",
|
"tika-client~=0.11.0",
|
||||||
"torch~=2.13.0",
|
"torch~=2.13.0",
|
||||||
"watchfiles>=1.1.1",
|
"watchfiles>=1.2",
|
||||||
"whitenoise~=6.11",
|
"whitenoise~=6.11",
|
||||||
"zxing-cpp~=3.0.0",
|
"zxing-cpp~=3.1.0",
|
||||||
]
|
]
|
||||||
[project.optional-dependencies]
|
[project.optional-dependencies]
|
||||||
mariadb = [
|
mariadb = [
|
||||||
@@ -101,25 +100,25 @@ dev = [
|
|||||||
{ include-group = "testing" },
|
{ include-group = "testing" },
|
||||||
]
|
]
|
||||||
docs = [
|
docs = [
|
||||||
"zensical>=0.0.47",
|
"zensical>=0.0.51",
|
||||||
]
|
]
|
||||||
lint = [
|
lint = [
|
||||||
"prek~=0.3.10",
|
"prek~=0.4.11",
|
||||||
"ruff~=0.15.20",
|
"ruff~=0.16.1",
|
||||||
]
|
]
|
||||||
testing = [
|
testing = [
|
||||||
"daphne",
|
"daphne",
|
||||||
"factory-boy~=3.3.1",
|
"factory-boy~=3.3.1",
|
||||||
"faker~=40.15.0",
|
"faker~=40.36.0",
|
||||||
"imagehash",
|
"imagehash",
|
||||||
"pytest~=9.0.3",
|
"pytest~=9.1.1",
|
||||||
"pytest-cov~=7.1.0",
|
"pytest-cov~=7.1.0",
|
||||||
"pytest-django~=4.12.0",
|
"pytest-django~=4.12.0",
|
||||||
"pytest-env~=1.6.0",
|
"pytest-env~=1.7.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.4",
|
||||||
"pytest-sugar",
|
"pytest-sugar",
|
||||||
"pytest-xdist~=3.8.0",
|
"pytest-xdist~=3.8.0",
|
||||||
"time-machine>=2.13",
|
"time-machine>=2.13",
|
||||||
@@ -183,6 +182,7 @@ output-format = "grouped"
|
|||||||
line-ending = "lf"
|
line-ending = "lf"
|
||||||
[tool.ruff.lint]
|
[tool.ruff.lint]
|
||||||
# https://docs.astral.sh/ruff/rules/
|
# https://docs.astral.sh/ruff/rules/
|
||||||
|
select = [ "E4", "E7", "E9", "F" ]
|
||||||
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
|
||||||
|
|||||||
+12
-9
@@ -56,13 +56,13 @@
|
|||||||
},
|
},
|
||||||
"architect": {
|
"architect": {
|
||||||
"build": {
|
"build": {
|
||||||
"builder": "@angular-builders/custom-webpack:browser",
|
"builder": "@angular/build:application",
|
||||||
"options": {
|
"options": {
|
||||||
"customWebpackConfig": {
|
"outputPath": {
|
||||||
"path": "./extra-webpack.config.ts"
|
"base": "dist/paperless-ui",
|
||||||
|
"browser": ""
|
||||||
},
|
},
|
||||||
"outputPath": "dist/paperless-ui",
|
"browser": "src/main.ts",
|
||||||
"main": "src/main.ts",
|
|
||||||
"outputHashing": "none",
|
"outputHashing": "none",
|
||||||
"index": "src/index.html",
|
"index": "src/index.html",
|
||||||
"polyfills": [
|
"polyfills": [
|
||||||
@@ -97,6 +97,7 @@
|
|||||||
"scripts": [],
|
"scripts": [],
|
||||||
"allowedCommonJsDependencies": [
|
"allowedCommonJsDependencies": [
|
||||||
"file-saver",
|
"file-saver",
|
||||||
|
"mime-names",
|
||||||
"utif"
|
"utif"
|
||||||
],
|
],
|
||||||
"extractLicenses": false,
|
"extractLicenses": false,
|
||||||
@@ -117,11 +118,13 @@
|
|||||||
"with": "src/environments/environment.prod.ts"
|
"with": "src/environments/environment.prod.ts"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"outputPath": "../src/documents/static/frontend/",
|
"outputPath": {
|
||||||
|
"base": "../src/documents/static/frontend/",
|
||||||
|
"browser": ""
|
||||||
|
},
|
||||||
"optimization": true,
|
"optimization": true,
|
||||||
"outputHashing": "none",
|
"outputHashing": "none",
|
||||||
"sourceMap": false,
|
"sourceMap": false,
|
||||||
"namedChunks": false,
|
|
||||||
"extractLicenses": true,
|
"extractLicenses": true,
|
||||||
"budgets": [
|
"budgets": [
|
||||||
{
|
{
|
||||||
@@ -145,7 +148,7 @@
|
|||||||
"defaultConfiguration": ""
|
"defaultConfiguration": ""
|
||||||
},
|
},
|
||||||
"serve": {
|
"serve": {
|
||||||
"builder": "@angular-builders/custom-webpack:dev-server",
|
"builder": "@angular/build:dev-server",
|
||||||
"options": {
|
"options": {
|
||||||
"buildTarget": "paperless-ui:build:en-US"
|
"buildTarget": "paperless-ui:build:en-US"
|
||||||
},
|
},
|
||||||
@@ -156,7 +159,7 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"extract-i18n": {
|
"extract-i18n": {
|
||||||
"builder": "@angular-builders/custom-webpack:extract-i18n",
|
"builder": "@angular/build:extract-i18n",
|
||||||
"options": {
|
"options": {
|
||||||
"buildTarget": "paperless-ui:build"
|
"buildTarget": "paperless-ui:build"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,24 +0,0 @@
|
|||||||
import {
|
|
||||||
CustomWebpackBrowserSchema,
|
|
||||||
TargetOptions,
|
|
||||||
} from '@angular-builders/custom-webpack'
|
|
||||||
import * as webpack from 'webpack'
|
|
||||||
const { codecovWebpackPlugin } = require('@codecov/webpack-plugin')
|
|
||||||
|
|
||||||
export default (
|
|
||||||
config: webpack.Configuration,
|
|
||||||
options: CustomWebpackBrowserSchema,
|
|
||||||
targetOptions: TargetOptions
|
|
||||||
) => {
|
|
||||||
if (config.plugins) {
|
|
||||||
config.plugins.push(
|
|
||||||
codecovWebpackPlugin({
|
|
||||||
enableBundleAnalysis: process.env.CODECOV_TOKEN !== undefined,
|
|
||||||
bundleName: 'paperless-ngx',
|
|
||||||
uploadToken: process.env.CODECOV_TOKEN,
|
|
||||||
})
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
return config
|
|
||||||
}
|
|
||||||
+1219
-821
File diff suppressed because it is too large
Load Diff
+28
-31
@@ -11,16 +11,16 @@
|
|||||||
},
|
},
|
||||||
"private": true,
|
"private": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@angular/cdk": "^22.0.3",
|
"@angular/cdk": "^22.0.6",
|
||||||
"@angular/common": "~22.0.5",
|
"@angular/common": "~22.1.0",
|
||||||
"@angular/compiler": "~22.0.5",
|
"@angular/compiler": "~22.1.0",
|
||||||
"@angular/core": "~22.0.5",
|
"@angular/core": "~22.1.0",
|
||||||
"@angular/forms": "~22.0.5",
|
"@angular/forms": "~22.1.0",
|
||||||
"@angular/localize": "~22.0.5",
|
"@angular/localize": "~22.1.0",
|
||||||
"@angular/platform-browser": "~22.0.5",
|
"@angular/platform-browser": "~22.1.0",
|
||||||
"@angular/router": "~22.0.5",
|
"@angular/router": "~22.1.0",
|
||||||
"@ng-bootstrap/ng-bootstrap": "^21.0.0",
|
"@ng-bootstrap/ng-bootstrap": "^21.0.0",
|
||||||
"@ng-select/ng-select": "^23.2.0",
|
"@ng-select/ng-select": "^23.5.0",
|
||||||
"@ngneat/dirty-check-forms": "^3.0.3",
|
"@ngneat/dirty-check-forms": "^3.0.3",
|
||||||
"@popperjs/core": "^2.11.8",
|
"@popperjs/core": "^2.11.8",
|
||||||
"bootstrap": "^5.3.8",
|
"bootstrap": "^5.3.8",
|
||||||
@@ -32,33 +32,31 @@
|
|||||||
"ngx-device-detector": "^12.0.0",
|
"ngx-device-detector": "^12.0.0",
|
||||||
"ngx-ui-tour-ng-bootstrap": "^19.0.0",
|
"ngx-ui-tour-ng-bootstrap": "^19.0.0",
|
||||||
"normalize-diacritics": "^5.0.0",
|
"normalize-diacritics": "^5.0.0",
|
||||||
"pdfjs-dist": "^6.0.227",
|
"pdfjs-dist": "^6.2.108",
|
||||||
"rxjs": "^7.8.2",
|
"rxjs": "^7.8.2",
|
||||||
"tslib": "^2.8.1",
|
"tslib": "^2.8.1",
|
||||||
"utif": "^3.1.0",
|
"utif": "^3.1.0",
|
||||||
"uuid": "^14.0.1"
|
"uuid": "^14.0.1"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@angular-builders/custom-webpack": "^22.0.1",
|
|
||||||
"@angular-builders/jest": "^22.0.1",
|
"@angular-builders/jest": "^22.0.1",
|
||||||
"@angular-devkit/core": "^22.0.5",
|
"@angular-devkit/core": "^22.1.2",
|
||||||
"@angular-devkit/schematics": "^22.0.5",
|
"@angular-devkit/schematics": "^22.1.2",
|
||||||
"@angular-eslint/builder": "22.0.0",
|
"@angular-eslint/builder": "22.1.0",
|
||||||
"@angular-eslint/eslint-plugin": "22.0.0",
|
"@angular-eslint/eslint-plugin": "22.1.0",
|
||||||
"@angular-eslint/eslint-plugin-template": "22.0.0",
|
"@angular-eslint/eslint-plugin-template": "22.1.0",
|
||||||
"@angular-eslint/schematics": "22.0.0",
|
"@angular-eslint/schematics": "22.1.0",
|
||||||
"@angular-eslint/template-parser": "22.0.0",
|
"@angular-eslint/template-parser": "22.1.0",
|
||||||
"@angular/build": "^22.0.5",
|
"@angular/build": "22.1.2",
|
||||||
"@angular/cli": "~22.0.5",
|
"@angular/cli": "22.1.2",
|
||||||
"@angular/compiler-cli": "~22.0.5",
|
"@angular/compiler-cli": "~22.1.0",
|
||||||
"@codecov/webpack-plugin": "^2.0.1",
|
"@playwright/test": "^1.62.0",
|
||||||
"@playwright/test": "^1.61.1",
|
|
||||||
"@types/jest": "^30.0.0",
|
"@types/jest": "^30.0.0",
|
||||||
"@types/node": "^26.0.0",
|
"@types/node": "^26.1.1",
|
||||||
"@typescript-eslint/eslint-plugin": "^8.62.0",
|
"@typescript-eslint/eslint-plugin": "^8.65.0",
|
||||||
"@typescript-eslint/parser": "^8.62.0",
|
"@typescript-eslint/parser": "^8.65.0",
|
||||||
"@typescript-eslint/utils": "^8.62.0",
|
"@typescript-eslint/utils": "^8.65.0",
|
||||||
"eslint": "^10.5.0",
|
"eslint": "^10.8.0",
|
||||||
"jest": "30.4.2",
|
"jest": "30.4.2",
|
||||||
"jest-environment-jsdom": "^30.4.1",
|
"jest-environment-jsdom": "^30.4.1",
|
||||||
"jest-junit": "^17.0.0",
|
"jest-junit": "^17.0.0",
|
||||||
@@ -66,8 +64,7 @@
|
|||||||
"jest-websocket-mock": "^2.5.0",
|
"jest-websocket-mock": "^2.5.0",
|
||||||
"prettier-plugin-organize-imports": "^4.3.0",
|
"prettier-plugin-organize-imports": "^4.3.0",
|
||||||
"ts-node": "~10.9.1",
|
"ts-node": "~10.9.1",
|
||||||
"typescript": "^6.0.3",
|
"typescript": "^6.0.3"
|
||||||
"webpack": "^5.107.2"
|
|
||||||
},
|
},
|
||||||
"packageManager": "pnpm@10.26.0"
|
"packageManager": "pnpm@11.15.1"
|
||||||
}
|
}
|
||||||
|
|||||||
Generated
+2177
-1981
File diff suppressed because it is too large
Load Diff
@@ -5,6 +5,7 @@ trustPolicy: no-downgrade
|
|||||||
trustPolicyExclude:
|
trustPolicyExclude:
|
||||||
- "chokidar@4.0.3"
|
- "chokidar@4.0.3"
|
||||||
- "semver@6.3.1 || 5.7.2"
|
- "semver@6.3.1 || 5.7.2"
|
||||||
|
blockExoticSubdeps: true
|
||||||
allowBuilds:
|
allowBuilds:
|
||||||
"@parcel/watcher": true
|
"@parcel/watcher": true
|
||||||
canvas: true
|
canvas: true
|
||||||
|
|||||||
@@ -14,43 +14,48 @@
|
|||||||
<a ngbNavLink>{{category}}</a>
|
<a ngbNavLink>{{category}}</a>
|
||||||
<ng-template ngbNavContent>
|
<ng-template ngbNavContent>
|
||||||
<div class="p-3">
|
<div class="p-3">
|
||||||
<div class="row row-cols-1 row-cols-md-2 row-cols-lg-3 g-2">
|
@for (section of getCategorySections(category); track section) {
|
||||||
@for (option of getCategoryOptions(category); track option.key) {
|
@if (section) {
|
||||||
<div class="col">
|
<h5 class="mt-4 mb-3">{{section}}</h5>
|
||||||
<div class="card bg-light">
|
}
|
||||||
<div class="card-body">
|
<div class="row row-cols-1 row-cols-md-2 row-cols-lg-3 g-2">
|
||||||
<div class="card-title d-flex align-items-center">
|
@for (option of getCategoryOptions(category, section); track option.key) {
|
||||||
<h6 class="mb-0">
|
<div class="col">
|
||||||
{{option.title}}
|
<div class="card bg-light">
|
||||||
</h6>
|
<div class="card-body">
|
||||||
<a class="btn btn-sm btn-link" title="Read the documentation about this setting" i18n-title [href]="getDocsUrl(option.config_key)" target="_blank" referrerpolicy="no-referrer">
|
<div class="card-title d-flex align-items-center">
|
||||||
<i-bs name="info-circle"></i-bs>
|
<h6 class="mb-0">
|
||||||
</a>
|
{{option.title}}
|
||||||
@if (isSet(option.key)) {
|
</h6>
|
||||||
<button type="button" class="btn btn-sm btn-link text-danger ms-auto pe-0" title="Reset" i18n-title (click)="resetOption(option.key)">
|
<a class="btn btn-sm btn-link" title="Read the documentation about this setting" i18n-title [href]="getDocsUrl(option.config_key)" target="_blank" referrerpolicy="no-referrer">
|
||||||
<i-bs class="me-1" name="x"></i-bs><ng-container i18n>Reset</ng-container>
|
<i-bs name="info-circle"></i-bs>
|
||||||
</button>
|
</a>
|
||||||
|
@if (isSet(option.key)) {
|
||||||
|
<button type="button" class="btn btn-sm btn-link text-danger ms-auto pe-0" title="Reset" i18n-title (click)="resetOption(option.key)">
|
||||||
|
<i-bs class="me-1" name="x"></i-bs><ng-container i18n>Reset</ng-container>
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
<div class="mb-n3">
|
||||||
|
@switch (option.type) {
|
||||||
|
@case (ConfigOptionType.Select) { <pngx-input-select [formControlName]="option.key" [error]="errors[option.key]" [items]="option.choices" [allowNull]="true"></pngx-input-select> }
|
||||||
|
@case (ConfigOptionType.Number) { <pngx-input-number [formControlName]="option.key" [error]="errors[option.key]" [showAdd]="false"></pngx-input-number> }
|
||||||
|
@case (ConfigOptionType.Boolean) { <pngx-input-switch [formControlName]="option.key" [error]="errors[option.key]" [showUnsetNote]="true" [horizontal]="true" title="Enable" i18n-title></pngx-input-switch> }
|
||||||
|
@case (ConfigOptionType.String) { <pngx-input-text [formControlName]="option.key" [error]="errors[option.key]"></pngx-input-text> }
|
||||||
|
@case (ConfigOptionType.JSON) { <pngx-input-text [formControlName]="option.key" [error]="errors[option.key]"></pngx-input-text> }
|
||||||
|
@case (ConfigOptionType.File) { <pngx-input-file [formControlName]="option.key" (upload)="uploadFile($event, option.key)" [error]="errors[option.key]"></pngx-input-file> }
|
||||||
|
@case (ConfigOptionType.Password) { <pngx-input-password [formControlName]="option.key" [error]="errors[option.key]"></pngx-input-password> }
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
@if (option.note) {
|
||||||
|
<div class="form-text fst-italic">{{option.note}}</div>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
<div class="mb-n3">
|
|
||||||
@switch (option.type) {
|
|
||||||
@case (ConfigOptionType.Select) { <pngx-input-select [formControlName]="option.key" [error]="errors[option.key]" [items]="option.choices" [allowNull]="true"></pngx-input-select> }
|
|
||||||
@case (ConfigOptionType.Number) { <pngx-input-number [formControlName]="option.key" [error]="errors[option.key]" [showAdd]="false"></pngx-input-number> }
|
|
||||||
@case (ConfigOptionType.Boolean) { <pngx-input-switch [formControlName]="option.key" [error]="errors[option.key]" [showUnsetNote]="true" [horizontal]="true" title="Enable" i18n-title></pngx-input-switch> }
|
|
||||||
@case (ConfigOptionType.String) { <pngx-input-text [formControlName]="option.key" [error]="errors[option.key]"></pngx-input-text> }
|
|
||||||
@case (ConfigOptionType.JSON) { <pngx-input-text [formControlName]="option.key" [error]="errors[option.key]"></pngx-input-text> }
|
|
||||||
@case (ConfigOptionType.File) { <pngx-input-file [formControlName]="option.key" (upload)="uploadFile($event, option.key)" [error]="errors[option.key]"></pngx-input-file> }
|
|
||||||
@case (ConfigOptionType.Password) { <pngx-input-password [formControlName]="option.key" [error]="errors[option.key]"></pngx-input-password> }
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
@if (option.note) {
|
|
||||||
<div class="form-text fst-italic">{{option.note}}</div>
|
|
||||||
}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
}
|
||||||
}
|
</div>
|
||||||
</div>
|
}
|
||||||
</div>
|
</div>
|
||||||
</ng-template>
|
</ng-template>
|
||||||
</li>
|
</li>
|
||||||
|
|||||||
@@ -8,7 +8,11 @@ import { NgbModule } from '@ng-bootstrap/ng-bootstrap'
|
|||||||
import { NgSelectModule } from '@ng-select/ng-select'
|
import { NgSelectModule } from '@ng-select/ng-select'
|
||||||
import { NgxBootstrapIconsModule, allIcons } from 'ngx-bootstrap-icons'
|
import { NgxBootstrapIconsModule, allIcons } from 'ngx-bootstrap-icons'
|
||||||
import { of, throwError } from 'rxjs'
|
import { of, throwError } from 'rxjs'
|
||||||
import { OutputTypeConfig } from 'src/app/data/paperless-config'
|
import {
|
||||||
|
ConfigCategory,
|
||||||
|
ConfigSection,
|
||||||
|
OutputTypeConfig,
|
||||||
|
} from 'src/app/data/paperless-config'
|
||||||
import { ConfigService } from 'src/app/services/config.service'
|
import { ConfigService } from 'src/app/services/config.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'
|
||||||
@@ -158,4 +162,23 @@ describe('ConfigComponent', () => {
|
|||||||
component.resetOption('barcodes_enabled')
|
component.resetOption('barcodes_enabled')
|
||||||
expect(component.configForm.get('barcodes_enabled').value).toBeNull()
|
expect(component.configForm.get('barcodes_enabled').value).toBeNull()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('should group options into sections within a category, or not', () => {
|
||||||
|
const sections = component.getCategorySections(ConfigCategory.OCR)
|
||||||
|
expect(sections).toEqual([null, ConfigSection.RemoteOCR])
|
||||||
|
expect(
|
||||||
|
component
|
||||||
|
.getCategoryOptions(ConfigCategory.OCR)
|
||||||
|
.map((option) => option.key)
|
||||||
|
).toContain('output_type')
|
||||||
|
expect(
|
||||||
|
component
|
||||||
|
.getCategoryOptions(ConfigCategory.OCR, ConfigSection.RemoteOCR)
|
||||||
|
.map((option) => option.key)
|
||||||
|
).toEqual([
|
||||||
|
'remote_ocr_engine',
|
||||||
|
'remote_ocr_api_key',
|
||||||
|
'remote_ocr_endpoint',
|
||||||
|
])
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -74,8 +74,20 @@ export class ConfigComponent
|
|||||||
return Object.values(ConfigCategory)
|
return Object.values(ConfigCategory)
|
||||||
}
|
}
|
||||||
|
|
||||||
getCategoryOptions(category: string): ConfigOption[] {
|
getCategorySections(category: string): string[] {
|
||||||
return PaperlessConfigOptions.filter((o) => o.category === category)
|
return [
|
||||||
|
...new Set(
|
||||||
|
PaperlessConfigOptions.filter((o) => o.category === category).map(
|
||||||
|
(o) => o.section ?? null // null means no section
|
||||||
|
)
|
||||||
|
),
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
getCategoryOptions(category: string, section: string = null): ConfigOption[] {
|
||||||
|
return PaperlessConfigOptions.filter(
|
||||||
|
(o) => o.category === category && (o.section ?? null) === section
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
initialConfig: PaperlessConfig
|
initialConfig: PaperlessConfig
|
||||||
|
|||||||
@@ -104,14 +104,14 @@
|
|||||||
</h6>
|
</h6>
|
||||||
<ul class="nav flex-column mb-2" cdkDropList (cdkDropListDropped)="onDrop($event)">
|
<ul class="nav flex-column mb-2" cdkDropList (cdkDropListDropped)="onDrop($event)">
|
||||||
@for (view of savedViewService.sidebarViews; track view.id) {
|
@for (view of savedViewService.sidebarViews; track view.id) {
|
||||||
<li class="nav-item w-100 app-link" cdkDrag [cdkDragDisabled]="!settingsService.organizingSidebarSavedViews()"
|
<li class="nav-item w-100 app-link" cdkDrag [cdkDragDisabled]="!settingsService.organizingSidebarSavedViews() || !canSaveSettings"
|
||||||
cdkDragPreviewContainer="parent" cdkDragPreviewClass="navItemDrag" (cdkDragStarted)="onDragStart($event)"
|
cdkDragPreviewContainer="parent" cdkDragPreviewClass="navItemDrag" (cdkDragStarted)="onDragStart($event)"
|
||||||
(cdkDragEnded)="onDragEnd($event)">
|
(cdkDragEnded)="onDragEnd($event)">
|
||||||
<a class="nav-link" routerLink="view/{{view.id}}"
|
<a class="nav-link" routerLink="view/{{view.id}}"
|
||||||
routerLinkActive="active" (click)="closeMenu()" [ngbPopover]="view.name"
|
routerLinkActive="active" (click)="closeMenu()" [ngbPopover]="view.name"
|
||||||
[disablePopover]="!slimSidebarEnabled" placement="end" container="body" triggers="mouseenter:mouseleave"
|
[disablePopover]="!slimSidebarEnabled" placement="end" container="body" triggers="mouseenter:mouseleave"
|
||||||
popoverClass="popover-slim">
|
popoverClass="popover-slim">
|
||||||
<i-bs class="me-2" name="funnel"></i-bs><span><div class="d-inline-flex view-name"><span class="overflow-hidden" [class.text-wrap]="!slimSidebarEnabled">{{view.name}}</span></div>
|
<i-bs class="me-2" [name]="view.icon || 'funnel'"></i-bs><span><div class="d-inline-flex view-name"><span class="overflow-hidden" [class.text-wrap]="!slimSidebarEnabled">{{view.name}}</span></div>
|
||||||
@if (showSidebarCounts && !slimSidebarEnabled) {
|
@if (showSidebarCounts && !slimSidebarEnabled) {
|
||||||
<span class="badge bg-info text-dark ms-2 d-inline">{{ savedViewService.getDocumentCount(view) }}</span>
|
<span class="badge bg-info text-dark ms-2 d-inline">{{ savedViewService.getDocumentCount(view) }}</span>
|
||||||
}
|
}
|
||||||
@@ -120,7 +120,7 @@
|
|||||||
<span class="badge bg-info text-dark position-absolute top-0 end-0 d-none d-md-block">{{ savedViewService.getDocumentCount(view) }}</span>
|
<span class="badge bg-info text-dark position-absolute top-0 end-0 d-none d-md-block">{{ savedViewService.getDocumentCount(view) }}</span>
|
||||||
}
|
}
|
||||||
</a>
|
</a>
|
||||||
@if (settingsService.organizingSidebarSavedViews()) {
|
@if (settingsService.organizingSidebarSavedViews() && canSaveSettings) {
|
||||||
<div class="position-absolute end-0 top-0 px-3 py-2" [class.me-n3]="slimSidebarEnabled" cdkDragHandle>
|
<div class="position-absolute end-0 top-0 px-3 py-2" [class.me-n3]="slimSidebarEnabled" cdkDragHandle>
|
||||||
<i-bs name="grip-vertical"></i-bs>
|
<i-bs name="grip-vertical"></i-bs>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+24
@@ -1019,4 +1019,28 @@ describe('WorkflowEditDialogComponent', () => {
|
|||||||
'pass3',
|
'pass3',
|
||||||
])
|
])
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('should parse passwords again when retrying a failed save', () => {
|
||||||
|
component.object = {
|
||||||
|
name: 'Workflow with blank Passwords',
|
||||||
|
id: 1,
|
||||||
|
order: null,
|
||||||
|
enabled: true,
|
||||||
|
triggers: [],
|
||||||
|
actions: [
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
type: WorkflowActionType.PasswordRemoval,
|
||||||
|
passwords: [],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
component.ngOnInit()
|
||||||
|
|
||||||
|
component.save()
|
||||||
|
component.objectForm.get('order').setValue(1)
|
||||||
|
|
||||||
|
expect(() => component.save()).not.toThrow()
|
||||||
|
expect(component.objectForm.get('actions').value[0].passwords).toEqual([])
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
+2
-3
@@ -1207,9 +1207,8 @@ export class WorkflowEditDialogComponent
|
|||||||
return passwords.join('\n')
|
return passwords.join('\n')
|
||||||
}
|
}
|
||||||
|
|
||||||
private parsePasswords(value: string = ''): string[] {
|
private parsePasswords(value: string | string[] = ''): string[] {
|
||||||
return value
|
return (Array.isArray(value) ? value : value.split(/[\n,]+/))
|
||||||
.split(/[\n,]+/)
|
|
||||||
.map((entry) => entry.trim())
|
.map((entry) => entry.trim())
|
||||||
.filter((entry) => entry.length > 0)
|
.filter((entry) => entry.length > 0)
|
||||||
}
|
}
|
||||||
|
|||||||
+4
-4
@@ -52,10 +52,10 @@ describe('CustomFieldsValuesComponent', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
it('should set selectedFields and map values correctly', () => {
|
it('should set selectedFields and map values correctly', () => {
|
||||||
component.value = { 1: 'value1' }
|
component.value = { 1: 'value1', 3: 0, 4: false }
|
||||||
component.selectedFields = [1, 2]
|
component.selectedFields = [1, 2, 3, 4]
|
||||||
expect(component.selectedFields).toEqual([1, 2])
|
expect(component.selectedFields).toEqual([1, 2, 3, 4])
|
||||||
expect(component.value).toEqual({ 1: 'value1', 2: null })
|
expect(component.value).toEqual({ 1: 'value1', 2: null, 3: 0, 4: false })
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should return the correct custom field by id', () => {
|
it('should return the correct custom field by id', () => {
|
||||||
|
|||||||
+1
-1
@@ -77,7 +77,7 @@ export class CustomFieldsValuesComponent extends AbstractInputComponent<Object>
|
|||||||
this._selectedFields = newFields
|
this._selectedFields = newFields
|
||||||
// map the selected fields to an object with field_id as key and value as value
|
// map the selected fields to an object with field_id as key and value as value
|
||||||
this.value = newFields.reduce((acc, fieldId) => {
|
this.value = newFields.reduce((acc, fieldId) => {
|
||||||
acc[fieldId] = this.value?.[fieldId] || null
|
acc[fieldId] = this.value?.[fieldId] ?? null
|
||||||
return acc
|
return acc
|
||||||
}, {})
|
}, {})
|
||||||
this.onChange(this.value)
|
this.onChange(this.value)
|
||||||
|
|||||||
@@ -36,7 +36,16 @@
|
|||||||
(focus)="clearLastSearchTerm()"
|
(focus)="clearLastSearchTerm()"
|
||||||
(clear)="clearLastSearchTerm()"
|
(clear)="clearLastSearchTerm()"
|
||||||
(blur)="onBlur()">
|
(blur)="onBlur()">
|
||||||
|
<ng-template ng-label-tmp let-item="item">
|
||||||
|
@if (iconField && item[iconField]) {
|
||||||
|
<i-bs class="me-2" [name]="item[iconField]"></i-bs>
|
||||||
|
}
|
||||||
|
<span [title]="item[bindLabel]">{{item[bindLabel]}}</span>
|
||||||
|
</ng-template>
|
||||||
<ng-template ng-option-tmp let-item="item">
|
<ng-template ng-option-tmp let-item="item">
|
||||||
|
@if (iconField && item[iconField]) {
|
||||||
|
<i-bs class="me-2" [name]="item[iconField]"></i-bs>
|
||||||
|
}
|
||||||
<span [title]="item[bindLabel]">{{item[bindLabel]}}</span>
|
<span [title]="item[bindLabel]">{{item[bindLabel]}}</span>
|
||||||
</ng-template>
|
</ng-template>
|
||||||
</ng-select>
|
</ng-select>
|
||||||
|
|||||||
@@ -35,7 +35,7 @@ import { AbstractInputComponent } from '../abstract-input'
|
|||||||
NgxBootstrapIconsModule,
|
NgxBootstrapIconsModule,
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
export class SelectComponent extends AbstractInputComponent<number> {
|
export class SelectComponent extends AbstractInputComponent<number | string> {
|
||||||
constructor() {
|
constructor() {
|
||||||
super()
|
super()
|
||||||
this.addItemRef = this.addItem.bind(this)
|
this.addItemRef = this.addItem.bind(this)
|
||||||
@@ -100,6 +100,9 @@ export class SelectComponent extends AbstractInputComponent<number> {
|
|||||||
@Input()
|
@Input()
|
||||||
bindLabel: string = 'name'
|
bindLabel: string = 'name'
|
||||||
|
|
||||||
|
@Input()
|
||||||
|
iconField: string
|
||||||
|
|
||||||
public searchFn = (term: string, item: any): boolean =>
|
public searchFn = (term: string, item: any): boolean =>
|
||||||
matchesSearchText(item?.[this.bindLabel], term)
|
matchesSearchText(item?.[this.bindLabel], term)
|
||||||
|
|
||||||
|
|||||||
@@ -151,6 +151,13 @@
|
|||||||
inset: 0;
|
inset: 0;
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
|
|
||||||
|
& section {
|
||||||
|
position: absolute;
|
||||||
|
text-align: initial;
|
||||||
|
box-sizing: border-box;
|
||||||
|
transform-origin: 0 0;
|
||||||
|
}
|
||||||
|
|
||||||
& .annotationTextContent {
|
& .annotationTextContent {
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -90,6 +90,7 @@ describe('PngxPdfViewerComponent', () => {
|
|||||||
}
|
}
|
||||||
expect(viewer).toBeInstanceOf(PDFSinglePageViewer)
|
expect(viewer).toBeInstanceOf(PDFSinglePageViewer)
|
||||||
expect(viewer.options.textLayerMode).toBe(0)
|
expect(viewer.options.textLayerMode).toBe(0)
|
||||||
|
expect(viewer.options.enableSelectionRendering).toBe(false)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('applies zoom, rotation, and page changes', async () => {
|
it('applies zoom, rotation, and page changes', async () => {
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import {
|
|||||||
ViewChild,
|
ViewChild,
|
||||||
} from '@angular/core'
|
} from '@angular/core'
|
||||||
import {
|
import {
|
||||||
|
AnnotationMode,
|
||||||
getDocument,
|
getDocument,
|
||||||
GlobalWorkerOptions,
|
GlobalWorkerOptions,
|
||||||
PDFDocumentLoadingTask,
|
PDFDocumentLoadingTask,
|
||||||
@@ -221,6 +222,8 @@ export class PngxPdfViewerComponent
|
|||||||
linkService: this.linkService,
|
linkService: this.linkService,
|
||||||
findController: this.findController,
|
findController: this.findController,
|
||||||
textLayerMode,
|
textLayerMode,
|
||||||
|
annotationMode: AnnotationMode.ENABLE,
|
||||||
|
enableSelectionRendering: false,
|
||||||
removePageBorders: true,
|
removePageBorders: true,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+9
@@ -17,6 +17,10 @@ const permissions = [
|
|||||||
'view_document',
|
'view_document',
|
||||||
'change_document',
|
'change_document',
|
||||||
'delete_document',
|
'delete_document',
|
||||||
|
'add_sharelinkbundle',
|
||||||
|
'view_sharelinkbundle',
|
||||||
|
'change_sharelinkbundle',
|
||||||
|
'delete_sharelinkbundle',
|
||||||
'change_tag',
|
'change_tag',
|
||||||
'view_documenttype',
|
'view_documenttype',
|
||||||
]
|
]
|
||||||
@@ -75,6 +79,7 @@ describe('PermissionsSelectComponent', () => {
|
|||||||
component.ngOnInit()
|
component.ngOnInit()
|
||||||
component.writeValue(permissions)
|
component.writeValue(permissions)
|
||||||
expect(component.typesWithAllActions).toContain('Document')
|
expect(component.typesWithAllActions).toContain('Document')
|
||||||
|
expect(component.typesWithAllActions).toContain('ShareLinkBundle')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should update checkboxes on permissions set', () => {
|
it('should update checkboxes on permissions set', () => {
|
||||||
@@ -85,6 +90,10 @@ describe('PermissionsSelectComponent', () => {
|
|||||||
expect(input1.nativeElement.checked).toBeTruthy()
|
expect(input1.nativeElement.checked).toBeTruthy()
|
||||||
const input2 = fixture.debugElement.query(By.css('input#Tag_Change'))
|
const input2 = fixture.debugElement.query(By.css('input#Tag_Change'))
|
||||||
expect(input2.nativeElement.checked).toBeTruthy()
|
expect(input2.nativeElement.checked).toBeTruthy()
|
||||||
|
const bundleInput = fixture.debugElement.query(
|
||||||
|
By.css('input#ShareLinkBundle_Add')
|
||||||
|
)
|
||||||
|
expect(bundleInput.nativeElement.checked).toBeTruthy()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('disable checkboxes when permissions are inherited', () => {
|
it('disable checkboxes when permissions are inherited', () => {
|
||||||
|
|||||||
+8
-2
@@ -2,10 +2,16 @@
|
|||||||
<button type="button" class="btn btn-sm btn-outline-primary" (click)="clickSuggest()" [disabled]="disabled() || loading() || (suggestions() && !aiEnabled())">
|
<button type="button" class="btn btn-sm btn-outline-primary" (click)="clickSuggest()" [disabled]="disabled() || loading() || (suggestions() && !aiEnabled())">
|
||||||
@if (loading()) {
|
@if (loading()) {
|
||||||
<div class="spinner-border spinner-border-sm" role="status"></div>
|
<div class="spinner-border spinner-border-sm" role="status"></div>
|
||||||
|
} @else if (noSuggestions) {
|
||||||
|
<i-bs width="1.2em" height="1.2em" name="check-circle"></i-bs>
|
||||||
} @else {
|
} @else {
|
||||||
<i-bs width="1.2em" height="1.2em" name="stars"></i-bs>
|
<i-bs width="1.2em" height="1.2em" name="stars"></i-bs>
|
||||||
}
|
}
|
||||||
<span class="d-none d-lg-inline ps-1" i18n>Suggest</span>
|
@if (noSuggestions) {
|
||||||
|
<span class="d-none d-lg-inline ps-1" i18n>No suggestions</span>
|
||||||
|
} @else {
|
||||||
|
<span class="d-none d-lg-inline ps-1" i18n>Suggest</span>
|
||||||
|
}
|
||||||
@if (totalSuggestions > 0) {
|
@if (totalSuggestions > 0) {
|
||||||
<span class="badge bg-primary ms-2">{{ totalSuggestions }}</span>
|
<span class="badge bg-primary ms-2">{{ totalSuggestions }}</span>
|
||||||
}
|
}
|
||||||
@@ -19,7 +25,7 @@
|
|||||||
|
|
||||||
<div ngbDropdownMenu aria-labelledby="suggestionsDropdown" class="shadow suggestions-dropdown">
|
<div ngbDropdownMenu aria-labelledby="suggestionsDropdown" class="shadow suggestions-dropdown">
|
||||||
<div class="list-group list-group-flush small pb-0">
|
<div class="list-group list-group-flush small pb-0">
|
||||||
@if (!suggestions()?.suggested_tags && !suggestions()?.suggested_document_types && !suggestions()?.suggested_correspondents) {
|
@if (totalSuggestions === 0) {
|
||||||
<div class="list-group-item text-muted fst-italic">
|
<div class="list-group-item text-muted fst-italic">
|
||||||
<small class="text-muted small fst-italic" i18n>No novel suggestions</small>
|
<small class="text-muted small fst-italic" i18n>No novel suggestions</small>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+29
@@ -30,6 +30,34 @@ describe('SuggestionsDropdownComponent', () => {
|
|||||||
expect(component.totalSuggestions).toBe(4)
|
expect(component.totalSuggestions).toBe(4)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('should show when a completed request returned no suggestions', () => {
|
||||||
|
fixture.componentRef.setInput('suggestions', {
|
||||||
|
correspondents: [],
|
||||||
|
tags: [],
|
||||||
|
document_types: [],
|
||||||
|
storage_paths: [],
|
||||||
|
dates: [],
|
||||||
|
})
|
||||||
|
fixture.detectChanges()
|
||||||
|
|
||||||
|
expect(component.noSuggestions).toBeTruthy()
|
||||||
|
expect(fixture.nativeElement.textContent).toContain('No suggestions')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should not show the empty state before a request or with suggestions', () => {
|
||||||
|
expect(component.noSuggestions).toBeFalsy()
|
||||||
|
|
||||||
|
fixture.componentRef.setInput('suggestions', {
|
||||||
|
correspondents: [],
|
||||||
|
tags: [42],
|
||||||
|
document_types: [],
|
||||||
|
storage_paths: [],
|
||||||
|
dates: [],
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(component.noSuggestions).toBeFalsy()
|
||||||
|
})
|
||||||
|
|
||||||
it('should emit getSuggestions when clickSuggest is called and suggestions are null', () => {
|
it('should emit getSuggestions when clickSuggest is called and suggestions are null', () => {
|
||||||
jest.spyOn(component.getSuggestions, 'emit')
|
jest.spyOn(component.getSuggestions, 'emit')
|
||||||
fixture.componentRef.setInput('suggestions', null)
|
fixture.componentRef.setInput('suggestions', null)
|
||||||
@@ -59,5 +87,6 @@ describe('SuggestionsDropdownComponent', () => {
|
|||||||
})
|
})
|
||||||
component.clickSuggest()
|
component.clickSuggest()
|
||||||
expect(component.dropdown.open).toBeTruthy()
|
expect(component.dropdown.open).toBeTruthy()
|
||||||
|
expect(fixture.nativeElement.textContent).toContain('No novel suggestions')
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
+17
@@ -61,4 +61,21 @@ export class SuggestionsDropdownComponent {
|
|||||||
this.suggestions()?.suggested_document_types?.length || 0
|
this.suggestions()?.suggested_document_types?.length || 0
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
get noSuggestions(): boolean {
|
||||||
|
const suggestions = this.suggestions()
|
||||||
|
return (
|
||||||
|
suggestions != null &&
|
||||||
|
!suggestions.title &&
|
||||||
|
!suggestions.tags?.length &&
|
||||||
|
!suggestions.suggested_tags?.length &&
|
||||||
|
!suggestions.correspondents?.length &&
|
||||||
|
!suggestions.suggested_correspondents?.length &&
|
||||||
|
!suggestions.document_types?.length &&
|
||||||
|
!suggestions.suggested_document_types?.length &&
|
||||||
|
!suggestions.storage_paths?.length &&
|
||||||
|
!suggestions.suggested_storage_paths?.length &&
|
||||||
|
!suggestions.dates?.length
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+1
@@ -1,6 +1,7 @@
|
|||||||
<pngx-widget-frame
|
<pngx-widget-frame
|
||||||
*pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.Document }"
|
*pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.Document }"
|
||||||
[title]="savedView.name"
|
[title]="savedView.name"
|
||||||
|
[titleIcon]="savedView.icon || 'funnel'"
|
||||||
[loading]="false"
|
[loading]="false"
|
||||||
[draggable]="savedView"
|
[draggable]="savedView"
|
||||||
>
|
>
|
||||||
|
|||||||
+6
-1
@@ -8,7 +8,12 @@
|
|||||||
<i-bs name="grip-vertical"></i-bs>
|
<i-bs name="grip-vertical"></i-bs>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
<h6 class="card-title mb-0">{{title()}}</h6>
|
<h6 class="card-title mb-0">
|
||||||
|
@if (titleIcon()) {
|
||||||
|
<i-bs class="me-2" [name]="titleIcon()"></i-bs>
|
||||||
|
}
|
||||||
|
{{title()}}
|
||||||
|
</h6>
|
||||||
<ng-content select="[title-badge]"></ng-content>
|
<ng-content select="[title-badge]"></ng-content>
|
||||||
@if (badge() !== null && badge() !== undefined) {
|
@if (badge() !== null && badge() !== undefined) {
|
||||||
<span class="badge bg-info text-dark ms-2">{{badge()}}</span>
|
<span class="badge bg-info text-dark ms-2">{{badge()}}</span>
|
||||||
|
|||||||
@@ -16,6 +16,8 @@ export class WidgetFrameComponent implements AfterViewInit {
|
|||||||
|
|
||||||
title = input<string>()
|
title = input<string>()
|
||||||
|
|
||||||
|
titleIcon = input<string>()
|
||||||
|
|
||||||
draggable = input<any>()
|
draggable = input<any>()
|
||||||
|
|
||||||
cardless = input(false)
|
cardless = input(false)
|
||||||
|
|||||||
@@ -2161,8 +2161,14 @@ describe('DocumentDetailComponent', () => {
|
|||||||
it('should support open share links and email modals', () => {
|
it('should support open share links and email modals', () => {
|
||||||
const modalSpy = jest.spyOn(modalService, 'open')
|
const modalSpy = jest.spyOn(modalService, 'open')
|
||||||
initNormally()
|
initNormally()
|
||||||
|
component.selectedVersionId.set(10)
|
||||||
component.openShareLinks()
|
component.openShareLinks()
|
||||||
expect(modalSpy).toHaveBeenCalled()
|
expect(modalSpy).toHaveBeenCalled()
|
||||||
|
expect(
|
||||||
|
(
|
||||||
|
modalSpy.mock.results[0].value as NgbModalRef
|
||||||
|
).componentInstance.documentId()
|
||||||
|
).toBe(10)
|
||||||
component.openEmailDocument()
|
component.openEmailDocument()
|
||||||
expect(modalSpy).toHaveBeenCalled()
|
expect(modalSpy).toHaveBeenCalled()
|
||||||
})
|
})
|
||||||
@@ -2191,9 +2197,6 @@ describe('DocumentDetailComponent', () => {
|
|||||||
const appendChildSpy = jest
|
const appendChildSpy = jest
|
||||||
.spyOn(document.body, 'appendChild')
|
.spyOn(document.body, 'appendChild')
|
||||||
.mockImplementation((node: Node) => node)
|
.mockImplementation((node: Node) => node)
|
||||||
const removeChildSpy = jest
|
|
||||||
.spyOn(document.body, 'removeChild')
|
|
||||||
.mockImplementation((node: Node) => node)
|
|
||||||
const createObjectURLSpy = jest
|
const createObjectURLSpy = jest
|
||||||
.spyOn(URL, 'createObjectURL')
|
.spyOn(URL, 'createObjectURL')
|
||||||
.mockReturnValue('blob:mock-url')
|
.mockReturnValue('blob:mock-url')
|
||||||
@@ -2212,6 +2215,7 @@ describe('DocumentDetailComponent', () => {
|
|||||||
src: '',
|
src: '',
|
||||||
onload: null,
|
onload: null,
|
||||||
contentWindow: mockContentWindow,
|
contentWindow: mockContentWindow,
|
||||||
|
remove: jest.fn(),
|
||||||
}
|
}
|
||||||
|
|
||||||
const createElementSpy = jest
|
const createElementSpy = jest
|
||||||
@@ -2255,12 +2259,11 @@ describe('DocumentDetailComponent', () => {
|
|||||||
mockContentWindow.onafterprint(new Event('afterprint'))
|
mockContentWindow.onafterprint(new Event('afterprint'))
|
||||||
}
|
}
|
||||||
|
|
||||||
expect(removeChildSpy).toHaveBeenCalledWith(mockIframe)
|
expect(mockIframe.remove).toHaveBeenCalled()
|
||||||
expect(revokeObjectURLSpy).toHaveBeenCalledWith('blob:mock-url')
|
expect(revokeObjectURLSpy).toHaveBeenCalledWith('blob:mock-url')
|
||||||
|
|
||||||
createElementSpy.mockRestore()
|
createElementSpy.mockRestore()
|
||||||
appendChildSpy.mockRestore()
|
appendChildSpy.mockRestore()
|
||||||
removeChildSpy.mockRestore()
|
|
||||||
createObjectURLSpy.mockRestore()
|
createObjectURLSpy.mockRestore()
|
||||||
revokeObjectURLSpy.mockRestore()
|
revokeObjectURLSpy.mockRestore()
|
||||||
})
|
})
|
||||||
@@ -2307,9 +2310,6 @@ describe('DocumentDetailComponent', () => {
|
|||||||
const appendChildSpy = jest
|
const appendChildSpy = jest
|
||||||
.spyOn(document.body, 'appendChild')
|
.spyOn(document.body, 'appendChild')
|
||||||
.mockImplementation((node: Node) => node)
|
.mockImplementation((node: Node) => node)
|
||||||
const removeChildSpy = jest
|
|
||||||
.spyOn(document.body, 'removeChild')
|
|
||||||
.mockImplementation((node: Node) => node)
|
|
||||||
const createObjectURLSpy = jest
|
const createObjectURLSpy = jest
|
||||||
.spyOn(URL, 'createObjectURL')
|
.spyOn(URL, 'createObjectURL')
|
||||||
.mockReturnValue('blob:mock-url')
|
.mockReturnValue('blob:mock-url')
|
||||||
@@ -2332,6 +2332,7 @@ describe('DocumentDetailComponent', () => {
|
|||||||
src: '',
|
src: '',
|
||||||
onload: null,
|
onload: null,
|
||||||
contentWindow: mockContentWindow,
|
contentWindow: mockContentWindow,
|
||||||
|
remove: jest.fn(),
|
||||||
}
|
}
|
||||||
|
|
||||||
const createElementSpy = jest
|
const createElementSpy = jest
|
||||||
@@ -2354,15 +2355,21 @@ describe('DocumentDetailComponent', () => {
|
|||||||
|
|
||||||
if (expectToast) {
|
if (expectToast) {
|
||||||
expect(toastSpy).toHaveBeenCalled()
|
expect(toastSpy).toHaveBeenCalled()
|
||||||
|
expect(mockIframe.remove).toHaveBeenCalled()
|
||||||
|
expect(revokeObjectURLSpy).toHaveBeenCalledWith('blob:mock-url')
|
||||||
} else {
|
} else {
|
||||||
expect(toastSpy).not.toHaveBeenCalled()
|
expect(toastSpy).not.toHaveBeenCalled()
|
||||||
|
expect(mockIframe.remove).not.toHaveBeenCalled()
|
||||||
|
expect(revokeObjectURLSpy).not.toHaveBeenCalled()
|
||||||
|
|
||||||
|
component.ngOnDestroy()
|
||||||
|
|
||||||
|
expect(mockIframe.remove).toHaveBeenCalled()
|
||||||
|
expect(revokeObjectURLSpy).toHaveBeenCalledWith('blob:mock-url')
|
||||||
}
|
}
|
||||||
expect(removeChildSpy).toHaveBeenCalledWith(mockIframe)
|
|
||||||
expect(revokeObjectURLSpy).toHaveBeenCalledWith('blob:mock-url')
|
|
||||||
|
|
||||||
createElementSpy.mockRestore()
|
createElementSpy.mockRestore()
|
||||||
appendChildSpy.mockRestore()
|
appendChildSpy.mockRestore()
|
||||||
removeChildSpy.mockRestore()
|
|
||||||
createObjectURLSpy.mockRestore()
|
createObjectURLSpy.mockRestore()
|
||||||
revokeObjectURLSpy.mockRestore()
|
revokeObjectURLSpy.mockRestore()
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -289,6 +289,8 @@ export class DocumentDetailComponent
|
|||||||
private incomingUpdateModal: NgbModalRef
|
private incomingUpdateModal: NgbModalRef
|
||||||
private pendingIncomingUpdate: IncomingDocumentUpdate
|
private pendingIncomingUpdate: IncomingDocumentUpdate
|
||||||
private lastLocalSaveModified: string | null = null
|
private lastLocalSaveModified: string | null = null
|
||||||
|
private printIframe: HTMLIFrameElement | null = null
|
||||||
|
private printBlobUrl: string | null = null
|
||||||
|
|
||||||
requiresPassword: boolean = false
|
requiresPassword: boolean = false
|
||||||
password: string
|
password: string
|
||||||
@@ -868,6 +870,7 @@ export class DocumentDetailComponent
|
|||||||
}
|
}
|
||||||
|
|
||||||
ngOnDestroy(): void {
|
ngOnDestroy(): void {
|
||||||
|
this.cleanupPrintDocument()
|
||||||
this.unsubscribeNotifier.next(this)
|
this.unsubscribeNotifier.next(this)
|
||||||
this.unsubscribeNotifier.complete()
|
this.unsubscribeNotifier.complete()
|
||||||
}
|
}
|
||||||
@@ -1889,6 +1892,7 @@ export class DocumentDetailComponent
|
|||||||
}
|
}
|
||||||
|
|
||||||
printDocument() {
|
printDocument() {
|
||||||
|
this.cleanupPrintDocument()
|
||||||
const selectedVersionId = this.getSelectedNonLatestVersionId()
|
const selectedVersionId = this.getSelectedNonLatestVersionId()
|
||||||
const printUrl = this.documentsService.getDownloadUrl(
|
const printUrl = this.documentsService.getDownloadUrl(
|
||||||
this.document().id,
|
this.document().id,
|
||||||
@@ -1902,6 +1906,8 @@ export class DocumentDetailComponent
|
|||||||
next: (blob) => {
|
next: (blob) => {
|
||||||
const blobUrl = URL.createObjectURL(blob)
|
const blobUrl = URL.createObjectURL(blob)
|
||||||
const iframe = document.createElement('iframe')
|
const iframe = document.createElement('iframe')
|
||||||
|
this.printIframe = iframe
|
||||||
|
this.printBlobUrl = blobUrl
|
||||||
iframe.style.position = 'fixed'
|
iframe.style.position = 'fixed'
|
||||||
iframe.style.right = '0'
|
iframe.style.right = '0'
|
||||||
iframe.style.bottom = '0'
|
iframe.style.bottom = '0'
|
||||||
@@ -1917,22 +1923,19 @@ export class DocumentDetailComponent
|
|||||||
iframe.contentWindow.focus()
|
iframe.contentWindow.focus()
|
||||||
iframe.contentWindow.print()
|
iframe.contentWindow.print()
|
||||||
iframe.contentWindow.onafterprint = () => {
|
iframe.contentWindow.onafterprint = () => {
|
||||||
document.body.removeChild(iframe)
|
this.cleanupPrintDocument()
|
||||||
URL.revokeObjectURL(blobUrl)
|
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
// FF throws cross-origin error on onafterprint
|
// FF throws cross-origin error on onafterprint
|
||||||
const isCrossOriginAfterPrintError =
|
const isCrossOriginAfterPrintError =
|
||||||
err instanceof DOMException &&
|
err instanceof DOMException &&
|
||||||
err.message.includes('onafterprint')
|
err.message.includes('onafterprint')
|
||||||
|
// FF throws here while print preview is still reading the iframe
|
||||||
|
// so keep it alive until the next print or teardown
|
||||||
if (!isCrossOriginAfterPrintError) {
|
if (!isCrossOriginAfterPrintError) {
|
||||||
this.toastService.showError($localize`Print failed.`, err)
|
this.toastService.showError($localize`Print failed.`, err)
|
||||||
|
timer(100).subscribe(() => this.cleanupPrintDocument())
|
||||||
}
|
}
|
||||||
timer(100).subscribe(() => {
|
|
||||||
// delay to avoid FF print failure
|
|
||||||
document.body.removeChild(iframe)
|
|
||||||
URL.revokeObjectURL(blobUrl)
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -1945,9 +1948,20 @@ export class DocumentDetailComponent
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private cleanupPrintDocument() {
|
||||||
|
if (this.printIframe) this.printIframe.remove()
|
||||||
|
this.printIframe = null
|
||||||
|
if (this.printBlobUrl) {
|
||||||
|
URL.revokeObjectURL(this.printBlobUrl)
|
||||||
|
this.printBlobUrl = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
public openShareLinks() {
|
public openShareLinks() {
|
||||||
const modal = this.modalService.open(ShareLinksDialogComponent)
|
const modal = this.modalService.open(ShareLinksDialogComponent)
|
||||||
modal.componentInstance.documentId.set(this.document().id)
|
modal.componentInstance.documentId.set(
|
||||||
|
this.selectedVersionId() ?? this.document().id
|
||||||
|
)
|
||||||
modal.componentInstance.hasArchiveVersion.set(
|
modal.componentInstance.hasArchiveVersion.set(
|
||||||
this.metadata()?.has_archive_version ??
|
this.metadata()?.has_archive_version ??
|
||||||
!!this.document()?.archived_file_name
|
!!this.document()?.archived_file_name
|
||||||
|
|||||||
@@ -97,7 +97,9 @@
|
|||||||
<div class="dropdown-menu shadow dropdown-menu-right" ngbDropdownMenu>
|
<div class="dropdown-menu shadow dropdown-menu-right" ngbDropdownMenu>
|
||||||
@if (!list.activeSavedViewId) {
|
@if (!list.activeSavedViewId) {
|
||||||
@for (view of savedViewService.allViews; track view) {
|
@for (view of savedViewService.allViews; track view) {
|
||||||
<button ngbDropdownItem (click)="loadViewConfig(view.id)">{{view.name}}</button>
|
<button ngbDropdownItem (click)="loadViewConfig(view.id)">
|
||||||
|
<i-bs class="me-2" [name]="view.icon || 'funnel'"></i-bs>{{view.name}}
|
||||||
|
</button>
|
||||||
}
|
}
|
||||||
@if (savedViewService.allViews.length > 0) {
|
@if (savedViewService.allViews.length > 0) {
|
||||||
<div class="dropdown-divider"></div>
|
<div class="dropdown-divider"></div>
|
||||||
|
|||||||
@@ -457,6 +457,7 @@ export class DocumentListComponent
|
|||||||
modal.componentInstance.buttonsEnabled.set(false)
|
modal.componentInstance.buttonsEnabled.set(false)
|
||||||
let savedView: SavedView = {
|
let savedView: SavedView = {
|
||||||
name: formValue.name,
|
name: formValue.name,
|
||||||
|
icon: formValue.icon,
|
||||||
filter_rules: this.list.filterRules,
|
filter_rules: this.list.filterRules,
|
||||||
sort_reverse: this.list.sortReverse,
|
sort_reverse: this.list.sortReverse,
|
||||||
sort_field: this.list.sortField,
|
sort_field: this.list.sortField,
|
||||||
|
|||||||
@@ -1697,6 +1697,24 @@ describe('FilterEditorComponent', () => {
|
|||||||
])
|
])
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('should carry over text filtering once with created and added relative dates', () => {
|
||||||
|
component.textFilter = 'foo'
|
||||||
|
const datesDropdown = fixture.debugElement.query(
|
||||||
|
By.directive(DatesDropdownComponent)
|
||||||
|
)
|
||||||
|
component.dateCreatedRelativeDate = RelativeDate.WITHIN_1_WEEK
|
||||||
|
component.dateAddedRelativeDate = RelativeDate.WITHIN_1_MONTH
|
||||||
|
datesDropdown.triggerEventHandler('datesSet')
|
||||||
|
fixture.detectChanges()
|
||||||
|
tick(400)
|
||||||
|
expect(component.filterRules).toEqual([
|
||||||
|
{
|
||||||
|
rule_type: FILTER_FULLTEXT_QUERY,
|
||||||
|
value: 'foo,created:[-1 week to now],added:[-1 month to now]',
|
||||||
|
},
|
||||||
|
])
|
||||||
|
})
|
||||||
|
|
||||||
it('should convert legacy title filters into full text query when adding a created relative date', () => {
|
it('should convert legacy title filters into full text query when adding a created relative date', () => {
|
||||||
component.filterRules = [
|
component.filterRules = [
|
||||||
{
|
{
|
||||||
@@ -2195,6 +2213,20 @@ describe('FilterEditorComponent', () => {
|
|||||||
expect(blurSpy).toHaveBeenCalled()
|
expect(blurSpy).toHaveBeenCalled()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('should only dismiss open autocomplete suggestions on Escape, keeping the query', () => {
|
||||||
|
component.textFilter = 'foo bar'
|
||||||
|
component.textFilterInput.nativeElement.value = 'foo bar'
|
||||||
|
jest.spyOn(component.searchTypeahead, 'isPopupOpen').mockReturnValue(true)
|
||||||
|
const dismissSpy = jest
|
||||||
|
.spyOn(component.searchTypeahead, 'dismissPopup')
|
||||||
|
.mockImplementation(() => {})
|
||||||
|
component.textFilterInput.nativeElement.dispatchEvent(
|
||||||
|
new KeyboardEvent('keydown', { key: 'Escape' })
|
||||||
|
)
|
||||||
|
expect(dismissSpy).toHaveBeenCalled()
|
||||||
|
expect(component.textFilter).toEqual('foo bar')
|
||||||
|
})
|
||||||
|
|
||||||
it('should adjust text filter targets if more like search', () => {
|
it('should adjust text filter targets if more like search', () => {
|
||||||
const TEXT_FILTER_TARGET_FULLTEXT_MORELIKE = 'fulltext-morelike' // private const
|
const TEXT_FILTER_TARGET_FULLTEXT_MORELIKE = 'fulltext-morelike' // private const
|
||||||
component.textFilterTarget = TEXT_FILTER_TARGET_FULLTEXT_MORELIKE
|
component.textFilterTarget = TEXT_FILTER_TARGET_FULLTEXT_MORELIKE
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import {
|
|||||||
import { FormsModule, ReactiveFormsModule } from '@angular/forms'
|
import { FormsModule, ReactiveFormsModule } from '@angular/forms'
|
||||||
import {
|
import {
|
||||||
NgbDropdownModule,
|
NgbDropdownModule,
|
||||||
|
NgbTypeahead,
|
||||||
NgbTypeaheadModule,
|
NgbTypeaheadModule,
|
||||||
} from '@ng-bootstrap/ng-bootstrap'
|
} from '@ng-bootstrap/ng-bootstrap'
|
||||||
import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons'
|
import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons'
|
||||||
@@ -351,6 +352,9 @@ export class FilterEditorComponent
|
|||||||
@ViewChild('textFilterInput')
|
@ViewChild('textFilterInput')
|
||||||
textFilterInput: ElementRef
|
textFilterInput: ElementRef
|
||||||
|
|
||||||
|
@ViewChild(NgbTypeahead)
|
||||||
|
searchTypeahead: NgbTypeahead
|
||||||
|
|
||||||
readonly customFields = signal<CustomField[]>([])
|
readonly customFields = signal<CustomField[]>([])
|
||||||
|
|
||||||
tagDocumentCounts: SelectionDataItem[]
|
tagDocumentCounts: SelectionDataItem[]
|
||||||
@@ -1016,7 +1020,6 @@ export class FilterEditorComponent
|
|||||||
this.dateAddedRelativeDate !== null ||
|
this.dateAddedRelativeDate !== null ||
|
||||||
this.dateCreatedRelativeDate !== null
|
this.dateCreatedRelativeDate !== null
|
||||||
) {
|
) {
|
||||||
let queryArgs: Array<string> = []
|
|
||||||
let existingRule = filterRules.find(
|
let existingRule = filterRules.find(
|
||||||
(fr) => fr.rule_type == FILTER_FULLTEXT_QUERY
|
(fr) => fr.rule_type == FILTER_FULLTEXT_QUERY
|
||||||
)
|
)
|
||||||
@@ -1038,32 +1041,28 @@ export class FilterEditorComponent
|
|||||||
existingRule.rule_type = FILTER_FULLTEXT_QUERY
|
existingRule.rule_type = FILTER_FULLTEXT_QUERY
|
||||||
}
|
}
|
||||||
|
|
||||||
let existingRuleArgs = existingRule?.value.split(',')
|
let queryArgs = existingRule?.value.split(',') ?? []
|
||||||
if (this.dateCreatedRelativeDate !== null) {
|
if (this.dateCreatedRelativeDate !== null) {
|
||||||
const rd = RELATIVE_DATE_QUERYSTRINGS.find(
|
const rd = RELATIVE_DATE_QUERYSTRINGS.find(
|
||||||
(qS) => qS.relativeDate == this.dateCreatedRelativeDate
|
(qS) => qS.relativeDate == this.dateCreatedRelativeDate
|
||||||
)
|
)
|
||||||
|
queryArgs = queryArgs.filter(
|
||||||
|
(arg) => !arg.match(RELATIVE_DATE_QUERY_REGEXP_CREATED)
|
||||||
|
)
|
||||||
queryArgs.push(
|
queryArgs.push(
|
||||||
`created:${rd.isRange ? `[${rd.dateQuery}]` : `"${rd.dateQuery}"`}`
|
`created:${rd.isRange ? `[${rd.dateQuery}]` : `"${rd.dateQuery}"`}`
|
||||||
)
|
)
|
||||||
if (existingRule) {
|
|
||||||
queryArgs = existingRuleArgs
|
|
||||||
.filter((arg) => !arg.match(RELATIVE_DATE_QUERY_REGEXP_CREATED))
|
|
||||||
.concat(queryArgs)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
if (this.dateAddedRelativeDate !== null) {
|
if (this.dateAddedRelativeDate !== null) {
|
||||||
const rd = RELATIVE_DATE_QUERYSTRINGS.find(
|
const rd = RELATIVE_DATE_QUERYSTRINGS.find(
|
||||||
(qS) => qS.relativeDate == this.dateAddedRelativeDate
|
(qS) => qS.relativeDate == this.dateAddedRelativeDate
|
||||||
)
|
)
|
||||||
|
queryArgs = queryArgs.filter(
|
||||||
|
(arg) => !arg.match(RELATIVE_DATE_QUERY_REGEXP_ADDED)
|
||||||
|
)
|
||||||
queryArgs.push(
|
queryArgs.push(
|
||||||
`added:${rd.isRange ? `[${rd.dateQuery}]` : `"${rd.dateQuery}"`}`
|
`added:${rd.isRange ? `[${rd.dateQuery}]` : `"${rd.dateQuery}"`}`
|
||||||
)
|
)
|
||||||
if (existingRule) {
|
|
||||||
queryArgs = existingRuleArgs
|
|
||||||
.filter((arg) => !arg.match(RELATIVE_DATE_QUERY_REGEXP_ADDED))
|
|
||||||
.concat(queryArgs)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (existingRule) {
|
if (existingRule) {
|
||||||
@@ -1155,6 +1154,7 @@ export class FilterEditorComponent
|
|||||||
}
|
}
|
||||||
|
|
||||||
set textFilter(value) {
|
set textFilter(value) {
|
||||||
|
this._textFilter = value // set immediately to prevent loss of keystrokes
|
||||||
this.textFilterDebounce.next(value)
|
this.textFilterDebounce.next(value)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1247,9 +1247,9 @@ export class FilterEditorComponent
|
|||||||
distinctUntilChanged(),
|
distinctUntilChanged(),
|
||||||
filter((query) => !query.length || query.length > 2)
|
filter((query) => !query.length || query.length > 2)
|
||||||
)
|
)
|
||||||
.subscribe((text) =>
|
.subscribe(() =>
|
||||||
this.updateTextFilter(
|
this.updateTextFilter(
|
||||||
text,
|
this._textFilter, // use the current value, not the debounced (possibly stale) one
|
||||||
this.textFilterTarget !== TEXT_FILTER_TARGET_FULLTEXT_QUERY
|
this.textFilterTarget !== TEXT_FILTER_TARGET_FULLTEXT_QUERY
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
@@ -1325,6 +1325,11 @@ export class FilterEditorComponent
|
|||||||
this.updateTextFilter(filterString)
|
this.updateTextFilter(filterString)
|
||||||
}
|
}
|
||||||
} else if (event.key === 'Escape') {
|
} else if (event.key === 'Escape') {
|
||||||
|
if (this.searchTypeahead?.isPopupOpen()) {
|
||||||
|
// only dismiss the suggestions, so longer query can use Enter
|
||||||
|
this.searchTypeahead.dismissPopup()
|
||||||
|
return
|
||||||
|
}
|
||||||
if (this._textFilter?.length) {
|
if (this._textFilter?.length) {
|
||||||
this.resetTextField()
|
this.resetTextField()
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
+8
@@ -6,6 +6,14 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="modal-body">
|
<div class="modal-body">
|
||||||
<pngx-input-text i18n-title title="Name" formControlName="name" [error]="error()?.name" autocomplete="off"></pngx-input-text>
|
<pngx-input-text i18n-title title="Name" formControlName="name" [error]="error()?.name" autocomplete="off"></pngx-input-text>
|
||||||
|
<pngx-input-select
|
||||||
|
i18n-title
|
||||||
|
title="Icon"
|
||||||
|
formControlName="icon"
|
||||||
|
[items]="savedViewIcons"
|
||||||
|
iconField="icon"
|
||||||
|
[error]="error()?.icon">
|
||||||
|
</pngx-input-select>
|
||||||
<pngx-input-check i18n-title title="Show in sidebar" formControlName="showInSideBar"></pngx-input-check>
|
<pngx-input-check i18n-title title="Show in sidebar" formControlName="showInSideBar"></pngx-input-check>
|
||||||
<pngx-input-check i18n-title title="Show on dashboard" formControlName="showOnDashboard"></pngx-input-check>
|
<pngx-input-check i18n-title title="Show on dashboard" formControlName="showOnDashboard"></pngx-input-check>
|
||||||
<pngx-permissions-form accordion="true" formControlName="permissions_form"></pngx-permissions-form>
|
<pngx-permissions-form accordion="true" formControlName="permissions_form"></pngx-permissions-form>
|
||||||
|
|||||||
+5
@@ -9,6 +9,7 @@ import { CheckComponent } from '../../common/input/check/check.component'
|
|||||||
import { PermissionsFormComponent } from '../../common/input/permissions/permissions-form/permissions-form.component'
|
import { PermissionsFormComponent } from '../../common/input/permissions/permissions-form/permissions-form.component'
|
||||||
import { PermissionsGroupComponent } from '../../common/input/permissions/permissions-group/permissions-group.component'
|
import { PermissionsGroupComponent } from '../../common/input/permissions/permissions-group/permissions-group.component'
|
||||||
import { PermissionsUserComponent } from '../../common/input/permissions/permissions-user/permissions-user.component'
|
import { PermissionsUserComponent } from '../../common/input/permissions/permissions-user/permissions-user.component'
|
||||||
|
import { SelectComponent } from '../../common/input/select/select.component'
|
||||||
import { TextComponent } from '../../common/input/text/text.component'
|
import { TextComponent } from '../../common/input/text/text.component'
|
||||||
import { SaveViewConfigDialogComponent } from './save-view-config-dialog.component'
|
import { SaveViewConfigDialogComponent } from './save-view-config-dialog.component'
|
||||||
|
|
||||||
@@ -40,6 +41,7 @@ describe('SaveViewConfigDialogComponent', () => {
|
|||||||
ReactiveFormsModule,
|
ReactiveFormsModule,
|
||||||
SaveViewConfigDialogComponent,
|
SaveViewConfigDialogComponent,
|
||||||
TextComponent,
|
TextComponent,
|
||||||
|
SelectComponent,
|
||||||
CheckComponent,
|
CheckComponent,
|
||||||
PermissionsFormComponent,
|
PermissionsFormComponent,
|
||||||
PermissionsUserComponent,
|
PermissionsUserComponent,
|
||||||
@@ -63,6 +65,7 @@ describe('SaveViewConfigDialogComponent', () => {
|
|||||||
expect(component.defaultName()).toEqual(name)
|
expect(component.defaultName()).toEqual(name)
|
||||||
expect(result).toEqual({
|
expect(result).toEqual({
|
||||||
name,
|
name,
|
||||||
|
icon: 'funnel',
|
||||||
showInSideBar: false,
|
showInSideBar: false,
|
||||||
showOnDashboard: false,
|
showOnDashboard: false,
|
||||||
})
|
})
|
||||||
@@ -94,6 +97,7 @@ describe('SaveViewConfigDialogComponent', () => {
|
|||||||
component.save()
|
component.save()
|
||||||
expect(result).toEqual({
|
expect(result).toEqual({
|
||||||
name,
|
name,
|
||||||
|
icon: 'funnel',
|
||||||
showInSideBar: true,
|
showInSideBar: true,
|
||||||
showOnDashboard: true,
|
showOnDashboard: true,
|
||||||
})
|
})
|
||||||
@@ -113,6 +117,7 @@ describe('SaveViewConfigDialogComponent', () => {
|
|||||||
component.save()
|
component.save()
|
||||||
expect(result).toEqual({
|
expect(result).toEqual({
|
||||||
name: '',
|
name: '',
|
||||||
|
icon: 'funnel',
|
||||||
showInSideBar: false,
|
showInSideBar: false,
|
||||||
showOnDashboard: false,
|
showOnDashboard: false,
|
||||||
permissions_form: permissions,
|
permissions_form: permissions,
|
||||||
|
|||||||
+9
@@ -13,9 +13,14 @@ import {
|
|||||||
ReactiveFormsModule,
|
ReactiveFormsModule,
|
||||||
} from '@angular/forms'
|
} from '@angular/forms'
|
||||||
import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap'
|
import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap'
|
||||||
|
import {
|
||||||
|
DEFAULT_SAVED_VIEW_ICON,
|
||||||
|
SAVED_VIEW_ICONS,
|
||||||
|
} from 'src/app/data/saved-view-icons'
|
||||||
import { User } from 'src/app/data/user'
|
import { User } from 'src/app/data/user'
|
||||||
import { CheckComponent } from '../../common/input/check/check.component'
|
import { CheckComponent } from '../../common/input/check/check.component'
|
||||||
import { PermissionsFormComponent } from '../../common/input/permissions/permissions-form/permissions-form.component'
|
import { PermissionsFormComponent } from '../../common/input/permissions/permissions-form/permissions-form.component'
|
||||||
|
import { SelectComponent } from '../../common/input/select/select.component'
|
||||||
import { TextComponent } from '../../common/input/text/text.component'
|
import { TextComponent } from '../../common/input/text/text.component'
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
@@ -24,6 +29,7 @@ import { TextComponent } from '../../common/input/text/text.component'
|
|||||||
styleUrls: ['./save-view-config-dialog.component.scss'],
|
styleUrls: ['./save-view-config-dialog.component.scss'],
|
||||||
imports: [
|
imports: [
|
||||||
CheckComponent,
|
CheckComponent,
|
||||||
|
SelectComponent,
|
||||||
TextComponent,
|
TextComponent,
|
||||||
PermissionsFormComponent,
|
PermissionsFormComponent,
|
||||||
FormsModule,
|
FormsModule,
|
||||||
@@ -41,6 +47,7 @@ export class SaveViewConfigDialogComponent implements OnInit {
|
|||||||
public saveClicked = new EventEmitter()
|
public saveClicked = new EventEmitter()
|
||||||
|
|
||||||
users: User[]
|
users: User[]
|
||||||
|
readonly savedViewIcons = SAVED_VIEW_ICONS
|
||||||
|
|
||||||
setDefaultName(value: string) {
|
setDefaultName(value: string) {
|
||||||
this.defaultName.set(value)
|
this.defaultName.set(value)
|
||||||
@@ -49,6 +56,7 @@ export class SaveViewConfigDialogComponent implements OnInit {
|
|||||||
|
|
||||||
saveViewConfigForm = new FormGroup({
|
saveViewConfigForm = new FormGroup({
|
||||||
name: new FormControl(''),
|
name: new FormControl(''),
|
||||||
|
icon: new FormControl(DEFAULT_SAVED_VIEW_ICON),
|
||||||
showInSideBar: new FormControl(false),
|
showInSideBar: new FormControl(false),
|
||||||
showOnDashboard: new FormControl(false),
|
showOnDashboard: new FormControl(false),
|
||||||
permissions_form: new FormControl(null),
|
permissions_form: new FormControl(null),
|
||||||
@@ -65,6 +73,7 @@ export class SaveViewConfigDialogComponent implements OnInit {
|
|||||||
const formValue = this.saveViewConfigForm.value
|
const formValue = this.saveViewConfigForm.value
|
||||||
const saveViewConfig = {
|
const saveViewConfig = {
|
||||||
name: formValue.name,
|
name: formValue.name,
|
||||||
|
icon: formValue.icon,
|
||||||
showInSideBar: formValue.showInSideBar,
|
showInSideBar: formValue.showInSideBar,
|
||||||
showOnDashboard: formValue.showOnDashboard,
|
showOnDashboard: formValue.showOnDashboard,
|
||||||
}
|
}
|
||||||
|
|||||||
+1
-1
@@ -88,7 +88,7 @@
|
|||||||
@if (depth > 0) {
|
@if (depth > 0) {
|
||||||
<div class="indicator"></div>
|
<div class="indicator"></div>
|
||||||
}
|
}
|
||||||
<button class="btn btn-link ms-0 ps-0 text-start" (click)="userCanEdit(object) ? openEditDialog(object) : null; $event.stopPropagation()">{{ object.name }}</button>
|
<button class="btn btn-link ms-0 ps-0 text-start" style="user-select: text;" [disabled]="!userCanEdit(object)" (click)="userCanEdit(object) ? openEditDialog(object) : null; $event.stopPropagation()">{{ object.name }}</button>
|
||||||
</td>
|
</td>
|
||||||
<td class="d-none d-sm-table-cell">{{ getMatching(object) }}</td>
|
<td class="d-none d-sm-table-cell">{{ getMatching(object) }}</td>
|
||||||
<td>{{ getDocumentCount(object) }}</td>
|
<td>{{ getDocumentCount(object) }}</td>
|
||||||
|
|||||||
@@ -7,23 +7,34 @@
|
|||||||
</pngx-page-header>
|
</pngx-page-header>
|
||||||
<form [formGroup]="savedViewsForm" (ngSubmit)="save()">
|
<form [formGroup]="savedViewsForm" (ngSubmit)="save()">
|
||||||
<ul class="list-group mb-3" formGroupName="savedViews">
|
<ul class="list-group mb-3" formGroupName="savedViews">
|
||||||
@for (view of savedViews(); track view) {
|
@for (view of pagedSavedViews(); track view) {
|
||||||
<li class="list-group-item py-3">
|
<li class="list-group-item py-3">
|
||||||
<div [formGroupName]="view.id">
|
<div [formGroupName]="view.id">
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<div class="col">
|
<div class="col-md">
|
||||||
<pngx-input-text title="Name" formControlName="name"></pngx-input-text>
|
<pngx-input-text title="Name" formControlName="name"></pngx-input-text>
|
||||||
</div>
|
</div>
|
||||||
<div class="col">
|
<div class="col-md">
|
||||||
<div class="form-check form-switch mt-3">
|
<pngx-input-select
|
||||||
<input type="checkbox" class="form-check-input" id="show_on_dashboard_{{view.id}}" formControlName="show_on_dashboard">
|
i18n-title
|
||||||
<label class="form-check-label" for="show_on_dashboard_{{view.id}}" i18n>Show on dashboard</label>
|
title="Icon"
|
||||||
</div>
|
formControlName="icon"
|
||||||
<div class="form-check form-switch">
|
[items]="savedViewIcons"
|
||||||
<input type="checkbox" class="form-check-input" id="show_in_sidebar_{{view.id}}" formControlName="show_in_sidebar">
|
iconField="icon">
|
||||||
<label class="form-check-label" for="show_in_sidebar_{{view.id}}" i18n>Show in sidebar</label>
|
</pngx-input-select>
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
@if (canSaveSettings) {
|
||||||
|
<div class="col-md">
|
||||||
|
<div class="form-check form-switch mt-3">
|
||||||
|
<input type="checkbox" class="form-check-input" id="show_on_dashboard_{{view.id}}" formControlName="show_on_dashboard">
|
||||||
|
<label class="form-check-label" for="show_on_dashboard_{{view.id}}" i18n>Show on dashboard</label>
|
||||||
|
</div>
|
||||||
|
<div class="form-check form-switch">
|
||||||
|
<input type="checkbox" class="form-check-input" id="show_in_sidebar_{{view.id}}" formControlName="show_in_sidebar">
|
||||||
|
<label class="form-check-label" for="show_in_sidebar_{{view.id}}" i18n>Show in sidebar</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
<div class="col-auto">
|
<div class="col-auto">
|
||||||
@if (canDeleteSavedView(view)) {
|
@if (canDeleteSavedView(view)) {
|
||||||
<label class="form-label" for="name_{{view.id}}" i18n>Actions</label>
|
<label class="form-label" for="name_{{view.id}}" i18n>Actions</label>
|
||||||
@@ -79,6 +90,11 @@
|
|||||||
}
|
}
|
||||||
</ul>
|
</ul>
|
||||||
|
|
||||||
<button type="button" (click)="reset()" class="btn btn-outline-secondary mb-2" [disabled]="(isDirty$ | async) === false" i18n>Cancel</button>
|
<div class="d-flex align-items-center mb-3">
|
||||||
<button type="submit" class="btn btn-primary ms-2 mb-2" [disabled]="(isDirty$ | async) === false" i18n>Save</button>
|
<button type="button" (click)="reset()" class="btn btn-outline-secondary mb-2" [disabled]="(isDirty$ | async) === false" i18n>Cancel</button>
|
||||||
|
<button type="submit" class="btn btn-primary ms-2 mb-2" [disabled]="(isDirty$ | async) === false" i18n>Save</button>
|
||||||
|
@if (savedViews()?.length > pageSize) {
|
||||||
|
<ngb-pagination class="ms-auto" [pageSize]="pageSize" [collectionSize]="savedViews().length" [page]="page()" [maxSize]="5" (pageChange)="page.set($event)" size="sm" aria-label="Pagination"></ngb-pagination>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { provideHttpClientTesting } from '@angular/common/http/testing'
|
|||||||
import { signal } from '@angular/core'
|
import { signal } from '@angular/core'
|
||||||
import { ComponentFixture, TestBed } from '@angular/core/testing'
|
import { ComponentFixture, TestBed } from '@angular/core/testing'
|
||||||
import { FormsModule, ReactiveFormsModule } from '@angular/forms'
|
import { FormsModule, ReactiveFormsModule } from '@angular/forms'
|
||||||
|
import { By } from '@angular/platform-browser'
|
||||||
import { NgbModal, NgbModule } from '@ng-bootstrap/ng-bootstrap'
|
import { NgbModal, NgbModule } from '@ng-bootstrap/ng-bootstrap'
|
||||||
import { NgxBootstrapIconsModule, allIcons } from 'ngx-bootstrap-icons'
|
import { NgxBootstrapIconsModule, allIcons } from 'ngx-bootstrap-icons'
|
||||||
import { Subject, of, throwError } from 'rxjs'
|
import { Subject, of, throwError } from 'rxjs'
|
||||||
@@ -25,8 +26,20 @@ import { PageHeaderComponent } from '../../common/page-header/page-header.compon
|
|||||||
import { SavedViewsComponent } from './saved-views.component'
|
import { SavedViewsComponent } from './saved-views.component'
|
||||||
|
|
||||||
const savedViews = [
|
const savedViews = [
|
||||||
{ id: 1, name: 'view1', show_in_sidebar: true, show_on_dashboard: true },
|
{
|
||||||
{ id: 2, name: 'view2', show_in_sidebar: false, show_on_dashboard: false },
|
id: 1,
|
||||||
|
name: 'view1',
|
||||||
|
icon: 'archive',
|
||||||
|
show_in_sidebar: true,
|
||||||
|
show_on_dashboard: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 2,
|
||||||
|
name: 'view2',
|
||||||
|
icon: 'funnel',
|
||||||
|
show_in_sidebar: false,
|
||||||
|
show_on_dashboard: false,
|
||||||
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
describe('SavedViewsComponent', () => {
|
describe('SavedViewsComponent', () => {
|
||||||
@@ -157,6 +170,24 @@ describe('SavedViewsComponent', () => {
|
|||||||
expect(patchBody.show_in_sidebar).toBeUndefined()
|
expect(patchBody.show_in_sidebar).toBeUndefined()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('should persist a changed icon', () => {
|
||||||
|
const patchSpy = jest.spyOn(savedViewService, 'patchMany')
|
||||||
|
const view = savedViews[0]
|
||||||
|
const iconControl = component.savedViewsForm
|
||||||
|
.get('savedViews')
|
||||||
|
.get(view.id.toString())
|
||||||
|
.get('icon')
|
||||||
|
|
||||||
|
iconControl.setValue('bell')
|
||||||
|
iconControl.markAsDirty()
|
||||||
|
component.save()
|
||||||
|
|
||||||
|
expect(patchSpy.mock.calls[0][0][0]).toMatchObject({
|
||||||
|
id: view.id,
|
||||||
|
icon: 'bell',
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
it('should persist visibility changes to user settings', () => {
|
it('should persist visibility changes to user settings', () => {
|
||||||
const patchSpy = jest.spyOn(savedViewService, 'patchMany')
|
const patchSpy = jest.spyOn(savedViewService, 'patchMany')
|
||||||
const updateVisibilitySpy = jest
|
const updateVisibilitySpy = jest
|
||||||
@@ -222,6 +253,44 @@ describe('SavedViewsComponent', () => {
|
|||||||
).toEqual(view.show_on_dashboard)
|
).toEqual(view.show_on_dashboard)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('should page saved views, clamp the page if views are removed', () => {
|
||||||
|
const manyViews = Array.from({ length: 30 }, (_, i) => ({
|
||||||
|
id: i + 1,
|
||||||
|
name: `view${i + 1}`,
|
||||||
|
})) as SavedView[]
|
||||||
|
const listSpy = jest.spyOn(savedViewService, 'list').mockReturnValue(
|
||||||
|
of({
|
||||||
|
all: manyViews.map((v) => v.id),
|
||||||
|
count: manyViews.length,
|
||||||
|
results: manyViews.concat([]),
|
||||||
|
})
|
||||||
|
)
|
||||||
|
component.ngOnInit()
|
||||||
|
fixture.detectChanges()
|
||||||
|
expect(listSpy).toHaveBeenCalledWith(1, 100000, null, false, {
|
||||||
|
full_perms: true,
|
||||||
|
})
|
||||||
|
expect(component.pagedSavedViews()).toHaveLength(25)
|
||||||
|
expect(fixture.debugElement.query(By.css('ngb-pagination'))).not.toBeNull()
|
||||||
|
// all views have controls, not just the current page
|
||||||
|
expect(
|
||||||
|
Object.keys(component.savedViewsForm.get('savedViews').value)
|
||||||
|
).toHaveLength(30)
|
||||||
|
|
||||||
|
component.page.set(2)
|
||||||
|
expect(component.pagedSavedViews()).toHaveLength(5)
|
||||||
|
|
||||||
|
listSpy.mockReturnValue(
|
||||||
|
of({
|
||||||
|
all: manyViews.slice(0, 25).map((v) => v.id),
|
||||||
|
count: 25,
|
||||||
|
results: manyViews.slice(0, 25),
|
||||||
|
})
|
||||||
|
)
|
||||||
|
component.ngOnInit()
|
||||||
|
expect(component.page()).toEqual(1)
|
||||||
|
})
|
||||||
|
|
||||||
it('should support editing permissions', () => {
|
it('should support editing permissions', () => {
|
||||||
const confirmClicked = new Subject<any>()
|
const confirmClicked = new Subject<any>()
|
||||||
const modalRef = {
|
const modalRef = {
|
||||||
|
|||||||
@@ -1,22 +1,34 @@
|
|||||||
import { AsyncPipe } from '@angular/common'
|
import { AsyncPipe } from '@angular/common'
|
||||||
import { Component, OnDestroy, OnInit, inject, signal } from '@angular/core'
|
import {
|
||||||
|
Component,
|
||||||
|
OnDestroy,
|
||||||
|
OnInit,
|
||||||
|
computed,
|
||||||
|
inject,
|
||||||
|
signal,
|
||||||
|
} from '@angular/core'
|
||||||
import {
|
import {
|
||||||
FormControl,
|
FormControl,
|
||||||
FormGroup,
|
FormGroup,
|
||||||
FormsModule,
|
FormsModule,
|
||||||
ReactiveFormsModule,
|
ReactiveFormsModule,
|
||||||
} from '@angular/forms'
|
} from '@angular/forms'
|
||||||
import { NgbModal } from '@ng-bootstrap/ng-bootstrap'
|
import { NgbModal, NgbPaginationModule } from '@ng-bootstrap/ng-bootstrap'
|
||||||
import { dirtyCheck } from '@ngneat/dirty-check-forms'
|
import { dirtyCheck } from '@ngneat/dirty-check-forms'
|
||||||
import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons'
|
import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons'
|
||||||
import { BehaviorSubject, Observable, of, switchMap, takeUntil } from 'rxjs'
|
import { BehaviorSubject, Observable, of, switchMap, takeUntil } from 'rxjs'
|
||||||
import { PermissionsDialogComponent } from 'src/app/components/common/permissions-dialog/permissions-dialog.component'
|
import { PermissionsDialogComponent } from 'src/app/components/common/permissions-dialog/permissions-dialog.component'
|
||||||
import { DisplayMode } from 'src/app/data/document'
|
import { DisplayMode } from 'src/app/data/document'
|
||||||
import { SavedView } from 'src/app/data/saved-view'
|
import { SavedView } from 'src/app/data/saved-view'
|
||||||
|
import {
|
||||||
|
DEFAULT_SAVED_VIEW_ICON,
|
||||||
|
SAVED_VIEW_ICONS,
|
||||||
|
} from 'src/app/data/saved-view-icons'
|
||||||
import { IfPermissionsDirective } from 'src/app/directives/if-permissions.directive'
|
import { IfPermissionsDirective } from 'src/app/directives/if-permissions.directive'
|
||||||
import {
|
import {
|
||||||
PermissionAction,
|
PermissionAction,
|
||||||
PermissionsService,
|
PermissionsService,
|
||||||
|
PermissionType,
|
||||||
} from 'src/app/services/permissions.service'
|
} from 'src/app/services/permissions.service'
|
||||||
import { SavedViewService } from 'src/app/services/rest/saved-view.service'
|
import { SavedViewService } from 'src/app/services/rest/saved-view.service'
|
||||||
import { SettingsService } from 'src/app/services/settings.service'
|
import { SettingsService } from 'src/app/services/settings.service'
|
||||||
@@ -24,6 +36,7 @@ import { ToastService } from 'src/app/services/toast.service'
|
|||||||
import { ConfirmButtonComponent } from '../../common/confirm-button/confirm-button.component'
|
import { ConfirmButtonComponent } from '../../common/confirm-button/confirm-button.component'
|
||||||
import { DragDropSelectComponent } from '../../common/input/drag-drop-select/drag-drop-select.component'
|
import { DragDropSelectComponent } from '../../common/input/drag-drop-select/drag-drop-select.component'
|
||||||
import { NumberComponent } from '../../common/input/number/number.component'
|
import { NumberComponent } from '../../common/input/number/number.component'
|
||||||
|
import { SelectComponent } from '../../common/input/select/select.component'
|
||||||
import { TextComponent } from '../../common/input/text/text.component'
|
import { TextComponent } from '../../common/input/text/text.component'
|
||||||
import { PageHeaderComponent } from '../../common/page-header/page-header.component'
|
import { PageHeaderComponent } from '../../common/page-header/page-header.component'
|
||||||
import { LoadingComponentWithPermissions } from '../../loading-component/loading.component'
|
import { LoadingComponentWithPermissions } from '../../loading-component/loading.component'
|
||||||
@@ -35,12 +48,14 @@ import { LoadingComponentWithPermissions } from '../../loading-component/loading
|
|||||||
PageHeaderComponent,
|
PageHeaderComponent,
|
||||||
ConfirmButtonComponent,
|
ConfirmButtonComponent,
|
||||||
NumberComponent,
|
NumberComponent,
|
||||||
|
SelectComponent,
|
||||||
TextComponent,
|
TextComponent,
|
||||||
IfPermissionsDirective,
|
IfPermissionsDirective,
|
||||||
DragDropSelectComponent,
|
DragDropSelectComponent,
|
||||||
FormsModule,
|
FormsModule,
|
||||||
ReactiveFormsModule,
|
ReactiveFormsModule,
|
||||||
AsyncPipe,
|
AsyncPipe,
|
||||||
|
NgbPaginationModule,
|
||||||
NgxBootstrapIconsModule,
|
NgxBootstrapIconsModule,
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
@@ -55,8 +70,17 @@ export class SavedViewsComponent
|
|||||||
private readonly modalService = inject(NgbModal)
|
private readonly modalService = inject(NgbModal)
|
||||||
|
|
||||||
DisplayMode = DisplayMode
|
DisplayMode = DisplayMode
|
||||||
|
readonly savedViewIcons = SAVED_VIEW_ICONS
|
||||||
|
|
||||||
readonly savedViews = signal<SavedView[]>(undefined)
|
readonly savedViews = signal<SavedView[]>(undefined)
|
||||||
|
readonly page = signal(1)
|
||||||
|
public readonly pageSize = 25
|
||||||
|
// All views are loaded at init, so paging is only for display
|
||||||
|
readonly pagedSavedViews = computed(() => {
|
||||||
|
const start = (this.page() - 1) * this.pageSize
|
||||||
|
return this.savedViews()?.slice(start, start + this.pageSize)
|
||||||
|
})
|
||||||
|
|
||||||
private savedViewsGroup = new FormGroup({})
|
private savedViewsGroup = new FormGroup({})
|
||||||
public savedViewsForm: FormGroup = new FormGroup({
|
public savedViewsForm: FormGroup = new FormGroup({
|
||||||
savedViews: this.savedViewsGroup,
|
savedViews: this.savedViewsGroup,
|
||||||
@@ -71,7 +95,9 @@ export class SavedViewsComponent
|
|||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
super()
|
super()
|
||||||
this.settings.organizingSidebarSavedViews.set(true)
|
if (this.canSaveSettings) {
|
||||||
|
this.settings.organizingSidebarSavedViews.set(true)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
ngOnInit(): void {
|
ngOnInit(): void {
|
||||||
@@ -81,15 +107,19 @@ export class SavedViewsComponent
|
|||||||
private reloadViews(): void {
|
private reloadViews(): void {
|
||||||
this.loading.set(true)
|
this.loading.set(true)
|
||||||
this.savedViewService
|
this.savedViewService
|
||||||
.list(null, null, null, false, { full_perms: true })
|
.list(1, 100000, null, false, { full_perms: true })
|
||||||
.subscribe((r) => {
|
.subscribe((r) => {
|
||||||
this.savedViews.set(r.results)
|
this.savedViews.set(r.results)
|
||||||
|
const pageCount = Math.ceil(r.results.length / this.pageSize)
|
||||||
|
this.page.update((page) => Math.min(page, Math.max(1, pageCount)))
|
||||||
this.initialize()
|
this.initialize()
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
ngOnDestroy(): void {
|
ngOnDestroy(): void {
|
||||||
this.settings.organizingSidebarSavedViews.set(false)
|
if (this.canSaveSettings) {
|
||||||
|
this.settings.organizingSidebarSavedViews.set(false)
|
||||||
|
}
|
||||||
super.ngOnDestroy()
|
super.ngOnDestroy()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -105,6 +135,7 @@ export class SavedViewsComponent
|
|||||||
storeData.savedViews[view.id.toString()] = {
|
storeData.savedViews[view.id.toString()] = {
|
||||||
id: view.id,
|
id: view.id,
|
||||||
name: view.name,
|
name: view.name,
|
||||||
|
icon: view.icon ?? DEFAULT_SAVED_VIEW_ICON,
|
||||||
show_on_dashboard: view.show_on_dashboard,
|
show_on_dashboard: view.show_on_dashboard,
|
||||||
show_in_sidebar: view.show_in_sidebar,
|
show_in_sidebar: view.show_in_sidebar,
|
||||||
page_size: view.page_size,
|
page_size: view.page_size,
|
||||||
@@ -117,6 +148,7 @@ export class SavedViewsComponent
|
|||||||
new FormGroup({
|
new FormGroup({
|
||||||
id: new FormControl({ value: null, disabled: !canEdit }),
|
id: new FormControl({ value: null, disabled: !canEdit }),
|
||||||
name: new FormControl({ value: null, disabled: !canEdit }),
|
name: new FormControl({ value: null, disabled: !canEdit }),
|
||||||
|
icon: new FormControl({ value: null, disabled: !canEdit }),
|
||||||
show_on_dashboard: new FormControl({
|
show_on_dashboard: new FormControl({
|
||||||
value: null,
|
value: null,
|
||||||
disabled: false,
|
disabled: false,
|
||||||
@@ -195,6 +227,7 @@ export class SavedViewsComponent
|
|||||||
|
|
||||||
const modelFieldsChanged =
|
const modelFieldsChanged =
|
||||||
group.get('name')?.dirty ||
|
group.get('name')?.dirty ||
|
||||||
|
group.get('icon')?.dirty ||
|
||||||
group.get('page_size')?.dirty ||
|
group.get('page_size')?.dirty ||
|
||||||
group.get('display_mode')?.dirty ||
|
group.get('display_mode')?.dirty ||
|
||||||
group.get('display_fields')?.dirty
|
group.get('display_fields')?.dirty
|
||||||
@@ -216,7 +249,7 @@ export class SavedViewsComponent
|
|||||||
switchMap(() => this.savedViewService.patchMany(changed))
|
switchMap(() => this.savedViewService.patchMany(changed))
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
if (visibilityChanged) {
|
if (visibilityChanged && this.canSaveSettings) {
|
||||||
saveOperation = saveOperation.pipe(
|
saveOperation = saveOperation.pipe(
|
||||||
switchMap(() =>
|
switchMap(() =>
|
||||||
this.settings.updateSavedViewsVisibility(
|
this.settings.updateSavedViewsVisibility(
|
||||||
@@ -250,6 +283,19 @@ export class SavedViewsComponent
|
|||||||
return this.permissionsService.currentUserOwnsObject(view)
|
return this.permissionsService.currentUserOwnsObject(view)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public get canSaveSettings(): boolean {
|
||||||
|
return (
|
||||||
|
this.permissionsService.currentUserCan(
|
||||||
|
PermissionAction.Change,
|
||||||
|
PermissionType.UISettings
|
||||||
|
) &&
|
||||||
|
this.permissionsService.currentUserCan(
|
||||||
|
PermissionAction.Add,
|
||||||
|
PermissionType.UISettings
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
public editPermissions(savedView: SavedView): void {
|
public editPermissions(savedView: SavedView): void {
|
||||||
const modal = this.modalService.open(PermissionsDialogComponent, {
|
const modal = this.modalService.open(PermissionsDialogComponent, {
|
||||||
backdrop: 'static',
|
backdrop: 'static',
|
||||||
|
|||||||
@@ -54,6 +54,10 @@ export const ConfigCategory = {
|
|||||||
AI: $localize`AI Settings`,
|
AI: $localize`AI Settings`,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const ConfigSection = {
|
||||||
|
RemoteOCR: $localize`Remote OCR`,
|
||||||
|
}
|
||||||
|
|
||||||
export const LLMEmbeddingBackendConfig = {
|
export const LLMEmbeddingBackendConfig = {
|
||||||
OPENAI_LIKE: 'openai-like',
|
OPENAI_LIKE: 'openai-like',
|
||||||
HUGGINGFACE: 'huggingface',
|
HUGGINGFACE: 'huggingface',
|
||||||
@@ -65,6 +69,10 @@ export const LLMBackendConfig = {
|
|||||||
OLLAMA: 'ollama',
|
OLLAMA: 'ollama',
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const RemoteOCREngineConfig = {
|
||||||
|
AZURE_AI: 'azureai',
|
||||||
|
}
|
||||||
|
|
||||||
export interface ConfigOption {
|
export interface ConfigOption {
|
||||||
key: string
|
key: string
|
||||||
title: string
|
title: string
|
||||||
@@ -72,6 +80,7 @@ export interface ConfigOption {
|
|||||||
choices?: Array<{ id: string; name: string }>
|
choices?: Array<{ id: string; name: string }>
|
||||||
config_key?: string
|
config_key?: string
|
||||||
category: string
|
category: string
|
||||||
|
section?: string
|
||||||
note?: string
|
note?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -181,6 +190,33 @@ export const PaperlessConfigOptions: ConfigOption[] = [
|
|||||||
config_key: 'PAPERLESS_OCR_USER_ARGS',
|
config_key: 'PAPERLESS_OCR_USER_ARGS',
|
||||||
category: ConfigCategory.OCR,
|
category: ConfigCategory.OCR,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
key: 'remote_ocr_engine',
|
||||||
|
title: $localize`Remote OCR Engine`,
|
||||||
|
type: ConfigOptionType.Select,
|
||||||
|
choices: mapToItems(RemoteOCREngineConfig),
|
||||||
|
config_key: 'PAPERLESS_REMOTE_OCR_ENGINE',
|
||||||
|
category: ConfigCategory.OCR,
|
||||||
|
section: ConfigSection.RemoteOCR,
|
||||||
|
note: $localize`Enabling remote OCR sends documents to a third-party service for processing. Consider the privacy implications as well as potential costs before enabling.`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'remote_ocr_api_key',
|
||||||
|
title: $localize`Remote OCR API Key`,
|
||||||
|
type: ConfigOptionType.Password,
|
||||||
|
config_key: 'PAPERLESS_REMOTE_OCR_API_KEY',
|
||||||
|
category: ConfigCategory.OCR,
|
||||||
|
section: ConfigSection.RemoteOCR,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'remote_ocr_endpoint',
|
||||||
|
title: $localize`Remote OCR Endpoint`,
|
||||||
|
type: ConfigOptionType.String,
|
||||||
|
config_key: 'PAPERLESS_REMOTE_OCR_ENDPOINT',
|
||||||
|
category: ConfigCategory.OCR,
|
||||||
|
section: ConfigSection.RemoteOCR,
|
||||||
|
note: $localize`Required when using the Azure AI engine.`,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
key: 'app_logo',
|
key: 'app_logo',
|
||||||
title: $localize`Application Logo`,
|
title: $localize`Application Logo`,
|
||||||
@@ -398,6 +434,9 @@ export interface PaperlessConfig extends ObjectWithId {
|
|||||||
barcode_enable_tag: boolean
|
barcode_enable_tag: boolean
|
||||||
barcode_tag_mapping: object
|
barcode_tag_mapping: object
|
||||||
barcode_tag_split: boolean
|
barcode_tag_split: boolean
|
||||||
|
remote_ocr_engine: string
|
||||||
|
remote_ocr_api_key: string
|
||||||
|
remote_ocr_endpoint: string
|
||||||
ai_enabled: boolean
|
ai_enabled: boolean
|
||||||
llm_embedding_backend: string
|
llm_embedding_backend: string
|
||||||
llm_embedding_model: string
|
llm_embedding_model: string
|
||||||
|
|||||||
@@ -0,0 +1,89 @@
|
|||||||
|
export const DEFAULT_SAVED_VIEW_ICON = 'funnel'
|
||||||
|
|
||||||
|
export const SAVED_VIEW_ICONS = [
|
||||||
|
{ id: 'archive', name: $localize`Archive`, icon: 'archive' },
|
||||||
|
{ id: 'bank', name: $localize`Bank`, icon: 'bank' },
|
||||||
|
{ id: 'basket', name: $localize`Basket`, icon: 'basket' },
|
||||||
|
{ id: 'bell', name: $localize`Bell`, icon: 'bell' },
|
||||||
|
{ id: 'bookmark', name: $localize`Bookmark`, icon: 'bookmark' },
|
||||||
|
{ id: 'boxes', name: $localize`Boxes`, icon: 'boxes' },
|
||||||
|
{ id: 'briefcase', name: $localize`Briefcase`, icon: 'briefcase' },
|
||||||
|
{ id: 'building', name: $localize`Building`, icon: 'building' },
|
||||||
|
{ id: 'calculator', name: $localize`Calculator`, icon: 'calculator' },
|
||||||
|
{ id: 'calendar', name: $localize`Calendar`, icon: 'calendar' },
|
||||||
|
{ id: 'camera', name: $localize`Camera`, icon: 'camera' },
|
||||||
|
{
|
||||||
|
id: 'card-checklist',
|
||||||
|
name: $localize`Checklist`,
|
||||||
|
icon: 'card-checklist',
|
||||||
|
},
|
||||||
|
{ id: 'cash', name: $localize`Cash`, icon: 'cash' },
|
||||||
|
{ id: 'chat-left-text', name: $localize`Chat`, icon: 'chat-left-text' },
|
||||||
|
{ id: 'check-circle', name: $localize`Check`, icon: 'check-circle' },
|
||||||
|
{ id: 'clipboard', name: $localize`Clipboard`, icon: 'clipboard' },
|
||||||
|
{ id: 'clock-history', name: $localize`Clock`, icon: 'clock-history' },
|
||||||
|
{ id: 'credit-card', name: $localize`Credit card`, icon: 'credit-card' },
|
||||||
|
{ id: 'download', name: $localize`Download`, icon: 'download' },
|
||||||
|
{ id: 'envelope', name: $localize`Envelope`, icon: 'envelope' },
|
||||||
|
{
|
||||||
|
id: 'exclamation-triangle',
|
||||||
|
name: $localize`Warning`,
|
||||||
|
icon: 'exclamation-triangle',
|
||||||
|
},
|
||||||
|
{ id: 'file-earmark', name: $localize`File`, icon: 'file-earmark' },
|
||||||
|
{
|
||||||
|
id: 'file-earmark-check',
|
||||||
|
name: $localize`Checked file`,
|
||||||
|
icon: 'file-earmark-check',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'file-earmark-lock',
|
||||||
|
name: $localize`Locked file`,
|
||||||
|
icon: 'file-earmark-lock',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'file-earmark-medical',
|
||||||
|
name: $localize`Medical file`,
|
||||||
|
icon: 'file-earmark-medical',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'file-earmark-person',
|
||||||
|
name: $localize`Person file`,
|
||||||
|
icon: 'file-earmark-person',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'file-earmark-spreadsheet',
|
||||||
|
name: $localize`Spreadsheet`,
|
||||||
|
icon: 'file-earmark-spreadsheet',
|
||||||
|
},
|
||||||
|
{ id: 'file-text', name: $localize`Text file`, icon: 'file-text' },
|
||||||
|
{ id: 'files', name: $localize`Files`, icon: 'files' },
|
||||||
|
{ id: 'folder', name: $localize`Folder`, icon: 'folder' },
|
||||||
|
{ id: 'funnel', name: $localize`Filter`, icon: 'funnel' },
|
||||||
|
{ id: 'gear', name: $localize`Gear`, icon: 'gear' },
|
||||||
|
{ id: 'globe2', name: $localize`Globe`, icon: 'globe2' },
|
||||||
|
{ id: 'hash', name: $localize`Hash`, icon: 'hash' },
|
||||||
|
{ id: 'heart', name: $localize`Heart`, icon: 'heart' },
|
||||||
|
{ id: 'house', name: $localize`House`, icon: 'house' },
|
||||||
|
{ id: 'inbox', name: $localize`Inbox`, icon: 'inbox' },
|
||||||
|
{ id: 'journals', name: $localize`Journals`, icon: 'journals' },
|
||||||
|
{ id: 'list-task', name: $localize`Task list`, icon: 'list-task' },
|
||||||
|
{ id: 'newspaper', name: $localize`Newspaper`, icon: 'newspaper' },
|
||||||
|
{ id: 'paperclip', name: $localize`Attachment`, icon: 'paperclip' },
|
||||||
|
{ id: 'people', name: $localize`People`, icon: 'people' },
|
||||||
|
{ id: 'person', name: $localize`Person`, icon: 'person' },
|
||||||
|
{ id: 'printer', name: $localize`Printer`, icon: 'printer' },
|
||||||
|
{ id: 'receipt', name: $localize`Receipt`, icon: 'receipt' },
|
||||||
|
{ id: 'safe', name: $localize`Safe`, icon: 'safe' },
|
||||||
|
{ id: 'search', name: $localize`Search`, icon: 'search' },
|
||||||
|
{ id: 'send', name: $localize`Send`, icon: 'send' },
|
||||||
|
{ id: 'shop', name: $localize`Shop`, icon: 'shop' },
|
||||||
|
{ id: 'stack', name: $localize`Stack`, icon: 'stack' },
|
||||||
|
{ id: 'stars', name: $localize`Stars`, icon: 'stars' },
|
||||||
|
{ id: 'tag', name: $localize`Tag`, icon: 'tag' },
|
||||||
|
{ id: 'tags', name: $localize`Tags`, icon: 'tags' },
|
||||||
|
{ id: 'telephone', name: $localize`Telephone`, icon: 'telephone' },
|
||||||
|
{ id: 'truck', name: $localize`Truck`, icon: 'truck' },
|
||||||
|
{ id: 'upc-scan', name: $localize`Barcode`, icon: 'upc-scan' },
|
||||||
|
{ id: 'wallet2', name: $localize`Wallet`, icon: 'wallet2' },
|
||||||
|
]
|
||||||
@@ -5,6 +5,8 @@ import { ObjectWithPermissions } from './object-with-permissions'
|
|||||||
export interface SavedView extends ObjectWithPermissions {
|
export interface SavedView extends ObjectWithPermissions {
|
||||||
name?: string
|
name?: string
|
||||||
|
|
||||||
|
icon?: string
|
||||||
|
|
||||||
show_on_dashboard?: boolean
|
show_on_dashboard?: boolean
|
||||||
|
|
||||||
show_in_sidebar?: boolean
|
show_in_sidebar?: boolean
|
||||||
|
|||||||
@@ -120,6 +120,12 @@ describe('PermissionsService', () => {
|
|||||||
actionKey: 'View', // PermissionAction.View
|
actionKey: 'View', // PermissionAction.View
|
||||||
typeKey: 'SystemMonitoring', // PermissionType.SystemMonitoring
|
typeKey: 'SystemMonitoring', // PermissionType.SystemMonitoring
|
||||||
})
|
})
|
||||||
|
expect(permissionsService.getPermissionKeys('add_sharelinkbundle')).toEqual(
|
||||||
|
{
|
||||||
|
actionKey: 'Add', // PermissionAction.Add
|
||||||
|
typeKey: 'ShareLinkBundle', // PermissionType.ShareLinkBundle
|
||||||
|
}
|
||||||
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('correctly checks explicit global permissions', () => {
|
it('correctly checks explicit global permissions', () => {
|
||||||
@@ -269,6 +275,10 @@ describe('PermissionsService', () => {
|
|||||||
'view_sharelink',
|
'view_sharelink',
|
||||||
'change_sharelink',
|
'change_sharelink',
|
||||||
'delete_sharelink',
|
'delete_sharelink',
|
||||||
|
'add_sharelinkbundle',
|
||||||
|
'view_sharelinkbundle',
|
||||||
|
'change_sharelinkbundle',
|
||||||
|
'delete_sharelinkbundle',
|
||||||
'add_workflow',
|
'add_workflow',
|
||||||
'view_workflow',
|
'view_workflow',
|
||||||
'change_workflow',
|
'change_workflow',
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ export enum PermissionType {
|
|||||||
User = '%s_user',
|
User = '%s_user',
|
||||||
Group = '%s_group',
|
Group = '%s_group',
|
||||||
ShareLink = '%s_sharelink',
|
ShareLink = '%s_sharelink',
|
||||||
|
ShareLinkBundle = '%s_sharelinkbundle',
|
||||||
CustomField = '%s_customfield',
|
CustomField = '%s_customfield',
|
||||||
Workflow = '%s_workflow',
|
Workflow = '%s_workflow',
|
||||||
ProcessedMail = '%s_processedmail',
|
ProcessedMail = '%s_processedmail',
|
||||||
|
|||||||
@@ -57,6 +57,19 @@ describe('LocalizedDateParserFormatter', () => {
|
|||||||
expect(val).toEqual({ day: 4, month: 5, year: 2023 })
|
expect(val).toEqual({ day: 4, month: 5, year: 2023 })
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('should parse yyyy-mm-dd input with unpadded month or day by locale', () => {
|
||||||
|
let val = dateParserFormatter.parse('2023-5-4')
|
||||||
|
expect(val).toEqual({ day: 4, month: 5, year: 2023 })
|
||||||
|
|
||||||
|
settingsService.setLanguage('de-de') // dd.mm.yyyy
|
||||||
|
val = dateParserFormatter.parse('2023-5-4')
|
||||||
|
expect(val).toEqual({ day: 4, month: 5, year: 2023 })
|
||||||
|
|
||||||
|
settingsService.setLanguage('tr-tr') // yyyy-mm-dd
|
||||||
|
val = dateParserFormatter.parse('2023-5-4')
|
||||||
|
expect(val).toEqual({ day: 4, month: 5, year: 2023 })
|
||||||
|
})
|
||||||
|
|
||||||
it('should parse date struct to string by locale', () => {
|
it('should parse date struct to string by locale', () => {
|
||||||
const dateStruct = {
|
const dateStruct = {
|
||||||
day: 4,
|
day: 4,
|
||||||
|
|||||||
@@ -52,15 +52,11 @@ export class LocalizedDateParserFormatter extends NgbDateParserFormatter {
|
|||||||
let segments = value.split(this.separatorRegExp)
|
let segments = value.split(this.separatorRegExp)
|
||||||
|
|
||||||
// always accept strict yyyy*mm*dd format even if that's not the input format since we can be certain its not yyyy*dd*mm
|
// always accept strict yyyy*mm*dd format even if that's not the input format since we can be certain its not yyyy*dd*mm
|
||||||
if (
|
if (segments.length == 3 && segments[0].length == 4) {
|
||||||
value.length == 10 &&
|
|
||||||
segments.length == 3 &&
|
|
||||||
segments[0].length == 4
|
|
||||||
) {
|
|
||||||
return inputFormat
|
return inputFormat
|
||||||
.replace('yyyy', segments[0])
|
.replace('yyyy', segments[0])
|
||||||
.replace('mm', segments[1])
|
.replace('mm', segments[1].padStart(2, '0'))
|
||||||
.replace('dd', segments[2])
|
.replace('dd', segments[2].padStart(2, '0'))
|
||||||
} else {
|
} else {
|
||||||
// otherwise pad & re-join without separator
|
// otherwise pad & re-join without separator
|
||||||
value = segments.map((segment) => segment.padStart(2, '0')).join('')
|
value = segments.map((segment) => segment.padStart(2, '0')).join('')
|
||||||
|
|||||||
@@ -35,19 +35,27 @@ import {
|
|||||||
arrowRightShort,
|
arrowRightShort,
|
||||||
arrowUpRight,
|
arrowUpRight,
|
||||||
asterisk,
|
asterisk,
|
||||||
|
bank,
|
||||||
|
basket,
|
||||||
bell,
|
bell,
|
||||||
bodyText,
|
bodyText,
|
||||||
|
bookmark,
|
||||||
boxArrowUp,
|
boxArrowUp,
|
||||||
boxArrowUpRight,
|
boxArrowUpRight,
|
||||||
boxes,
|
boxes,
|
||||||
braces,
|
braces,
|
||||||
|
briefcase,
|
||||||
|
building,
|
||||||
|
calculator,
|
||||||
calendar,
|
calendar,
|
||||||
calendarEvent,
|
calendarEvent,
|
||||||
calendarEventFill,
|
calendarEventFill,
|
||||||
|
camera,
|
||||||
cardChecklist,
|
cardChecklist,
|
||||||
cardHeading,
|
cardHeading,
|
||||||
caretDown,
|
caretDown,
|
||||||
caretUp,
|
caretUp,
|
||||||
|
cash,
|
||||||
chatLeftText,
|
chatLeftText,
|
||||||
chatSquareDots,
|
chatSquareDots,
|
||||||
check,
|
check,
|
||||||
@@ -65,6 +73,7 @@ import {
|
|||||||
clipboardCheckFill,
|
clipboardCheckFill,
|
||||||
clipboardFill,
|
clipboardFill,
|
||||||
clockHistory,
|
clockHistory,
|
||||||
|
creditCard,
|
||||||
dash,
|
dash,
|
||||||
dashCircle,
|
dashCircle,
|
||||||
diagram3,
|
diagram3,
|
||||||
@@ -83,9 +92,12 @@ import {
|
|||||||
fileEarmarkDiff,
|
fileEarmarkDiff,
|
||||||
fileEarmarkFill,
|
fileEarmarkFill,
|
||||||
fileEarmarkLock,
|
fileEarmarkLock,
|
||||||
|
fileEarmarkMedical,
|
||||||
fileEarmarkMinus,
|
fileEarmarkMinus,
|
||||||
|
fileEarmarkPerson,
|
||||||
fileEarmarkPlus,
|
fileEarmarkPlus,
|
||||||
fileEarmarkRichtext,
|
fileEarmarkRichtext,
|
||||||
|
fileEarmarkSpreadsheet,
|
||||||
fileText,
|
fileText,
|
||||||
files,
|
files,
|
||||||
filter,
|
filter,
|
||||||
@@ -93,12 +105,15 @@ import {
|
|||||||
folderFill,
|
folderFill,
|
||||||
funnel,
|
funnel,
|
||||||
gear,
|
gear,
|
||||||
|
globe2,
|
||||||
google,
|
google,
|
||||||
grid,
|
grid,
|
||||||
gripVertical,
|
gripVertical,
|
||||||
hash,
|
hash,
|
||||||
hddStack,
|
hddStack,
|
||||||
|
heart,
|
||||||
house,
|
house,
|
||||||
|
inbox,
|
||||||
infoCircle,
|
infoCircle,
|
||||||
journals,
|
journals,
|
||||||
link,
|
link,
|
||||||
@@ -106,7 +121,9 @@ import {
|
|||||||
listTask,
|
listTask,
|
||||||
listUl,
|
listUl,
|
||||||
microsoft,
|
microsoft,
|
||||||
|
newspaper,
|
||||||
nodePlus,
|
nodePlus,
|
||||||
|
paperclip,
|
||||||
pencil,
|
pencil,
|
||||||
people,
|
people,
|
||||||
peopleFill,
|
peopleFill,
|
||||||
@@ -121,9 +138,12 @@ import {
|
|||||||
plusCircle,
|
plusCircle,
|
||||||
printer,
|
printer,
|
||||||
questionCircle,
|
questionCircle,
|
||||||
|
receipt,
|
||||||
|
safe,
|
||||||
scissors,
|
scissors,
|
||||||
search,
|
search,
|
||||||
send,
|
send,
|
||||||
|
shop,
|
||||||
slashCircle,
|
slashCircle,
|
||||||
sliders2Vertical,
|
sliders2Vertical,
|
||||||
sortAlphaDown,
|
sortAlphaDown,
|
||||||
@@ -133,14 +153,17 @@ import {
|
|||||||
tag,
|
tag,
|
||||||
tagFill,
|
tagFill,
|
||||||
tags,
|
tags,
|
||||||
|
telephone,
|
||||||
textIndentLeft,
|
textIndentLeft,
|
||||||
textLeft,
|
textLeft,
|
||||||
threeDots,
|
threeDots,
|
||||||
threeDotsVertical,
|
threeDotsVertical,
|
||||||
trash,
|
trash,
|
||||||
|
truck,
|
||||||
uiRadios,
|
uiRadios,
|
||||||
unlock,
|
unlock,
|
||||||
upcScan,
|
upcScan,
|
||||||
|
wallet2,
|
||||||
windowStack,
|
windowStack,
|
||||||
x,
|
x,
|
||||||
xCircle,
|
xCircle,
|
||||||
@@ -258,15 +281,22 @@ const icons = {
|
|||||||
arrowRightShort,
|
arrowRightShort,
|
||||||
arrowUpRight,
|
arrowUpRight,
|
||||||
asterisk,
|
asterisk,
|
||||||
|
bank,
|
||||||
|
basket,
|
||||||
bell,
|
bell,
|
||||||
braces,
|
braces,
|
||||||
bodyText,
|
bodyText,
|
||||||
|
bookmark,
|
||||||
boxArrowUp,
|
boxArrowUp,
|
||||||
boxArrowUpRight,
|
boxArrowUpRight,
|
||||||
boxes,
|
boxes,
|
||||||
|
briefcase,
|
||||||
|
building,
|
||||||
|
calculator,
|
||||||
calendar,
|
calendar,
|
||||||
calendarEvent,
|
calendarEvent,
|
||||||
calendarEventFill,
|
calendarEventFill,
|
||||||
|
camera,
|
||||||
cardChecklist,
|
cardChecklist,
|
||||||
cardHeading,
|
cardHeading,
|
||||||
caretDown,
|
caretDown,
|
||||||
@@ -288,6 +318,8 @@ const icons = {
|
|||||||
clipboardCheckFill,
|
clipboardCheckFill,
|
||||||
clipboardFill,
|
clipboardFill,
|
||||||
clockHistory,
|
clockHistory,
|
||||||
|
cash,
|
||||||
|
creditCard,
|
||||||
dash,
|
dash,
|
||||||
dashCircle,
|
dashCircle,
|
||||||
diagram3,
|
diagram3,
|
||||||
@@ -306,9 +338,12 @@ const icons = {
|
|||||||
fileEarmarkDiff,
|
fileEarmarkDiff,
|
||||||
fileEarmarkFill,
|
fileEarmarkFill,
|
||||||
fileEarmarkLock,
|
fileEarmarkLock,
|
||||||
|
fileEarmarkMedical,
|
||||||
fileEarmarkMinus,
|
fileEarmarkMinus,
|
||||||
|
fileEarmarkPerson,
|
||||||
fileEarmarkPlus,
|
fileEarmarkPlus,
|
||||||
fileEarmarkRichtext,
|
fileEarmarkRichtext,
|
||||||
|
fileEarmarkSpreadsheet,
|
||||||
files,
|
files,
|
||||||
fileText,
|
fileText,
|
||||||
filter,
|
filter,
|
||||||
@@ -316,12 +351,15 @@ const icons = {
|
|||||||
folderFill,
|
folderFill,
|
||||||
funnel,
|
funnel,
|
||||||
gear,
|
gear,
|
||||||
|
globe2,
|
||||||
google,
|
google,
|
||||||
grid,
|
grid,
|
||||||
gripVertical,
|
gripVertical,
|
||||||
hash,
|
hash,
|
||||||
hddStack,
|
hddStack,
|
||||||
|
heart,
|
||||||
house,
|
house,
|
||||||
|
inbox,
|
||||||
infoCircle,
|
infoCircle,
|
||||||
journals,
|
journals,
|
||||||
link,
|
link,
|
||||||
@@ -329,8 +367,10 @@ const icons = {
|
|||||||
listTask,
|
listTask,
|
||||||
listUl,
|
listUl,
|
||||||
microsoft,
|
microsoft,
|
||||||
|
newspaper,
|
||||||
nodePlus,
|
nodePlus,
|
||||||
pencil,
|
pencil,
|
||||||
|
paperclip,
|
||||||
people,
|
people,
|
||||||
peopleFill,
|
peopleFill,
|
||||||
person,
|
person,
|
||||||
@@ -344,10 +384,13 @@ const icons = {
|
|||||||
plusCircle,
|
plusCircle,
|
||||||
printer,
|
printer,
|
||||||
questionCircle,
|
questionCircle,
|
||||||
|
receipt,
|
||||||
|
safe,
|
||||||
scissors,
|
scissors,
|
||||||
search,
|
search,
|
||||||
send,
|
send,
|
||||||
slashCircle,
|
slashCircle,
|
||||||
|
shop,
|
||||||
sliders2Vertical,
|
sliders2Vertical,
|
||||||
sortAlphaDown,
|
sortAlphaDown,
|
||||||
sortAlphaUpAlt,
|
sortAlphaUpAlt,
|
||||||
@@ -358,12 +401,15 @@ const icons = {
|
|||||||
tags,
|
tags,
|
||||||
textIndentLeft,
|
textIndentLeft,
|
||||||
textLeft,
|
textLeft,
|
||||||
|
telephone,
|
||||||
threeDots,
|
threeDots,
|
||||||
threeDotsVertical,
|
threeDotsVertical,
|
||||||
trash,
|
trash,
|
||||||
|
truck,
|
||||||
uiRadios,
|
uiRadios,
|
||||||
unlock,
|
unlock,
|
||||||
upcScan,
|
upcScan,
|
||||||
|
wallet2,
|
||||||
windowStack,
|
windowStack,
|
||||||
x,
|
x,
|
||||||
xCircle,
|
xCircle,
|
||||||
|
|||||||
@@ -19,6 +19,13 @@ export const GlobalWorkerOptions = {
|
|||||||
workerSrc: '',
|
workerSrc: '',
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const AnnotationMode = {
|
||||||
|
DISABLE: 0,
|
||||||
|
ENABLE: 1,
|
||||||
|
ENABLE_FORMS: 2,
|
||||||
|
ENABLE_STORAGE: 3,
|
||||||
|
}
|
||||||
|
|
||||||
export const getDocument = (_src: unknown): PDFDocumentLoadingTask => {
|
export const getDocument = (_src: unknown): PDFDocumentLoadingTask => {
|
||||||
return new PDFDocumentLoadingTask(Promise.resolve(new PDFDocumentProxy()))
|
return new PDFDocumentLoadingTask(Promise.resolve(new PDFDocumentProxy()))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,346 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import abc
|
||||||
|
import hashlib
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import shutil
|
||||||
|
import tempfile
|
||||||
|
import zipfile
|
||||||
|
from contextlib import AbstractContextManager
|
||||||
|
from contextlib import contextmanager
|
||||||
|
from pathlib import Path
|
||||||
|
from pathlib import PurePosixPath
|
||||||
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
|
from django.conf import settings
|
||||||
|
from django.core.serializers.json import DjangoJSONEncoder
|
||||||
|
|
||||||
|
from documents.file_handling import delete_empty_directories
|
||||||
|
from documents.utils import compute_checksum
|
||||||
|
from documents.utils import copy_file_with_basic_stats
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from collections.abc import Iterator
|
||||||
|
from typing import TextIO
|
||||||
|
|
||||||
|
|
||||||
|
def _dumps(content: list | dict) -> str:
|
||||||
|
"""Serialize export JSON consistently across all sinks."""
|
||||||
|
return json.dumps(content, cls=DjangoJSONEncoder, indent=2, ensure_ascii=False)
|
||||||
|
|
||||||
|
|
||||||
|
class StreamingManifestWriter:
|
||||||
|
"""Incrementally writes a JSON array to a text handle, one record at a time.
|
||||||
|
|
||||||
|
Knows nothing about folders or zips: it writes the array framing and records
|
||||||
|
to whatever handle the sink's ``stream()`` yields. The sink owns the handle's
|
||||||
|
lifecycle (atomic rename, compare, spooling).
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, handle: TextIO) -> None:
|
||||||
|
self._file = handle
|
||||||
|
self._first = True
|
||||||
|
self._file.write("[")
|
||||||
|
|
||||||
|
def write_record(self, record: dict) -> None:
|
||||||
|
if not self._first:
|
||||||
|
self._file.write(",\n")
|
||||||
|
else:
|
||||||
|
self._first = False
|
||||||
|
self._file.write(_dumps(record))
|
||||||
|
|
||||||
|
def write_batch(self, records: list[dict]) -> None:
|
||||||
|
for record in records:
|
||||||
|
self.write_record(record)
|
||||||
|
|
||||||
|
def close(self) -> None:
|
||||||
|
"""Write the closing bracket. Does NOT close the handle (the sink owns it)."""
|
||||||
|
self._file.write("\n]")
|
||||||
|
|
||||||
|
|
||||||
|
class ExportSink(AbstractContextManager, abc.ABC):
|
||||||
|
"""Destination for a document export.
|
||||||
|
|
||||||
|
The command declares export contents via three verbs; the sink decides how to
|
||||||
|
persist each. ``arcname`` is always a relative POSIX path
|
||||||
|
(e.g. ``"manifest.json"``, ``"originals/foo.pdf"``).
|
||||||
|
|
||||||
|
Contract:
|
||||||
|
* At most one ``stream()`` open at a time (it is the manifest);
|
||||||
|
``add_file``/``add_json`` may be called while it is open.
|
||||||
|
* Context-manager: normal exit finalizes, an exception aborts. No partial or
|
||||||
|
failed run leaves a complete-looking artifact.
|
||||||
|
"""
|
||||||
|
|
||||||
|
@abc.abstractmethod
|
||||||
|
def add_file(
|
||||||
|
self,
|
||||||
|
source: Path,
|
||||||
|
arcname: str,
|
||||||
|
*,
|
||||||
|
checksum: str | None = None,
|
||||||
|
) -> None: ...
|
||||||
|
|
||||||
|
@abc.abstractmethod
|
||||||
|
def add_json(self, content: list | dict, arcname: str) -> None: ...
|
||||||
|
|
||||||
|
@abc.abstractmethod
|
||||||
|
def stream(self, arcname: str) -> AbstractContextManager[TextIO]: ...
|
||||||
|
|
||||||
|
def _open(self) -> None:
|
||||||
|
"""Hook called on context entry. Override as needed."""
|
||||||
|
|
||||||
|
@abc.abstractmethod
|
||||||
|
def _finalize(self) -> None:
|
||||||
|
"""Commit on clean exit."""
|
||||||
|
|
||||||
|
@abc.abstractmethod
|
||||||
|
def _abort(self) -> None:
|
||||||
|
"""Roll back on exception."""
|
||||||
|
|
||||||
|
def __enter__(self) -> ExportSink:
|
||||||
|
self._open()
|
||||||
|
return self
|
||||||
|
|
||||||
|
def __exit__(self, exc_type, exc_val, exc_tb) -> None:
|
||||||
|
if exc_type is not None:
|
||||||
|
self._abort()
|
||||||
|
else:
|
||||||
|
self._finalize()
|
||||||
|
|
||||||
|
|
||||||
|
class DirectoryExportSink(ExportSink):
|
||||||
|
"""Writes loose files into a target directory, with incremental sync.
|
||||||
|
|
||||||
|
Owns the snapshot/skip/compare/prune machinery that used to live in the
|
||||||
|
command (``files_in_export_dir``, ``check_and_copy``, ``check_and_write_json``,
|
||||||
|
and the ``--delete`` pass).
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
target: Path,
|
||||||
|
*,
|
||||||
|
compare_checksums: bool,
|
||||||
|
compare_json: bool,
|
||||||
|
delete: bool,
|
||||||
|
) -> None:
|
||||||
|
self._target = target.resolve()
|
||||||
|
self._compare_checksums = compare_checksums
|
||||||
|
self._compare_json = compare_json
|
||||||
|
self._delete = delete
|
||||||
|
self._snapshot: set[Path] = set()
|
||||||
|
self._stream_open = False
|
||||||
|
|
||||||
|
def _open(self) -> None:
|
||||||
|
for x in self._target.glob("**/*"):
|
||||||
|
if x.is_file():
|
||||||
|
self._snapshot.add(x.resolve())
|
||||||
|
|
||||||
|
def add_file(
|
||||||
|
self,
|
||||||
|
source: Path,
|
||||||
|
arcname: str,
|
||||||
|
*,
|
||||||
|
checksum: str | None = None,
|
||||||
|
) -> None:
|
||||||
|
target = (self._target / arcname).resolve()
|
||||||
|
self._snapshot.discard(target)
|
||||||
|
perform_copy = False
|
||||||
|
if target.exists():
|
||||||
|
source_stat = source.stat()
|
||||||
|
target_stat = target.stat()
|
||||||
|
if self._compare_checksums and checksum:
|
||||||
|
perform_copy = compute_checksum(target) != checksum
|
||||||
|
elif (
|
||||||
|
source_stat.st_mtime != target_stat.st_mtime
|
||||||
|
or source_stat.st_size != target_stat.st_size
|
||||||
|
):
|
||||||
|
perform_copy = True
|
||||||
|
else:
|
||||||
|
perform_copy = True
|
||||||
|
if perform_copy:
|
||||||
|
target.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
copy_file_with_basic_stats(source, target)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _content_unchanged(target: Path, new_bytes: bytes) -> bool:
|
||||||
|
"""True if ``target`` already holds byte-identical content (BLAKE2b)."""
|
||||||
|
return (
|
||||||
|
hashlib.blake2b(target.read_bytes()).hexdigest()
|
||||||
|
== hashlib.blake2b(new_bytes).hexdigest()
|
||||||
|
)
|
||||||
|
|
||||||
|
def add_json(self, content: list | dict, arcname: str) -> None:
|
||||||
|
target = (self._target / arcname).resolve()
|
||||||
|
json_str = _dumps(content)
|
||||||
|
perform_write = True
|
||||||
|
if target in self._snapshot:
|
||||||
|
self._snapshot.discard(target)
|
||||||
|
if self._compare_json and self._content_unchanged(
|
||||||
|
target,
|
||||||
|
json_str.encode("utf-8"),
|
||||||
|
):
|
||||||
|
perform_write = False
|
||||||
|
if perform_write:
|
||||||
|
target.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
target.write_text(json_str, encoding="utf-8")
|
||||||
|
|
||||||
|
@contextmanager
|
||||||
|
def stream(self, arcname: str) -> Iterator[TextIO]:
|
||||||
|
if self._stream_open:
|
||||||
|
raise RuntimeError("A stream is already open on this sink")
|
||||||
|
target = (self._target / arcname).resolve()
|
||||||
|
tmp = target.with_suffix(target.suffix + ".tmp")
|
||||||
|
target.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
handle = tmp.open("w", encoding="utf-8")
|
||||||
|
self._stream_open = True
|
||||||
|
try:
|
||||||
|
yield handle
|
||||||
|
except BaseException:
|
||||||
|
handle.close()
|
||||||
|
tmp.unlink(missing_ok=True)
|
||||||
|
raise
|
||||||
|
else:
|
||||||
|
handle.close()
|
||||||
|
self._commit_streamed_file(target, tmp)
|
||||||
|
finally:
|
||||||
|
self._stream_open = False
|
||||||
|
|
||||||
|
def _commit_streamed_file(self, target: Path, tmp: Path) -> None:
|
||||||
|
if target in self._snapshot:
|
||||||
|
self._snapshot.discard(target)
|
||||||
|
if self._compare_json and self._content_unchanged(
|
||||||
|
target,
|
||||||
|
tmp.read_bytes(),
|
||||||
|
):
|
||||||
|
tmp.unlink()
|
||||||
|
return
|
||||||
|
tmp.rename(target)
|
||||||
|
|
||||||
|
def _finalize(self) -> None:
|
||||||
|
if self._delete:
|
||||||
|
for f in self._snapshot:
|
||||||
|
if not f.is_relative_to(self._target): # pragma: no cover
|
||||||
|
# Defense in depth: a symlink inside the export dir can
|
||||||
|
# resolve outside of it; never delete outside the target.
|
||||||
|
continue
|
||||||
|
f.unlink()
|
||||||
|
delete_empty_directories(f.parent, self._target)
|
||||||
|
|
||||||
|
def _abort(self) -> None:
|
||||||
|
# Folder mode is in-place/incremental: streamed .tmp files are already
|
||||||
|
# cleaned in stream(); leave everything else intact and skip the prune.
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
class ZipExportSink(ExportSink):
|
||||||
|
"""Writes a single zip archive, produced atomically only on success.
|
||||||
|
|
||||||
|
Builds into ``<target>/<zip_name>.zip.tmp`` and renames to ``.zip`` on clean
|
||||||
|
finalize. The manifest stream is spooled to a temp file in SCRATCH_DIR and
|
||||||
|
added as an entry at finalize (a zip entry cannot be interleaved with others).
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, target: Path, zip_name: str, *, delete: bool = False) -> None:
|
||||||
|
self._target = target.resolve()
|
||||||
|
self._zip_path = (self._target / zip_name).with_suffix(".zip")
|
||||||
|
self._tmp_path = self._zip_path.with_name(self._zip_path.name + ".tmp")
|
||||||
|
self._delete = delete
|
||||||
|
self._zip: zipfile.ZipFile | None = None
|
||||||
|
self._dirs: set[str] = set()
|
||||||
|
self._pending_manifest: tuple[Path, str] | None = None
|
||||||
|
self._stream_open = False
|
||||||
|
|
||||||
|
def _open(self) -> None:
|
||||||
|
settings.SCRATCH_DIR.mkdir(parents=True, exist_ok=True)
|
||||||
|
self._zip = zipfile.ZipFile(
|
||||||
|
self._tmp_path,
|
||||||
|
"w",
|
||||||
|
compression=zipfile.ZIP_DEFLATED,
|
||||||
|
allowZip64=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
def _ensure_dirs(self, arcname: str) -> None:
|
||||||
|
assert self._zip is not None
|
||||||
|
dir_arc = ""
|
||||||
|
for part in PurePosixPath(arcname).parts[:-1]:
|
||||||
|
dir_arc += f"{part}/"
|
||||||
|
if dir_arc not in self._dirs:
|
||||||
|
self._dirs.add(dir_arc)
|
||||||
|
self._zip.mkdir(dir_arc)
|
||||||
|
|
||||||
|
def add_file(
|
||||||
|
self,
|
||||||
|
source: Path,
|
||||||
|
arcname: str,
|
||||||
|
*,
|
||||||
|
checksum: str | None = None,
|
||||||
|
) -> None:
|
||||||
|
assert self._zip is not None
|
||||||
|
self._ensure_dirs(arcname)
|
||||||
|
self._zip.write(source, arcname=arcname)
|
||||||
|
|
||||||
|
def add_json(self, content: list | dict, arcname: str) -> None:
|
||||||
|
assert self._zip is not None
|
||||||
|
self._ensure_dirs(arcname)
|
||||||
|
self._zip.writestr(arcname, _dumps(content))
|
||||||
|
|
||||||
|
@contextmanager
|
||||||
|
def stream(self, arcname: str) -> Iterator[TextIO]:
|
||||||
|
if self._stream_open:
|
||||||
|
raise RuntimeError("A stream is already open on this sink")
|
||||||
|
settings.SCRATCH_DIR.mkdir(parents=True, exist_ok=True)
|
||||||
|
fd, tmp_name = tempfile.mkstemp(
|
||||||
|
dir=settings.SCRATCH_DIR,
|
||||||
|
prefix="export-manifest-",
|
||||||
|
suffix=".json",
|
||||||
|
)
|
||||||
|
tmp = Path(tmp_name)
|
||||||
|
handle = os.fdopen(fd, "w", encoding="utf-8")
|
||||||
|
self._stream_open = True
|
||||||
|
try:
|
||||||
|
yield handle
|
||||||
|
except BaseException:
|
||||||
|
handle.close()
|
||||||
|
tmp.unlink(missing_ok=True)
|
||||||
|
raise
|
||||||
|
else:
|
||||||
|
handle.close()
|
||||||
|
self._pending_manifest = (tmp, arcname)
|
||||||
|
finally:
|
||||||
|
self._stream_open = False
|
||||||
|
|
||||||
|
def _finalize(self) -> None:
|
||||||
|
assert self._zip is not None
|
||||||
|
if self._pending_manifest is not None:
|
||||||
|
tmp, arcname = self._pending_manifest
|
||||||
|
self._ensure_dirs(arcname)
|
||||||
|
self._zip.write(tmp, arcname=arcname)
|
||||||
|
tmp.unlink(missing_ok=True)
|
||||||
|
self._pending_manifest = None
|
||||||
|
self._zip.close()
|
||||||
|
self._zip = None
|
||||||
|
if self._delete:
|
||||||
|
self._wipe_destination()
|
||||||
|
self._tmp_path.replace(self._zip_path)
|
||||||
|
|
||||||
|
def _wipe_destination(self) -> None:
|
||||||
|
skip = {self._zip_path.resolve(), self._tmp_path.resolve()}
|
||||||
|
for item in self._target.glob("*"):
|
||||||
|
if item.resolve() in skip:
|
||||||
|
continue
|
||||||
|
if item.is_dir():
|
||||||
|
shutil.rmtree(item)
|
||||||
|
else:
|
||||||
|
item.unlink()
|
||||||
|
|
||||||
|
def _abort(self) -> None:
|
||||||
|
if self._zip is not None:
|
||||||
|
self._zip.close()
|
||||||
|
self._zip = None
|
||||||
|
self._tmp_path.unlink(missing_ok=True)
|
||||||
|
if self._pending_manifest is not None:
|
||||||
|
self._pending_manifest[0].unlink(missing_ok=True)
|
||||||
|
self._pending_manifest = None
|
||||||
+35
-50
@@ -39,7 +39,6 @@ from guardian.utils import get_user_obj_perms_model
|
|||||||
from rest_framework import serializers
|
from rest_framework import serializers
|
||||||
from rest_framework.filters import BaseFilterBackend
|
from rest_framework.filters import BaseFilterBackend
|
||||||
from rest_framework.filters import OrderingFilter
|
from rest_framework.filters import OrderingFilter
|
||||||
from rest_framework_guardian.filters import ObjectPermissionsFilter
|
|
||||||
|
|
||||||
from documents.models import Correspondent
|
from documents.models import Correspondent
|
||||||
from documents.models import CustomField
|
from documents.models import CustomField
|
||||||
@@ -51,7 +50,7 @@ from documents.models import ShareLink
|
|||||||
from documents.models import ShareLinkBundle
|
from documents.models import ShareLinkBundle
|
||||||
from documents.models import StoragePath
|
from documents.models import StoragePath
|
||||||
from documents.models import Tag
|
from documents.models import Tag
|
||||||
from documents.permissions import permitted_document_ids
|
from documents.permissions import permitted_object_ids
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from collections.abc import Callable
|
from collections.abc import Callable
|
||||||
@@ -725,9 +724,13 @@ class CustomFieldQueryParser:
|
|||||||
)
|
)
|
||||||
|
|
||||||
# First we look up reverse links from the requested documents.
|
# First we look up reverse links from the requested documents.
|
||||||
|
# Scoped to this specific field (not just any document link field) and
|
||||||
|
# excluding unset instances, which have a null value_document_ids and
|
||||||
|
# are equivalent to having no reverse link at all.
|
||||||
links = CustomFieldInstance.objects.filter(
|
links = CustomFieldInstance.objects.filter(
|
||||||
document_id__in=value,
|
document_id__in=value,
|
||||||
field__data_type=CustomField.FieldDataType.DOCUMENTLINK,
|
field=custom_field,
|
||||||
|
value_document_ids__isnull=False,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Check if any of the requested IDs are missing.
|
# Check if any of the requested IDs are missing.
|
||||||
@@ -1024,59 +1027,41 @@ class PaperlessTaskFilterSet(FilterSet):
|
|||||||
return queryset.exclude(status__in=PaperlessTask.COMPLETE_STATUSES)
|
return queryset.exclude(status__in=PaperlessTask.COMPLETE_STATUSES)
|
||||||
|
|
||||||
|
|
||||||
class ObjectOwnedOrGrantedPermissionsFilter(ObjectPermissionsFilter):
|
class PermittedObjectsFilter(BaseFilterBackend):
|
||||||
"""
|
"""
|
||||||
A filter backend that limits results to those where the requesting user
|
Filters a queryset down to objects the requesting user owns, are
|
||||||
has read object level permissions, owns the objects, or objects without
|
unowned, or (when ``include_granted`` is True) has an explicit
|
||||||
an owner (for backwards compat)
|
user/group guardian permission on. Backed by ``permitted_object_ids``
|
||||||
|
-- a single ``id__in`` subquery, not a join -- so it can't produce
|
||||||
|
duplicate rows even when the base queryset already carries independent
|
||||||
|
joins (e.g. multi-value ``tags__id__all`` filtering), and stays
|
||||||
|
index-friendly at scale instead of falling back to guardian's
|
||||||
|
varchar-cast join.
|
||||||
|
|
||||||
|
Set ``include_granted = False`` on a subclass for endpoints that
|
||||||
|
intentionally only show owned/unowned objects regardless of explicit
|
||||||
|
shares (e.g. ``TrashView``).
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
include_granted: bool = True
|
||||||
|
perm_codename: str | None = None
|
||||||
|
|
||||||
def filter_queryset(self, request, queryset, view):
|
def filter_queryset(self, request, queryset, view):
|
||||||
|
# Before the superuser and owner-only paths, neither of which consults
|
||||||
|
# permitted_object_ids. Scoped to authenticated users so anonymous
|
||||||
|
# access (AnonymousUser.is_active is False) keeps its existing
|
||||||
|
# unowned-only behaviour.
|
||||||
|
if request.user.is_authenticated and not request.user.is_active:
|
||||||
|
return queryset.none()
|
||||||
if request.user.is_superuser:
|
if request.user.is_superuser:
|
||||||
return queryset
|
return queryset
|
||||||
objects_with_perms = super().filter_queryset(request, queryset, view)
|
if not self.include_granted:
|
||||||
objects_owned = queryset.filter(owner=request.user)
|
return queryset.filter(Q(owner=request.user) | Q(owner__isnull=True))
|
||||||
objects_unowned = queryset.filter(owner__isnull=True)
|
model = queryset.model
|
||||||
return objects_with_perms | objects_owned | objects_unowned
|
perm = self.perm_codename or f"view_{model._meta.model_name}"
|
||||||
|
return queryset.filter(
|
||||||
|
id__in=permitted_object_ids(request.user, model, perm),
|
||||||
class DocumentPermissionsFilter(BaseFilterBackend):
|
)
|
||||||
"""
|
|
||||||
A filter backend limiting Document results to those the requesting user
|
|
||||||
owns, are unowned, or has explicit (user- or group-level) view
|
|
||||||
permission on.
|
|
||||||
|
|
||||||
Unlike ``ObjectOwnedOrGrantedPermissionsFilter``, this does not build an
|
|
||||||
``objects_with_perms | objects_owned | objects_unowned`` union of
|
|
||||||
querysets derived from the same base queryset. When that base queryset
|
|
||||||
already carries independent joins on a multi-valued relation (e.g. two
|
|
||||||
separate joins from ``tags__id__all`` filtering on two tags), each
|
|
||||||
OR-ed branch can end up pairing those joins' aliases differently,
|
|
||||||
letting more than one row out of the join's cross product satisfy the
|
|
||||||
combined WHERE -- returning the same document more than once. Filtering
|
|
||||||
via a single ``id__in`` against ``permitted_document_ids`` (a plain
|
|
||||||
subquery, not a join) sidesteps that entirely and is also cheaper than
|
|
||||||
guardian's join-based permission check.
|
|
||||||
"""
|
|
||||||
|
|
||||||
def filter_queryset(self, request, queryset, view):
|
|
||||||
if request.user.is_superuser:
|
|
||||||
return queryset
|
|
||||||
return queryset.filter(id__in=permitted_document_ids(request.user))
|
|
||||||
|
|
||||||
|
|
||||||
class ObjectOwnedPermissionsFilter(ObjectPermissionsFilter):
|
|
||||||
"""
|
|
||||||
A filter backend that limits results to those where the requesting user
|
|
||||||
owns the objects or objects without an owner (for backwards compat)
|
|
||||||
"""
|
|
||||||
|
|
||||||
def filter_queryset(self, request, queryset, view):
|
|
||||||
if request.user.is_superuser:
|
|
||||||
return queryset
|
|
||||||
objects_owned = queryset.filter(owner=request.user)
|
|
||||||
objects_unowned = queryset.filter(owner__isnull=True)
|
|
||||||
return objects_owned | objects_unowned
|
|
||||||
|
|
||||||
|
|
||||||
class DocumentsOrderingFilter(OrderingFilter):
|
class DocumentsOrderingFilter(OrderingFilter):
|
||||||
|
|||||||
@@ -632,8 +632,20 @@ class Command(BaseCommand):
|
|||||||
# Process each change
|
# Process each change
|
||||||
for change_type, path in changes:
|
for change_type, path in changes:
|
||||||
path = Path(path).resolve()
|
path = Path(path).resolve()
|
||||||
|
if change_type == Change.deleted:
|
||||||
|
# Consumed (or otherwise removed); a later file
|
||||||
|
# reusing this name must not be skipped as
|
||||||
|
# already-queued.
|
||||||
|
queued.discard(path)
|
||||||
if not path.is_file():
|
if not path.is_file():
|
||||||
continue
|
continue
|
||||||
|
if path in queued:
|
||||||
|
# Already queued and awaiting consumption; a stray
|
||||||
|
# event (NAS metadata touch, AV scan, etc.) while
|
||||||
|
# the file sits on disk mid-consumption must not
|
||||||
|
# cause it to be queued a second time (GH #13511).
|
||||||
|
logger.debug(f"Ignoring event for queued file: {path}")
|
||||||
|
continue
|
||||||
logger.debug(f"Event: {change_type.name} for {path}")
|
logger.debug(f"Event: {change_type.name} for {path}")
|
||||||
tracker.track(path, change_type)
|
tracker.track(path, change_type)
|
||||||
|
|
||||||
|
|||||||
@@ -1,8 +1,4 @@
|
|||||||
import hashlib
|
|
||||||
import json
|
|
||||||
import os
|
import os
|
||||||
import shutil
|
|
||||||
import tempfile
|
|
||||||
from itertools import islice
|
from itertools import islice
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import TYPE_CHECKING
|
from typing import TYPE_CHECKING
|
||||||
@@ -19,7 +15,6 @@ from django.contrib.auth.models import User
|
|||||||
from django.contrib.contenttypes.models import ContentType
|
from django.contrib.contenttypes.models import ContentType
|
||||||
from django.core import serializers
|
from django.core import serializers
|
||||||
from django.core.management.base import CommandError
|
from django.core.management.base import CommandError
|
||||||
from django.core.serializers.json import DjangoJSONEncoder
|
|
||||||
from django.db import transaction
|
from django.db import transaction
|
||||||
from django.utils import timezone
|
from django.utils import timezone
|
||||||
from filelock import FileLock
|
from filelock import FileLock
|
||||||
@@ -34,7 +29,10 @@ if TYPE_CHECKING:
|
|||||||
if settings.AUDIT_LOG_ENABLED:
|
if settings.AUDIT_LOG_ENABLED:
|
||||||
from auditlog.models import LogEntry
|
from auditlog.models import LogEntry
|
||||||
|
|
||||||
from documents.file_handling import delete_empty_directories
|
from documents.export.sinks import DirectoryExportSink
|
||||||
|
from documents.export.sinks import ExportSink
|
||||||
|
from documents.export.sinks import StreamingManifestWriter
|
||||||
|
from documents.export.sinks import ZipExportSink
|
||||||
from documents.file_handling import generate_filename
|
from documents.file_handling import generate_filename
|
||||||
from documents.management.commands.base import PaperlessCommand
|
from documents.management.commands.base import PaperlessCommand
|
||||||
from documents.management.commands.mixins import CryptMixin
|
from documents.management.commands.mixins import CryptMixin
|
||||||
@@ -60,8 +58,7 @@ from documents.settings import EXPORTER_ARCHIVE_NAME
|
|||||||
from documents.settings import EXPORTER_FILE_NAME
|
from documents.settings import EXPORTER_FILE_NAME
|
||||||
from documents.settings import EXPORTER_SHARE_LINK_BUNDLE_NAME
|
from documents.settings import EXPORTER_SHARE_LINK_BUNDLE_NAME
|
||||||
from documents.settings import EXPORTER_THUMBNAIL_NAME
|
from documents.settings import EXPORTER_THUMBNAIL_NAME
|
||||||
from documents.utils import compute_checksum
|
from documents.utils import QuerySetStream
|
||||||
from documents.utils import copy_file_with_basic_stats
|
|
||||||
from paperless import version
|
from paperless import version
|
||||||
from paperless.models import ApplicationConfiguration
|
from paperless.models import ApplicationConfiguration
|
||||||
from paperless_mail.models import MailAccount
|
from paperless_mail.models import MailAccount
|
||||||
@@ -84,87 +81,6 @@ def serialize_queryset_batched(
|
|||||||
yield serializers.serialize("python", chunk)
|
yield serializers.serialize("python", chunk)
|
||||||
|
|
||||||
|
|
||||||
class StreamingManifestWriter:
|
|
||||||
"""Incrementally writes a JSON array to a file, one record at a time.
|
|
||||||
|
|
||||||
Writes to <target>.tmp first; on close(), optionally BLAKE2b-compares
|
|
||||||
with the existing file (--compare-json) and renames or discards accordingly.
|
|
||||||
On exception, discard() deletes the tmp file and leaves the original intact.
|
|
||||||
"""
|
|
||||||
|
|
||||||
def __init__(
|
|
||||||
self,
|
|
||||||
path: Path,
|
|
||||||
*,
|
|
||||||
compare_json: bool = False,
|
|
||||||
files_in_export_dir: "set[Path] | None" = None,
|
|
||||||
) -> None:
|
|
||||||
self._path = path.resolve()
|
|
||||||
self._tmp_path = self._path.with_suffix(self._path.suffix + ".tmp")
|
|
||||||
self._compare_json = compare_json
|
|
||||||
self._files_in_export_dir: set[Path] = (
|
|
||||||
files_in_export_dir if files_in_export_dir is not None else set()
|
|
||||||
)
|
|
||||||
self._file = None
|
|
||||||
self._first = True
|
|
||||||
|
|
||||||
def open(self) -> None:
|
|
||||||
self._path.parent.mkdir(parents=True, exist_ok=True)
|
|
||||||
self._file = self._tmp_path.open("w", encoding="utf-8")
|
|
||||||
self._file.write("[")
|
|
||||||
self._first = True
|
|
||||||
|
|
||||||
def write_record(self, record: dict) -> None:
|
|
||||||
if not self._first:
|
|
||||||
self._file.write(",\n")
|
|
||||||
else:
|
|
||||||
self._first = False
|
|
||||||
self._file.write(
|
|
||||||
json.dumps(record, cls=DjangoJSONEncoder, indent=2, ensure_ascii=False),
|
|
||||||
)
|
|
||||||
|
|
||||||
def write_batch(self, records: list[dict]) -> None:
|
|
||||||
for record in records:
|
|
||||||
self.write_record(record)
|
|
||||||
|
|
||||||
def close(self) -> None:
|
|
||||||
if self._file is None:
|
|
||||||
return
|
|
||||||
self._file.write("\n]")
|
|
||||||
self._file.close()
|
|
||||||
self._file = None
|
|
||||||
self._finalize()
|
|
||||||
|
|
||||||
def discard(self) -> None:
|
|
||||||
if self._file is not None:
|
|
||||||
self._file.close()
|
|
||||||
self._file = None
|
|
||||||
if self._tmp_path.exists():
|
|
||||||
self._tmp_path.unlink()
|
|
||||||
|
|
||||||
def _finalize(self) -> None:
|
|
||||||
"""Compare with existing file (if --compare-json) then rename or discard tmp."""
|
|
||||||
if self._path in self._files_in_export_dir:
|
|
||||||
self._files_in_export_dir.remove(self._path)
|
|
||||||
if self._compare_json:
|
|
||||||
existing_hash = hashlib.blake2b(self._path.read_bytes()).hexdigest()
|
|
||||||
new_hash = hashlib.blake2b(self._tmp_path.read_bytes()).hexdigest()
|
|
||||||
if existing_hash == new_hash:
|
|
||||||
self._tmp_path.unlink()
|
|
||||||
return
|
|
||||||
self._tmp_path.rename(self._path)
|
|
||||||
|
|
||||||
def __enter__(self) -> "StreamingManifestWriter":
|
|
||||||
self.open()
|
|
||||||
return self
|
|
||||||
|
|
||||||
def __exit__(self, exc_type, exc_val, exc_tb) -> None:
|
|
||||||
if exc_type is not None:
|
|
||||||
self.discard()
|
|
||||||
else:
|
|
||||||
self.close()
|
|
||||||
|
|
||||||
|
|
||||||
class Command(CryptMixin, PaperlessCommand):
|
class Command(CryptMixin, PaperlessCommand):
|
||||||
help = (
|
help = (
|
||||||
"Decrypt and rename all files in our collection into a given target "
|
"Decrypt and rename all files in our collection into a given target "
|
||||||
@@ -314,20 +230,13 @@ class Command(CryptMixin, PaperlessCommand):
|
|||||||
self.passphrase: str | None = options.get("passphrase")
|
self.passphrase: str | None = options.get("passphrase")
|
||||||
self.batch_size: int = options["batch_size"]
|
self.batch_size: int = options["batch_size"]
|
||||||
|
|
||||||
self.files_in_export_dir: set[Path] = set()
|
|
||||||
self.exported_files: set[str] = set()
|
self.exported_files: set[str] = set()
|
||||||
|
|
||||||
# If zipping, save the original target for later and
|
if self.zip_export and (self.compare_checksums or self.compare_json):
|
||||||
# get a temporary directory for the target instead
|
raise CommandError(
|
||||||
temp_dir = None
|
"--compare-checksums and --compare-json have no effect when "
|
||||||
self.original_target = self.target
|
"used with --zip",
|
||||||
if self.zip_export:
|
|
||||||
settings.SCRATCH_DIR.mkdir(parents=True, exist_ok=True)
|
|
||||||
temp_dir = tempfile.TemporaryDirectory(
|
|
||||||
dir=settings.SCRATCH_DIR,
|
|
||||||
prefix="paperless-export",
|
|
||||||
)
|
)
|
||||||
self.target = Path(temp_dir.name).resolve()
|
|
||||||
|
|
||||||
if not self.target.exists():
|
if not self.target.exists():
|
||||||
raise CommandError("That path doesn't exist")
|
raise CommandError("That path doesn't exist")
|
||||||
@@ -338,33 +247,28 @@ class Command(CryptMixin, PaperlessCommand):
|
|||||||
if not os.access(self.target, os.W_OK):
|
if not os.access(self.target, os.W_OK):
|
||||||
raise CommandError("That path doesn't appear to be writable")
|
raise CommandError("That path doesn't appear to be writable")
|
||||||
|
|
||||||
try:
|
sink: ExportSink
|
||||||
# Prevent any ongoing changes in the documents
|
if self.zip_export:
|
||||||
with FileLock(settings.MEDIA_LOCK):
|
sink = ZipExportSink(
|
||||||
self.dump()
|
self.target,
|
||||||
|
options["zip_name"],
|
||||||
|
delete=self.delete,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
sink = DirectoryExportSink(
|
||||||
|
self.target,
|
||||||
|
compare_checksums=self.compare_checksums,
|
||||||
|
compare_json=self.compare_json,
|
||||||
|
delete=self.delete,
|
||||||
|
)
|
||||||
|
|
||||||
# We've written everything to the temporary directory in this case,
|
# Prevent any ongoing changes in the documents while exporting
|
||||||
# now make an archive in the original target, with all files stored
|
with FileLock(settings.MEDIA_LOCK), sink:
|
||||||
if self.zip_export and temp_dir is not None:
|
self.dump(sink)
|
||||||
shutil.make_archive(
|
|
||||||
self.original_target / options["zip_name"],
|
|
||||||
format="zip",
|
|
||||||
root_dir=temp_dir.name,
|
|
||||||
)
|
|
||||||
|
|
||||||
finally:
|
def dump(self, sink: ExportSink) -> None:
|
||||||
# Always cleanup the temporary directory, if one was created
|
# 1. Create manifest, containing all correspondents, types, tags, storage
|
||||||
if self.zip_export and temp_dir is not None:
|
# paths, note, documents and ui_settings
|
||||||
temp_dir.cleanup()
|
|
||||||
|
|
||||||
def dump(self) -> None:
|
|
||||||
# 1. Take a snapshot of what files exist in the current export folder
|
|
||||||
for x in self.target.glob("**/*"):
|
|
||||||
if x.is_file():
|
|
||||||
self.files_in_export_dir.add(x.resolve())
|
|
||||||
|
|
||||||
# 2. Create manifest, containing all correspondents, types, tags, storage paths
|
|
||||||
# note, documents and ui_settings
|
|
||||||
_excluded_usernames = ["consumer", "AnonymousUser"]
|
_excluded_usernames = ["consumer", "AnonymousUser"]
|
||||||
manifest_key_to_object_query: dict[str, QuerySet[Any]] = {
|
manifest_key_to_object_query: dict[str, QuerySet[Any]] = {
|
||||||
"correspondents": Correspondent.objects.all(),
|
"correspondents": Correspondent.objects.all(),
|
||||||
@@ -427,13 +331,9 @@ class Command(CryptMixin, PaperlessCommand):
|
|||||||
|
|
||||||
document_manifest: list[dict] = []
|
document_manifest: list[dict] = []
|
||||||
share_link_bundle_manifest: list[dict] = []
|
share_link_bundle_manifest: list[dict] = []
|
||||||
manifest_path = (self.target / "manifest.json").resolve()
|
|
||||||
|
|
||||||
with StreamingManifestWriter(
|
with sink.stream("manifest.json") as handle:
|
||||||
manifest_path,
|
writer = StreamingManifestWriter(handle)
|
||||||
compare_json=self.compare_json,
|
|
||||||
files_in_export_dir=self.files_in_export_dir,
|
|
||||||
) as writer:
|
|
||||||
with transaction.atomic():
|
with transaction.atomic():
|
||||||
for key, qs in manifest_key_to_object_query.items():
|
for key, qs in manifest_key_to_object_query.items():
|
||||||
if key == "documents":
|
if key == "documents":
|
||||||
@@ -469,9 +369,6 @@ class Command(CryptMixin, PaperlessCommand):
|
|||||||
self._encrypt_record_inline(record)
|
self._encrypt_record_inline(record)
|
||||||
writer.write_batch(batch)
|
writer.write_batch(batch)
|
||||||
|
|
||||||
document_map: dict[int, Document] = {
|
|
||||||
d.pk: d for d in Document.global_objects.order_by("id")
|
|
||||||
}
|
|
||||||
share_link_bundle_map: dict[int, ShareLinkBundle] = {
|
share_link_bundle_map: dict[int, ShareLinkBundle] = {
|
||||||
b.pk: b
|
b.pk: b
|
||||||
for b in ShareLinkBundle.objects.order_by("id").prefetch_related(
|
for b in ShareLinkBundle.objects.order_by("id").prefetch_related(
|
||||||
@@ -479,84 +376,72 @@ class Command(CryptMixin, PaperlessCommand):
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
# 3. Export files from each document
|
# 2. Export files from each document
|
||||||
for index, document_dict in enumerate(
|
# document_manifest and this stream are both ordered by id from the
|
||||||
self.track(
|
# same underlying rows, so zip them in lockstep instead of building
|
||||||
document_manifest,
|
# a dict of every Document instance up front (QuerySetStream keeps
|
||||||
description="Exporting documents...",
|
# only one batch of documents resident at a time).
|
||||||
total=len(document_manifest),
|
documents_stream = QuerySetStream(
|
||||||
),
|
Document.global_objects.order_by("id"),
|
||||||
|
chunk_size=self.batch_size,
|
||||||
|
)
|
||||||
|
for document_dict, document in self.track(
|
||||||
|
zip(document_manifest, documents_stream, strict=True),
|
||||||
|
description="Exporting documents...",
|
||||||
|
total=len(document_manifest),
|
||||||
):
|
):
|
||||||
document = document_map[document_dict["pk"]]
|
# Both document_manifest and documents_stream come from the same
|
||||||
|
# Document.global_objects.order_by("id") query, taken while
|
||||||
|
# MEDIA_LOCK is held, so this should be unreachable -- it guards
|
||||||
|
# against silent data corruption if that invariant ever breaks.
|
||||||
|
if document.pk != document_dict["pk"]: # pragma: no cover
|
||||||
|
raise CommandError(
|
||||||
|
"Document export ordering mismatch: expected "
|
||||||
|
f"pk={document_dict['pk']}, got pk={document.pk}. "
|
||||||
|
"Documents may have changed during export.",
|
||||||
|
)
|
||||||
|
|
||||||
# 3.1. generate a unique filename
|
# generate a unique filename, then the arcnames for its files
|
||||||
base_name = self.generate_base_name(document)
|
base_name = self.generate_base_name(document)
|
||||||
|
original_arc, thumbnail_arc, archive_arc = (
|
||||||
# 3.2. write filenames into manifest
|
|
||||||
original_target, thumbnail_target, archive_target = (
|
|
||||||
self.generate_document_targets(document, base_name, document_dict)
|
self.generate_document_targets(document, base_name, document_dict)
|
||||||
)
|
)
|
||||||
|
|
||||||
# 3.3. write files to target folder
|
|
||||||
if not self.data_only:
|
if not self.data_only:
|
||||||
self.copy_document_files(
|
self.copy_document_files(
|
||||||
document,
|
document,
|
||||||
original_target,
|
sink,
|
||||||
thumbnail_target,
|
original_arc,
|
||||||
archive_target,
|
thumbnail_arc,
|
||||||
|
archive_arc,
|
||||||
)
|
)
|
||||||
|
|
||||||
if self.split_manifest:
|
if self.split_manifest:
|
||||||
self._write_split_manifest(document_dict, document, base_name)
|
self._write_split_manifest(sink, document_dict, document, base_name)
|
||||||
else:
|
else:
|
||||||
writer.write_record(document_dict)
|
writer.write_record(document_dict)
|
||||||
|
|
||||||
for bundle_dict in share_link_bundle_manifest:
|
for bundle_dict in share_link_bundle_manifest:
|
||||||
bundle = share_link_bundle_map[bundle_dict["pk"]]
|
bundle = share_link_bundle_map[bundle_dict["pk"]]
|
||||||
|
bundle_arc = self.generate_share_link_bundle_target(
|
||||||
bundle_target = self.generate_share_link_bundle_target(
|
|
||||||
bundle,
|
bundle,
|
||||||
bundle_dict,
|
bundle_dict,
|
||||||
)
|
)
|
||||||
|
if not self.data_only and bundle_arc is not None:
|
||||||
if not self.data_only and bundle_target is not None:
|
self.copy_share_link_bundle_file(bundle, sink, bundle_arc)
|
||||||
self.copy_share_link_bundle_file(bundle, bundle_target)
|
|
||||||
|
|
||||||
writer.write_record(bundle_dict)
|
writer.write_record(bundle_dict)
|
||||||
|
|
||||||
# 4.2 write version information to target folder
|
writer.close()
|
||||||
extra_metadata_path = (self.target / "metadata.json").resolve()
|
|
||||||
|
# 3. Write version (and crypto params) to metadata.json
|
||||||
|
# Django stores most crypto values in the field itself; we store
|
||||||
|
# them once here for the whole export
|
||||||
metadata: dict[str, str | int | dict[str, str | int]] = {
|
metadata: dict[str, str | int | dict[str, str | int]] = {
|
||||||
"version": version.__full_version_str__,
|
"version": version.__full_version_str__,
|
||||||
}
|
}
|
||||||
|
|
||||||
# 4.2.1 If needed, write the crypto values into the metadata
|
|
||||||
# Django stores most of these in the field itself, we store them once here
|
|
||||||
if self.passphrase:
|
if self.passphrase:
|
||||||
metadata.update(self.get_crypt_params())
|
metadata.update(self.get_crypt_params())
|
||||||
|
sink.add_json(metadata, "metadata.json")
|
||||||
self.check_and_write_json(
|
|
||||||
metadata,
|
|
||||||
extra_metadata_path,
|
|
||||||
)
|
|
||||||
|
|
||||||
if self.delete:
|
|
||||||
# 5. Remove files which we did not explicitly export in this run
|
|
||||||
if not self.zip_export:
|
|
||||||
for f in self.files_in_export_dir:
|
|
||||||
f.unlink()
|
|
||||||
|
|
||||||
delete_empty_directories(
|
|
||||||
f.parent,
|
|
||||||
self.target,
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
# 5. Remove anything in the original location (before moving the zip)
|
|
||||||
for item in self.original_target.glob("*"):
|
|
||||||
if item.is_dir():
|
|
||||||
shutil.rmtree(item)
|
|
||||||
else:
|
|
||||||
item.unlink()
|
|
||||||
|
|
||||||
def generate_base_name(self, document: Document) -> Path:
|
def generate_base_name(self, document: Document) -> Path:
|
||||||
"""
|
"""
|
||||||
@@ -584,73 +469,69 @@ class Command(CryptMixin, PaperlessCommand):
|
|||||||
document: Document,
|
document: Document,
|
||||||
base_name: Path,
|
base_name: Path,
|
||||||
document_dict: dict,
|
document_dict: dict,
|
||||||
) -> tuple[Path, Path | None, Path | None]:
|
) -> tuple[str, str | None, str | None]:
|
||||||
"""
|
"""
|
||||||
Generates the targets for a given document, including the original file, archive file and thumbnail (depending on settings).
|
Generates the relative POSIX arcnames for a document's original, thumbnail
|
||||||
|
and archive files (depending on settings), and records them in the manifest.
|
||||||
"""
|
"""
|
||||||
original_name = base_name
|
original_name = base_name
|
||||||
if self.use_folder_prefix:
|
if self.use_folder_prefix:
|
||||||
original_name = Path("originals") / original_name
|
original_name = Path("originals") / original_name
|
||||||
original_target = (self.target / original_name).resolve()
|
original_arc = original_name.as_posix()
|
||||||
document_dict[EXPORTER_FILE_NAME] = str(original_name)
|
document_dict[EXPORTER_FILE_NAME] = original_arc
|
||||||
|
|
||||||
if not self.no_thumbnail:
|
if not self.no_thumbnail:
|
||||||
thumbnail_name = base_name.parent / (base_name.stem + "-thumbnail.webp")
|
thumbnail_name = base_name.parent / (base_name.stem + "-thumbnail.webp")
|
||||||
if self.use_folder_prefix:
|
if self.use_folder_prefix:
|
||||||
thumbnail_name = Path("thumbnails") / thumbnail_name
|
thumbnail_name = Path("thumbnails") / thumbnail_name
|
||||||
thumbnail_target = (self.target / thumbnail_name).resolve()
|
thumbnail_arc = thumbnail_name.as_posix()
|
||||||
document_dict[EXPORTER_THUMBNAIL_NAME] = str(thumbnail_name)
|
document_dict[EXPORTER_THUMBNAIL_NAME] = thumbnail_arc
|
||||||
else:
|
else:
|
||||||
thumbnail_target = None
|
thumbnail_arc = None
|
||||||
|
|
||||||
if not self.no_archive and document.has_archive_version:
|
if not self.no_archive and document.has_archive_version:
|
||||||
archive_name = base_name.parent / (base_name.stem + "-archive.pdf")
|
archive_name = base_name.parent / (base_name.stem + "-archive.pdf")
|
||||||
if self.use_folder_prefix:
|
if self.use_folder_prefix:
|
||||||
archive_name = Path("archive") / archive_name
|
archive_name = Path("archive") / archive_name
|
||||||
archive_target = (self.target / archive_name).resolve()
|
archive_arc = archive_name.as_posix()
|
||||||
document_dict[EXPORTER_ARCHIVE_NAME] = str(archive_name)
|
document_dict[EXPORTER_ARCHIVE_NAME] = archive_arc
|
||||||
else:
|
else:
|
||||||
archive_target = None
|
archive_arc = None
|
||||||
|
|
||||||
return original_target, thumbnail_target, archive_target
|
return original_arc, thumbnail_arc, archive_arc
|
||||||
|
|
||||||
def copy_document_files(
|
def copy_document_files(
|
||||||
self,
|
self,
|
||||||
document: Document,
|
document: Document,
|
||||||
original_target: Path,
|
sink: ExportSink,
|
||||||
thumbnail_target: Path | None,
|
original_arc: str,
|
||||||
archive_target: Path | None,
|
thumbnail_arc: str | None,
|
||||||
|
archive_arc: str | None,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""
|
"""
|
||||||
Copies files from the document storage location to the specified target location.
|
Hands the document's files to the sink (original, thumbnail, archive).
|
||||||
|
|
||||||
If the document is encrypted, the files are decrypted before copying them to the target location.
|
|
||||||
"""
|
"""
|
||||||
self.check_and_copy(
|
sink.add_file(document.source_path, original_arc, checksum=document.checksum)
|
||||||
document.source_path,
|
|
||||||
document.checksum,
|
|
||||||
original_target,
|
|
||||||
)
|
|
||||||
|
|
||||||
if thumbnail_target:
|
if thumbnail_arc:
|
||||||
self.check_and_copy(document.thumbnail_path, None, thumbnail_target)
|
sink.add_file(document.thumbnail_path, thumbnail_arc)
|
||||||
|
|
||||||
if archive_target:
|
if archive_arc:
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
assert isinstance(document.archive_path, Path)
|
assert isinstance(document.archive_path, Path)
|
||||||
self.check_and_copy(
|
sink.add_file(
|
||||||
document.archive_path,
|
document.archive_path,
|
||||||
document.archive_checksum,
|
archive_arc,
|
||||||
archive_target,
|
checksum=document.archive_checksum,
|
||||||
)
|
)
|
||||||
|
|
||||||
def generate_share_link_bundle_target(
|
def generate_share_link_bundle_target(
|
||||||
self,
|
self,
|
||||||
bundle: ShareLinkBundle,
|
bundle: ShareLinkBundle,
|
||||||
bundle_dict: dict,
|
bundle_dict: dict,
|
||||||
) -> Path | None:
|
) -> str | None:
|
||||||
"""
|
"""
|
||||||
Generates the export target for a share link bundle file, when present.
|
Generates the relative POSIX arcname for a share link bundle file, if any.
|
||||||
"""
|
"""
|
||||||
if not bundle.file_path:
|
if not bundle.file_path:
|
||||||
return None
|
return None
|
||||||
@@ -666,25 +547,22 @@ class Command(CryptMixin, PaperlessCommand):
|
|||||||
bundle_dict["fields"]["file_path"] = portable_bundle_path.as_posix()
|
bundle_dict["fields"]["file_path"] = portable_bundle_path.as_posix()
|
||||||
bundle_dict[EXPORTER_SHARE_LINK_BUNDLE_NAME] = export_bundle_path.as_posix()
|
bundle_dict[EXPORTER_SHARE_LINK_BUNDLE_NAME] = export_bundle_path.as_posix()
|
||||||
|
|
||||||
return (self.target / export_bundle_path).resolve()
|
return export_bundle_path.as_posix()
|
||||||
|
|
||||||
def copy_share_link_bundle_file(
|
def copy_share_link_bundle_file(
|
||||||
self,
|
self,
|
||||||
bundle: ShareLinkBundle,
|
bundle: ShareLinkBundle,
|
||||||
bundle_target: Path,
|
sink: ExportSink,
|
||||||
|
bundle_arc: str,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""
|
"""
|
||||||
Copies a share link bundle ZIP into the export directory.
|
Hands a share link bundle ZIP to the sink.
|
||||||
"""
|
"""
|
||||||
bundle_source_path = bundle.absolute_file_path
|
bundle_source_path = bundle.absolute_file_path
|
||||||
if bundle_source_path is None:
|
if bundle_source_path is None:
|
||||||
raise FileNotFoundError(f"Share link bundle {bundle.pk} has no file path")
|
raise FileNotFoundError(f"Share link bundle {bundle.pk} has no file path")
|
||||||
|
|
||||||
self.check_and_copy(
|
sink.add_file(bundle_source_path, bundle_arc)
|
||||||
bundle_source_path,
|
|
||||||
None,
|
|
||||||
bundle_target,
|
|
||||||
)
|
|
||||||
|
|
||||||
def _encrypt_record_inline(self, record: dict) -> None:
|
def _encrypt_record_inline(self, record: dict) -> None:
|
||||||
"""Encrypt sensitive fields in a single record, if passphrase is set."""
|
"""Encrypt sensitive fields in a single record, if passphrase is set."""
|
||||||
@@ -700,6 +578,7 @@ class Command(CryptMixin, PaperlessCommand):
|
|||||||
|
|
||||||
def _write_split_manifest(
|
def _write_split_manifest(
|
||||||
self,
|
self,
|
||||||
|
sink: ExportSink,
|
||||||
document_dict: dict,
|
document_dict: dict,
|
||||||
document: Document,
|
document: Document,
|
||||||
base_name: Path,
|
base_name: Path,
|
||||||
@@ -721,81 +600,4 @@ class Command(CryptMixin, PaperlessCommand):
|
|||||||
manifest_name = base_name.with_name(f"{base_name.stem}-manifest.json")
|
manifest_name = base_name.with_name(f"{base_name.stem}-manifest.json")
|
||||||
if self.use_folder_prefix:
|
if self.use_folder_prefix:
|
||||||
manifest_name = Path("json") / manifest_name
|
manifest_name = Path("json") / manifest_name
|
||||||
manifest_name = (self.target / manifest_name).resolve()
|
sink.add_json(content, manifest_name.as_posix())
|
||||||
manifest_name.parent.mkdir(parents=True, exist_ok=True)
|
|
||||||
self.check_and_write_json(content, manifest_name)
|
|
||||||
|
|
||||||
def check_and_write_json(
|
|
||||||
self,
|
|
||||||
content: list[dict] | dict,
|
|
||||||
target: Path,
|
|
||||||
) -> None:
|
|
||||||
"""
|
|
||||||
Writes the source content to the target json file.
|
|
||||||
If --compare-json arg was used, don't write to target file if
|
|
||||||
the file exists and checksum is identical to content checksum.
|
|
||||||
This preserves the file timestamps when no changes are made.
|
|
||||||
"""
|
|
||||||
|
|
||||||
target = target.resolve()
|
|
||||||
perform_write = True
|
|
||||||
if target in self.files_in_export_dir:
|
|
||||||
self.files_in_export_dir.remove(target)
|
|
||||||
if self.compare_json:
|
|
||||||
target_checksum = hashlib.blake2b(target.read_bytes()).hexdigest()
|
|
||||||
src_str = json.dumps(
|
|
||||||
content,
|
|
||||||
cls=DjangoJSONEncoder,
|
|
||||||
indent=2,
|
|
||||||
ensure_ascii=False,
|
|
||||||
)
|
|
||||||
src_checksum = hashlib.blake2b(src_str.encode("utf-8")).hexdigest()
|
|
||||||
if src_checksum == target_checksum:
|
|
||||||
perform_write = False
|
|
||||||
|
|
||||||
if perform_write:
|
|
||||||
target.write_text(
|
|
||||||
json.dumps(
|
|
||||||
content,
|
|
||||||
cls=DjangoJSONEncoder,
|
|
||||||
indent=2,
|
|
||||||
ensure_ascii=False,
|
|
||||||
),
|
|
||||||
encoding="utf-8",
|
|
||||||
)
|
|
||||||
|
|
||||||
def check_and_copy(
|
|
||||||
self,
|
|
||||||
source: Path,
|
|
||||||
source_checksum: str | None,
|
|
||||||
target: Path,
|
|
||||||
) -> None:
|
|
||||||
"""
|
|
||||||
Copies the source to the target, if target doesn't exist or the target doesn't seem to match
|
|
||||||
the source attributes
|
|
||||||
"""
|
|
||||||
|
|
||||||
target = target.resolve()
|
|
||||||
if target in self.files_in_export_dir:
|
|
||||||
self.files_in_export_dir.remove(target)
|
|
||||||
|
|
||||||
perform_copy = False
|
|
||||||
|
|
||||||
if target.exists():
|
|
||||||
source_stat = source.stat()
|
|
||||||
target_stat = target.stat()
|
|
||||||
if self.compare_checksums and source_checksum:
|
|
||||||
target_checksum = compute_checksum(target)
|
|
||||||
perform_copy = target_checksum != source_checksum
|
|
||||||
elif (
|
|
||||||
source_stat.st_mtime != target_stat.st_mtime
|
|
||||||
or source_stat.st_size != target_stat.st_size
|
|
||||||
):
|
|
||||||
perform_copy = True
|
|
||||||
else:
|
|
||||||
# Copy if it does not exist
|
|
||||||
perform_copy = True
|
|
||||||
|
|
||||||
if perform_copy:
|
|
||||||
target.parent.mkdir(parents=True, exist_ok=True)
|
|
||||||
copy_file_with_basic_stats(source, target)
|
|
||||||
|
|||||||
@@ -55,7 +55,7 @@ class Command(PaperlessCommand):
|
|||||||
"--ratio",
|
"--ratio",
|
||||||
default=85.0,
|
default=85.0,
|
||||||
type=float,
|
type=float,
|
||||||
help="Ratio to consider documents a match",
|
help="Ratio to consider documents a match (0.0 - 100.0)",
|
||||||
)
|
)
|
||||||
parser.add_argument(
|
parser.add_argument(
|
||||||
"--delete",
|
"--delete",
|
||||||
@@ -69,6 +69,17 @@ class Command(PaperlessCommand):
|
|||||||
action="store_true",
|
action="store_true",
|
||||||
help="Skip the confirmation prompt when used with --delete",
|
help="Skip the confirmation prompt when used with --delete",
|
||||||
)
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--url",
|
||||||
|
default=None,
|
||||||
|
type=str,
|
||||||
|
help=(
|
||||||
|
"Base URL of the Paperless instance (e.g. "
|
||||||
|
"http://localhost:8000 or https://paperless.local). If set, matched "
|
||||||
|
"documents are shown as clickable (usually ctrl+click) links to "
|
||||||
|
"<url>/documents/<id>/details instead of by title."
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
def _render_results(
|
def _render_results(
|
||||||
self,
|
self,
|
||||||
@@ -76,6 +87,7 @@ class Command(PaperlessCommand):
|
|||||||
*,
|
*,
|
||||||
opt_ratio: float,
|
opt_ratio: float,
|
||||||
do_delete: bool,
|
do_delete: bool,
|
||||||
|
base_url: str | None = None,
|
||||||
) -> list[int]:
|
) -> list[int]:
|
||||||
"""Render match results as a Rich table. Returns list of PKs to delete."""
|
"""Render match results as a Rich table. Returns list of PKs to delete."""
|
||||||
if not matches:
|
if not matches:
|
||||||
@@ -88,13 +100,22 @@ class Command(PaperlessCommand):
|
|||||||
)
|
)
|
||||||
return []
|
return []
|
||||||
|
|
||||||
# Fetch titles for matched documents in a single query.
|
# Fetch titles for matched documents in a single query, unless we're
|
||||||
all_pks = {pk for m in matches for pk in (m.doc_one_pk, m.doc_two_pk)}
|
# going to show URLs instead.
|
||||||
titles: dict[int, str] = dict(
|
titles: dict[int, str] = {}
|
||||||
Document.objects.filter(pk__in=all_pks)
|
if not base_url:
|
||||||
.only("pk", "title")
|
all_pks = {pk for m in matches for pk in (m.doc_one_pk, m.doc_two_pk)}
|
||||||
.values_list("pk", "title"),
|
titles = dict(
|
||||||
)
|
Document.objects.filter(pk__in=all_pks)
|
||||||
|
.only("pk", "title")
|
||||||
|
.values_list("pk", "title"),
|
||||||
|
)
|
||||||
|
|
||||||
|
def _cell(pk: int) -> str:
|
||||||
|
if base_url:
|
||||||
|
doc_url = f"{base_url.rstrip('/')}/documents/{pk}/details"
|
||||||
|
return f"[link={doc_url}]{doc_url}[/link]"
|
||||||
|
return f"[dim]#{pk}[/dim] {titles.get(pk, 'Unknown')}"
|
||||||
|
|
||||||
table = Table(
|
table = Table(
|
||||||
title=f"Fuzzy Matches (threshold: {opt_ratio:.1f}%)",
|
title=f"Fuzzy Matches (threshold: {opt_ratio:.1f}%)",
|
||||||
@@ -124,8 +145,8 @@ class Command(PaperlessCommand):
|
|||||||
|
|
||||||
table.add_row(
|
table.add_row(
|
||||||
str(i),
|
str(i),
|
||||||
f"[dim]#{pk_a}[/dim] {titles.get(pk_a, 'Unknown')}",
|
_cell(pk_a),
|
||||||
f"[dim]#{pk_b}[/dim] {titles.get(pk_b, 'Unknown')}",
|
_cell(pk_b),
|
||||||
Text(f"{ratio:.1f}%", style=ratio_style),
|
Text(f"{ratio:.1f}%", style=ratio_style),
|
||||||
)
|
)
|
||||||
maybe_delete_ids.append(pk_b)
|
maybe_delete_ids.append(pk_b)
|
||||||
@@ -208,6 +229,7 @@ class Command(PaperlessCommand):
|
|||||||
matches,
|
matches,
|
||||||
opt_ratio=opt_ratio,
|
opt_ratio=opt_ratio,
|
||||||
do_delete=options["delete"],
|
do_delete=options["delete"],
|
||||||
|
base_url=options["url"],
|
||||||
)
|
)
|
||||||
|
|
||||||
if options["delete"] and maybe_delete_ids:
|
if options["delete"] and maybe_delete_ids:
|
||||||
|
|||||||
+14
-19
@@ -19,7 +19,7 @@ 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.models import WorkflowTrigger
|
from documents.models import WorkflowTrigger
|
||||||
from documents.permissions import get_objects_for_user_owner_aware
|
from documents.permissions import permitted_object_ids
|
||||||
from documents.regex import safe_regex_search
|
from documents.regex import safe_regex_search
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
@@ -55,10 +55,8 @@ def match_correspondents(document: Document, classifier: DocumentClassifier, use
|
|||||||
user = document.owner
|
user = document.owner
|
||||||
|
|
||||||
if user is not None:
|
if user is not None:
|
||||||
correspondents = get_objects_for_user_owner_aware(
|
correspondents = Correspondent.objects.filter(
|
||||||
user,
|
id__in=permitted_object_ids(user, Correspondent, "view_correspondent"),
|
||||||
"documents.view_correspondent",
|
|
||||||
Correspondent,
|
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
correspondents = Correspondent.objects.all()
|
correspondents = Correspondent.objects.all()
|
||||||
@@ -86,10 +84,8 @@ def match_document_types(document: Document, classifier: DocumentClassifier, use
|
|||||||
user = document.owner
|
user = document.owner
|
||||||
|
|
||||||
if user is not None:
|
if user is not None:
|
||||||
document_types = get_objects_for_user_owner_aware(
|
document_types = DocumentType.objects.filter(
|
||||||
user,
|
id__in=permitted_object_ids(user, DocumentType, "view_documenttype"),
|
||||||
"documents.view_documenttype",
|
|
||||||
DocumentType,
|
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
document_types = DocumentType.objects.all()
|
document_types = DocumentType.objects.all()
|
||||||
@@ -116,7 +112,9 @@ def match_tags(document: Document, classifier: DocumentClassifier, user=None):
|
|||||||
user = document.owner
|
user = document.owner
|
||||||
|
|
||||||
if user is not None:
|
if user is not None:
|
||||||
tags = get_objects_for_user_owner_aware(user, "documents.view_tag", Tag)
|
tags = Tag.objects.filter(
|
||||||
|
id__in=permitted_object_ids(user, Tag, "view_tag"),
|
||||||
|
)
|
||||||
else:
|
else:
|
||||||
tags = Tag.objects.all()
|
tags = Tag.objects.all()
|
||||||
|
|
||||||
@@ -145,10 +143,8 @@ def match_storage_paths(document: Document, classifier: DocumentClassifier, user
|
|||||||
user = document.owner
|
user = document.owner
|
||||||
|
|
||||||
if user is not None:
|
if user is not None:
|
||||||
storage_paths = get_objects_for_user_owner_aware(
|
storage_paths = StoragePath.objects.filter(
|
||||||
user,
|
id__in=permitted_object_ids(user, StoragePath, "view_storagepath"),
|
||||||
"documents.view_storagepath",
|
|
||||||
StoragePath,
|
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
storage_paths = StoragePath.objects.all()
|
storage_paths = StoragePath.objects.all()
|
||||||
@@ -300,7 +296,7 @@ def consumable_document_matches_workflow(
|
|||||||
]:
|
]:
|
||||||
reason = (
|
reason = (
|
||||||
f"Document source {document.source.name} not in"
|
f"Document source {document.source.name} not in"
|
||||||
f" {[DocumentSource(int(x)).name for x in trigger.sources]}",
|
f" {[DocumentSource(int(x)).name for x in trigger.sources]}"
|
||||||
)
|
)
|
||||||
trigger_matched = False
|
trigger_matched = False
|
||||||
|
|
||||||
@@ -310,8 +306,7 @@ def consumable_document_matches_workflow(
|
|||||||
and document.mailrule_id != trigger.filter_mailrule.pk
|
and document.mailrule_id != trigger.filter_mailrule.pk
|
||||||
):
|
):
|
||||||
reason = (
|
reason = (
|
||||||
f"Document mail rule {document.mailrule_id}"
|
f"Document mail rule {document.mailrule_id} != {trigger.filter_mailrule.pk}"
|
||||||
f" != {trigger.filter_mailrule.pk}",
|
|
||||||
)
|
)
|
||||||
trigger_matched = False
|
trigger_matched = False
|
||||||
|
|
||||||
@@ -326,7 +321,7 @@ def consumable_document_matches_workflow(
|
|||||||
):
|
):
|
||||||
reason = (
|
reason = (
|
||||||
f"Document filename {document.original_file.name} does not match"
|
f"Document filename {document.original_file.name} does not match"
|
||||||
f" {trigger.filter_filename.lower()}",
|
f" {trigger.filter_filename.lower()}"
|
||||||
)
|
)
|
||||||
trigger_matched = False
|
trigger_matched = False
|
||||||
|
|
||||||
@@ -349,7 +344,7 @@ def consumable_document_matches_workflow(
|
|||||||
):
|
):
|
||||||
reason = (
|
reason = (
|
||||||
f"Document path {document.original_file}"
|
f"Document path {document.original_file}"
|
||||||
f" does not match {trigger.filter_path}",
|
f" does not match {trigger.filter_path}"
|
||||||
)
|
)
|
||||||
trigger_matched = False
|
trigger_matched = False
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,79 @@
|
|||||||
|
from django.db import migrations
|
||||||
|
from django.db import models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
dependencies = [
|
||||||
|
("documents", "0022_add_perf_indexes"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="savedview",
|
||||||
|
name="icon",
|
||||||
|
field=models.CharField(
|
||||||
|
choices=[
|
||||||
|
("archive", "Archive"),
|
||||||
|
("bank", "Bank"),
|
||||||
|
("basket", "Basket"),
|
||||||
|
("bell", "Bell"),
|
||||||
|
("bookmark", "Bookmark"),
|
||||||
|
("boxes", "Boxes"),
|
||||||
|
("briefcase", "Briefcase"),
|
||||||
|
("building", "Building"),
|
||||||
|
("calculator", "Calculator"),
|
||||||
|
("calendar", "Calendar"),
|
||||||
|
("camera", "Camera"),
|
||||||
|
("card-checklist", "Checklist"),
|
||||||
|
("cash", "Cash"),
|
||||||
|
("chat-left-text", "Chat"),
|
||||||
|
("check-circle", "Check"),
|
||||||
|
("clipboard", "Clipboard"),
|
||||||
|
("clock-history", "Clock"),
|
||||||
|
("credit-card", "Credit card"),
|
||||||
|
("download", "Download"),
|
||||||
|
("envelope", "Envelope"),
|
||||||
|
("exclamation-triangle", "Warning"),
|
||||||
|
("file-earmark", "File"),
|
||||||
|
("file-earmark-check", "Checked file"),
|
||||||
|
("file-earmark-lock", "Locked file"),
|
||||||
|
("file-earmark-medical", "Medical file"),
|
||||||
|
("file-earmark-person", "Person file"),
|
||||||
|
("file-earmark-spreadsheet", "Spreadsheet"),
|
||||||
|
("file-text", "Text file"),
|
||||||
|
("files", "Files"),
|
||||||
|
("folder", "Folder"),
|
||||||
|
("funnel", "Filter"),
|
||||||
|
("gear", "Gear"),
|
||||||
|
("globe2", "Globe"),
|
||||||
|
("hash", "Hash"),
|
||||||
|
("heart", "Heart"),
|
||||||
|
("house", "House"),
|
||||||
|
("inbox", "Inbox"),
|
||||||
|
("journals", "Journals"),
|
||||||
|
("list-task", "Task list"),
|
||||||
|
("newspaper", "Newspaper"),
|
||||||
|
("paperclip", "Attachment"),
|
||||||
|
("people", "People"),
|
||||||
|
("person", "Person"),
|
||||||
|
("printer", "Printer"),
|
||||||
|
("receipt", "Receipt"),
|
||||||
|
("safe", "Safe"),
|
||||||
|
("search", "Search"),
|
||||||
|
("send", "Send"),
|
||||||
|
("shop", "Shop"),
|
||||||
|
("stack", "Stack"),
|
||||||
|
("stars", "Stars"),
|
||||||
|
("tag", "Tag"),
|
||||||
|
("tags", "Tags"),
|
||||||
|
("telephone", "Telephone"),
|
||||||
|
("truck", "Truck"),
|
||||||
|
("upc-scan", "Barcode"),
|
||||||
|
("wallet2", "Wallet"),
|
||||||
|
],
|
||||||
|
default="funnel",
|
||||||
|
max_length=64,
|
||||||
|
verbose_name="icon",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -519,6 +519,68 @@ class Document(SoftDeleteModel, ModelWithOwner): # type: ignore[django-manager-
|
|||||||
|
|
||||||
|
|
||||||
class SavedView(ModelWithOwner):
|
class SavedView(ModelWithOwner):
|
||||||
|
class Icon(models.TextChoices):
|
||||||
|
ARCHIVE = ("archive", _("Archive"))
|
||||||
|
BANK = ("bank", _("Bank"))
|
||||||
|
BASKET = ("basket", _("Basket"))
|
||||||
|
BELL = ("bell", _("Bell"))
|
||||||
|
BOOKMARK = ("bookmark", _("Bookmark"))
|
||||||
|
BOXES = ("boxes", _("Boxes"))
|
||||||
|
BRIEFCASE = ("briefcase", _("Briefcase"))
|
||||||
|
BUILDING = ("building", _("Building"))
|
||||||
|
CALCULATOR = ("calculator", _("Calculator"))
|
||||||
|
CALENDAR = ("calendar", _("Calendar"))
|
||||||
|
CAMERA = ("camera", _("Camera"))
|
||||||
|
CARD_CHECKLIST = ("card-checklist", _("Checklist"))
|
||||||
|
CASH = ("cash", _("Cash"))
|
||||||
|
CHAT_LEFT_TEXT = ("chat-left-text", _("Chat"))
|
||||||
|
CHECK_CIRCLE = ("check-circle", _("Check"))
|
||||||
|
CLIPBOARD = ("clipboard", _("Clipboard"))
|
||||||
|
CLOCK_HISTORY = ("clock-history", _("Clock"))
|
||||||
|
CREDIT_CARD = ("credit-card", _("Credit card"))
|
||||||
|
DOWNLOAD = ("download", _("Download"))
|
||||||
|
ENVELOPE = ("envelope", _("Envelope"))
|
||||||
|
EXCLAMATION_TRIANGLE = ("exclamation-triangle", _("Warning"))
|
||||||
|
FILE_EARMARK = ("file-earmark", _("File"))
|
||||||
|
FILE_EARMARK_CHECK = ("file-earmark-check", _("Checked file"))
|
||||||
|
FILE_EARMARK_LOCK = ("file-earmark-lock", _("Locked file"))
|
||||||
|
FILE_EARMARK_MEDICAL = ("file-earmark-medical", _("Medical file"))
|
||||||
|
FILE_EARMARK_PERSON = ("file-earmark-person", _("Person file"))
|
||||||
|
FILE_EARMARK_SPREADSHEET = (
|
||||||
|
"file-earmark-spreadsheet",
|
||||||
|
_("Spreadsheet"),
|
||||||
|
)
|
||||||
|
FILE_TEXT = ("file-text", _("Text file"))
|
||||||
|
FILES = ("files", _("Files"))
|
||||||
|
FOLDER = ("folder", _("Folder"))
|
||||||
|
FUNNEL = ("funnel", _("Filter"))
|
||||||
|
GEAR = ("gear", _("Gear"))
|
||||||
|
GLOBE = ("globe2", _("Globe"))
|
||||||
|
HASH = ("hash", _("Hash"))
|
||||||
|
HEART = ("heart", _("Heart"))
|
||||||
|
HOUSE = ("house", _("House"))
|
||||||
|
INBOX = ("inbox", _("Inbox"))
|
||||||
|
JOURNALS = ("journals", _("Journals"))
|
||||||
|
LIST_TASK = ("list-task", _("Task list"))
|
||||||
|
NEWSPAPER = ("newspaper", _("Newspaper"))
|
||||||
|
PAPERCLIP = ("paperclip", _("Attachment"))
|
||||||
|
PEOPLE = ("people", _("People"))
|
||||||
|
PERSON = ("person", _("Person"))
|
||||||
|
PRINTER = ("printer", _("Printer"))
|
||||||
|
RECEIPT = ("receipt", _("Receipt"))
|
||||||
|
SAFE = ("safe", _("Safe"))
|
||||||
|
SEARCH = ("search", _("Search"))
|
||||||
|
SEND = ("send", _("Send"))
|
||||||
|
SHOP = ("shop", _("Shop"))
|
||||||
|
STACK = ("stack", _("Stack"))
|
||||||
|
STARS = ("stars", _("Stars"))
|
||||||
|
TAG = ("tag", _("Tag"))
|
||||||
|
TAGS = ("tags", _("Tags"))
|
||||||
|
TELEPHONE = ("telephone", _("Telephone"))
|
||||||
|
TRUCK = ("truck", _("Truck"))
|
||||||
|
UPC_SCAN = ("upc-scan", _("Barcode"))
|
||||||
|
WALLET = ("wallet2", _("Wallet"))
|
||||||
|
|
||||||
class DisplayMode(models.TextChoices):
|
class DisplayMode(models.TextChoices):
|
||||||
TABLE = ("table", _("Table"))
|
TABLE = ("table", _("Table"))
|
||||||
SMALL_CARDS = ("smallCards", _("Small Cards"))
|
SMALL_CARDS = ("smallCards", _("Small Cards"))
|
||||||
@@ -541,6 +603,13 @@ class SavedView(ModelWithOwner):
|
|||||||
|
|
||||||
name = models.CharField(_("name"), max_length=128)
|
name = models.CharField(_("name"), max_length=128)
|
||||||
|
|
||||||
|
icon = models.CharField(
|
||||||
|
_("icon"),
|
||||||
|
max_length=64,
|
||||||
|
choices=Icon.choices,
|
||||||
|
default=Icon.FUNNEL,
|
||||||
|
)
|
||||||
|
|
||||||
sort_field = models.CharField(
|
sort_field = models.CharField(
|
||||||
_("sort field"),
|
_("sort field"),
|
||||||
max_length=128,
|
max_length=128,
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ from django.contrib.contenttypes.models import ContentType
|
|||||||
from django.db.models import Case
|
from django.db.models import Case
|
||||||
from django.db.models import Count
|
from django.db.models import Count
|
||||||
from django.db.models import IntegerField
|
from django.db.models import IntegerField
|
||||||
|
from django.db.models import Model
|
||||||
from django.db.models import Q
|
from django.db.models import Q
|
||||||
from django.db.models import QuerySet
|
from django.db.models import QuerySet
|
||||||
from django.db.models import Value
|
from django.db.models import Value
|
||||||
@@ -53,11 +54,15 @@ class PaperlessObjectPermissions(DjangoObjectPermissions):
|
|||||||
|
|
||||||
class PaperlessAdminPermissions(BasePermission):
|
class PaperlessAdminPermissions(BasePermission):
|
||||||
def has_permission(self, request, view):
|
def has_permission(self, request, view):
|
||||||
return request.user.is_staff
|
return request.user.is_active and request.user.is_staff
|
||||||
|
|
||||||
|
|
||||||
def has_global_statistics_permission(user: User | None) -> bool:
|
def has_global_statistics_permission(user: User | None) -> bool:
|
||||||
if user is None or not getattr(user, "is_authenticated", False):
|
if (
|
||||||
|
user is None
|
||||||
|
or not getattr(user, "is_active", False)
|
||||||
|
or not getattr(user, "is_authenticated", False)
|
||||||
|
):
|
||||||
return False
|
return False
|
||||||
|
|
||||||
return getattr(user, "is_superuser", False) or user.has_perm(
|
return getattr(user, "is_superuser", False) or user.has_perm(
|
||||||
@@ -66,7 +71,11 @@ def has_global_statistics_permission(user: User | None) -> bool:
|
|||||||
|
|
||||||
|
|
||||||
def has_system_status_permission(user: User | None) -> bool:
|
def has_system_status_permission(user: User | None) -> bool:
|
||||||
if user is None or not getattr(user, "is_authenticated", False):
|
if (
|
||||||
|
user is None
|
||||||
|
or not getattr(user, "is_active", False)
|
||||||
|
or not getattr(user, "is_authenticated", False)
|
||||||
|
):
|
||||||
return False
|
return False
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -163,47 +172,86 @@ def set_permissions_for_object(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def permitted_document_ids(user):
|
def permitted_object_ids(
|
||||||
|
user: User | None,
|
||||||
|
model: type[Model],
|
||||||
|
perm: str,
|
||||||
|
*,
|
||||||
|
include_deleted: bool = False,
|
||||||
|
) -> QuerySet[int]:
|
||||||
"""
|
"""
|
||||||
Return a queryset of document IDs the user may view, limited to non-deleted
|
Generic version of ``permitted_document_ids`` for any model with an
|
||||||
documents. This intentionally avoids ``get_objects_for_user`` to keep the
|
``owner`` field and guardian object-level permissions. ``include_deleted``
|
||||||
subquery small and index-friendly.
|
only has an effect for models exposing a ``global_objects``/``deleted_at``
|
||||||
|
soft-delete pattern (currently only ``Document``); for every other model
|
||||||
|
it is accepted but has no effect, since those models have no soft-delete
|
||||||
|
concept.
|
||||||
"""
|
"""
|
||||||
|
has_soft_delete = hasattr(model, "global_objects")
|
||||||
base_docs = Document.objects.filter(deleted_at__isnull=True).only("id", "owner")
|
manager = (
|
||||||
|
model.global_objects if include_deleted and has_soft_delete else model.objects
|
||||||
|
)
|
||||||
|
base_qs = manager.all().only("id", "owner")
|
||||||
|
|
||||||
if user is None or not getattr(user, "is_authenticated", False):
|
if user is None or not getattr(user, "is_authenticated", False):
|
||||||
# Just Anonymous user e.g. for drf-spectacular
|
return base_qs.filter(owner__isnull=True).values_list("id", flat=True)
|
||||||
return base_docs.filter(owner__isnull=True).values_list("id", flat=True)
|
|
||||||
|
# Deactivated users get nothing, deactivated superusers included, so this
|
||||||
|
# has to come before the superuser shortcut. guardian's
|
||||||
|
# ObjectPermissionChecker denies inactive users, but get_objects_for_user
|
||||||
|
# (the pattern this replaces) does not, so it would not be inherited.
|
||||||
|
if not getattr(user, "is_active", False):
|
||||||
|
return base_qs.none().values_list("id", flat=True)
|
||||||
|
|
||||||
if getattr(user, "is_superuser", False):
|
if getattr(user, "is_superuser", False):
|
||||||
return base_docs.values_list("id", flat=True)
|
return base_qs.values_list("id", flat=True)
|
||||||
|
|
||||||
document_ct = ContentType.objects.get_for_model(Document)
|
# Guardian's UserObjectPermission/GroupObjectPermission always store a bare
|
||||||
|
# codename, but has_perm()-style callers commonly pass the qualified
|
||||||
|
# "app_label.codename" form. content_type already disambiguates the
|
||||||
|
# codename, so just drop any prefix rather than silently under-permitting.
|
||||||
|
perm = perm.rsplit(".", 1)[-1]
|
||||||
|
|
||||||
|
content_type = ContentType.objects.get_for_model(model)
|
||||||
perm_filter = {
|
perm_filter = {
|
||||||
"permission__codename": "view_document",
|
"permission__codename": perm,
|
||||||
"permission__content_type": document_ct,
|
"permission__content_type": content_type,
|
||||||
}
|
}
|
||||||
|
|
||||||
user_perm_docs = (
|
user_perm_ids = (
|
||||||
UserObjectPermission.objects.filter(user=user, **perm_filter)
|
UserObjectPermission.objects.filter(user=user, **perm_filter)
|
||||||
.annotate(object_pk_int=Cast("object_pk", IntegerField()))
|
.annotate(object_pk_int=Cast("object_pk", IntegerField()))
|
||||||
.values_list("object_pk_int", flat=True)
|
.values_list("object_pk_int", flat=True)
|
||||||
)
|
)
|
||||||
|
group_perm_ids = (
|
||||||
group_perm_docs = (
|
|
||||||
GroupObjectPermission.objects.filter(group__user=user, **perm_filter)
|
GroupObjectPermission.objects.filter(group__user=user, **perm_filter)
|
||||||
.annotate(object_pk_int=Cast("object_pk", IntegerField()))
|
.annotate(object_pk_int=Cast("object_pk", IntegerField()))
|
||||||
.values_list("object_pk_int", flat=True)
|
.values_list("object_pk_int", flat=True)
|
||||||
)
|
)
|
||||||
|
permitted_ids = user_perm_ids.union(group_perm_ids)
|
||||||
|
|
||||||
permitted_documents = user_perm_docs.union(group_perm_docs)
|
return base_qs.filter(
|
||||||
|
Q(owner=user) | Q(owner__isnull=True) | Q(id__in=permitted_ids),
|
||||||
return base_docs.filter(
|
|
||||||
Q(owner=user) | Q(owner__isnull=True) | Q(id__in=permitted_documents),
|
|
||||||
).values_list("id", flat=True)
|
).values_list("id", flat=True)
|
||||||
|
|
||||||
|
|
||||||
|
def permitted_document_ids(
|
||||||
|
user: User | None,
|
||||||
|
*,
|
||||||
|
perm: str = "view_document",
|
||||||
|
include_deleted: bool = False,
|
||||||
|
) -> QuerySet[int]:
|
||||||
|
"""
|
||||||
|
Document-specific convenience wrapper around ``permitted_object_ids``.
|
||||||
|
Return a queryset of document IDs the user has ``perm`` on (default
|
||||||
|
``"view_document"``). By default limited to non-deleted documents; pass
|
||||||
|
``include_deleted=True`` for callers that need to check permission on
|
||||||
|
soft-deleted documents (e.g. trash restore). This intentionally avoids
|
||||||
|
``get_objects_for_user`` to keep the subquery small and index-friendly.
|
||||||
|
"""
|
||||||
|
return permitted_object_ids(user, Document, perm, include_deleted=include_deleted)
|
||||||
|
|
||||||
|
|
||||||
def get_document_count_filter_for_user(user, related_name: str = "documents"):
|
def get_document_count_filter_for_user(user, related_name: str = "documents"):
|
||||||
"""
|
"""
|
||||||
Return the Q object used to filter document counts for the given user.
|
Return the Q object used to filter document counts for the given user.
|
||||||
@@ -326,6 +374,13 @@ def get_objects_for_user_owner_aware(
|
|||||||
"""
|
"""
|
||||||
Returns objects the user owns, are unowned, or has explicit perms.
|
Returns objects the user owns, are unowned, or has explicit perms.
|
||||||
When include_deleted is True, soft-deleted items are also included.
|
When include_deleted is True, soft-deleted items are also included.
|
||||||
|
|
||||||
|
Legacy slow path (guardian-backed, O(n) style permission resolution).
|
||||||
|
Most queryset-filtering call sites have migrated onto
|
||||||
|
``PermittedObjectsFilter``/``permitted_object_ids()``, but this function
|
||||||
|
is kept because production callers still remain. Several callers remain
|
||||||
|
across ``documents/``, ``paperless_mail/``, and ``paperless_ai/`` --
|
||||||
|
grep for this function name before removing it.
|
||||||
"""
|
"""
|
||||||
manager = (
|
manager = (
|
||||||
Model.global_objects
|
Model.global_objects
|
||||||
@@ -345,6 +400,15 @@ def get_objects_for_user_owner_aware(
|
|||||||
|
|
||||||
|
|
||||||
def has_perms_owner_aware(user, perms, obj):
|
def has_perms_owner_aware(user, perms, obj):
|
||||||
|
"""
|
||||||
|
Legacy slow path (guardian-backed) single-object permission check.
|
||||||
|
|
||||||
|
The queryset-filtering side of this migrated onto
|
||||||
|
``PermittedObjectsFilter``/``permitted_object_ids()``, but this
|
||||||
|
single-object check still has many production callers. Several callers
|
||||||
|
remain across ``documents/``, ``paperless_mail/``, and ``paperless_ai/``
|
||||||
|
-- grep for this function name before removing it.
|
||||||
|
"""
|
||||||
checker = ObjectPermissionChecker(user)
|
checker = ObjectPermissionChecker(user)
|
||||||
return obj.owner is None or obj.owner == user or checker.has_perm(perms, obj)
|
return obj.owner is None or obj.owner == user or checker.has_perm(perms, obj)
|
||||||
|
|
||||||
|
|||||||
@@ -139,31 +139,38 @@ def _simple_query_tokens(raw_query: str) -> list[str]:
|
|||||||
return simple_search_tokens(raw_query)
|
return simple_search_tokens(raw_query)
|
||||||
|
|
||||||
|
|
||||||
def _build_simple_field_query(
|
def _build_simple_token_query(
|
||||||
index: tantivy.Index,
|
index: tantivy.Index,
|
||||||
field: str,
|
fields: list[str],
|
||||||
tokens: list[str],
|
token: str,
|
||||||
|
*,
|
||||||
|
allow_infix: bool,
|
||||||
) -> tantivy.Query:
|
) -> tantivy.Query:
|
||||||
patterns = []
|
escaped = regex.escape(token)
|
||||||
for idx, token in enumerate(tokens):
|
# The simple analyzer keeps punctuation inside whitespace-delimited terms.
|
||||||
escaped = regex.escape(token)
|
# Boundary-constrained query tokens may therefore begin either at the indexed
|
||||||
# For multi-token substring search, only the first token can begin mid-word.
|
# term boundary or after punctuation within a term (for example,
|
||||||
# Later tokens follow a whitespace boundary in the original query, so anchor
|
# ``medical-history``). This avoids matching a numeric token such as ``6``
|
||||||
# them to the start of the next indexed token to reduce false positives like
|
# in the middle of ``16``.
|
||||||
# matching "Z-Berichte 16" for the query "Z-Berichte 6".
|
pattern = (
|
||||||
if idx == 0:
|
f".*{escaped}.*"
|
||||||
patterns.append(f".*{escaped}.*")
|
if allow_infix
|
||||||
else:
|
else (
|
||||||
patterns.append(f"{escaped}.*")
|
f"({escaped}.*|"
|
||||||
if len(patterns) == 1:
|
rf".*[\x20-\x2f\x3a-\x40\x5b-\x60\x7b-\x7e]{escaped}.*)"
|
||||||
query = tantivy.Query.regex_query(index.schema, field, patterns[0])
|
)
|
||||||
else:
|
)
|
||||||
query = tantivy.Query.regex_phrase_query(index.schema, field, patterns)
|
field_queries: list[tuple[tantivy.Occur, tantivy.Query]] = []
|
||||||
|
for field in fields:
|
||||||
|
query = tantivy.Query.regex_query(index.schema, field, pattern)
|
||||||
|
boost = _SIMPLE_FIELD_BOOSTS.get(field, 1.0)
|
||||||
|
if boost > 1.0:
|
||||||
|
query = tantivy.Query.boost_query(query, boost)
|
||||||
|
field_queries.append((tantivy.Occur.Should, query))
|
||||||
|
|
||||||
boost = _SIMPLE_FIELD_BOOSTS.get(field, 1.0)
|
if len(field_queries) == 1:
|
||||||
if boost > 1.0:
|
return field_queries[0][1]
|
||||||
return tantivy.Query.boost_query(query, boost)
|
return tantivy.Query.boolean_query(field_queries)
|
||||||
return query
|
|
||||||
|
|
||||||
|
|
||||||
def parse_user_query(
|
def parse_user_query(
|
||||||
@@ -265,10 +272,31 @@ def parse_simple_query(
|
|||||||
|
|
||||||
clauses: list[tuple[tantivy.Occur, tantivy.Query]] = []
|
clauses: list[tuple[tantivy.Occur, tantivy.Query]] = []
|
||||||
if tokens:
|
if tokens:
|
||||||
clauses = [
|
# Match every query token, regardless of its position in the document.
|
||||||
(tantivy.Occur.Should, _build_simple_field_query(index, field, tokens))
|
# Each token may occur in any of the requested fields, so text mode also
|
||||||
for field in fields
|
# finds documents whose matches are split between title and content.
|
||||||
|
token_queries = [
|
||||||
|
(
|
||||||
|
tantivy.Occur.Must,
|
||||||
|
_build_simple_token_query(
|
||||||
|
index,
|
||||||
|
fields,
|
||||||
|
token,
|
||||||
|
# Preserve historical infix matching for single-token
|
||||||
|
# searches. In multi-token searches, constrain numeric
|
||||||
|
# tokens to boundaries to avoid partial-number overlap.
|
||||||
|
# This depends on token content, not query order.
|
||||||
|
allow_infix=len(tokens) == 1 or not token.isdecimal(),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
for token in tokens
|
||||||
]
|
]
|
||||||
|
simple_query = (
|
||||||
|
token_queries[0][1]
|
||||||
|
if len(token_queries) == 1
|
||||||
|
else tantivy.Query.boolean_query(token_queries)
|
||||||
|
)
|
||||||
|
clauses.append((tantivy.Occur.Should, simple_query))
|
||||||
|
|
||||||
if cjk_fields and _has_cjk(raw_query):
|
if cjk_fields and _has_cjk(raw_query):
|
||||||
cjk_q = _build_cjk_query(index, raw_query, cjk_fields)
|
cjk_q = _build_cjk_query(index, raw_query, cjk_fields)
|
||||||
|
|||||||
@@ -39,7 +39,6 @@ from drf_spectacular.utils import extend_schema_field
|
|||||||
from drf_spectacular.utils import extend_schema_serializer
|
from drf_spectacular.utils import extend_schema_serializer
|
||||||
from drf_writable_nested.serializers import NestedUpdateMixin
|
from drf_writable_nested.serializers import NestedUpdateMixin
|
||||||
from guardian.core import ObjectPermissionChecker
|
from guardian.core import ObjectPermissionChecker
|
||||||
from guardian.shortcuts import get_objects_for_user
|
|
||||||
from guardian.shortcuts import get_users_with_perms
|
from guardian.shortcuts import get_users_with_perms
|
||||||
from guardian.utils import get_group_obj_perms_model
|
from guardian.utils import get_group_obj_perms_model
|
||||||
from guardian.utils import get_user_obj_perms_model
|
from guardian.utils import get_user_obj_perms_model
|
||||||
@@ -80,8 +79,8 @@ from documents.models import WorkflowTrigger
|
|||||||
from documents.parsers import is_mime_type_supported
|
from documents.parsers import is_mime_type_supported
|
||||||
from documents.permissions import get_document_count_filter_for_user
|
from documents.permissions import get_document_count_filter_for_user
|
||||||
from documents.permissions import get_groups_with_only_permission
|
from documents.permissions import get_groups_with_only_permission
|
||||||
from documents.permissions import get_objects_for_user_owner_aware
|
|
||||||
from documents.permissions import has_perms_owner_aware
|
from documents.permissions import has_perms_owner_aware
|
||||||
|
from documents.permissions import permitted_document_ids
|
||||||
from documents.permissions import set_permissions_for_object
|
from documents.permissions import set_permissions_for_object
|
||||||
from documents.regex import validate_regex_pattern
|
from documents.regex import validate_regex_pattern
|
||||||
from documents.templating.filepath import validate_filepath_template_and_render
|
from documents.templating.filepath import validate_filepath_template_and_render
|
||||||
@@ -865,10 +864,10 @@ def validate_documentlink_targets(user, doc_ids):
|
|||||||
if user is None:
|
if user is None:
|
||||||
return
|
return
|
||||||
|
|
||||||
target_documents = Document.objects.filter(id__in=doc_ids).select_related("owner")
|
if (
|
||||||
if not all(
|
Document.objects.filter(id__in=doc_ids)
|
||||||
has_perms_owner_aware(user, "change_document", document)
|
.exclude(id__in=permitted_document_ids(user, perm="change_document"))
|
||||||
for document in target_documents
|
.exists()
|
||||||
):
|
):
|
||||||
raise PermissionDenied(
|
raise PermissionDenied(
|
||||||
_("Insufficient permissions."),
|
_("Insufficient permissions."),
|
||||||
@@ -1011,13 +1010,8 @@ def _get_viewable_duplicates(
|
|||||||
).exclude(pk=document.pk)
|
).exclude(pk=document.pk)
|
||||||
duplicates = duplicates.filter(root_document__isnull=True)
|
duplicates = duplicates.filter(root_document__isnull=True)
|
||||||
duplicates = duplicates.order_by("-created")
|
duplicates = duplicates.order_by("-created")
|
||||||
allowed = get_objects_for_user_owner_aware(
|
allowed_ids = permitted_document_ids(user, include_deleted=True)
|
||||||
user,
|
return duplicates.filter(id__in=allowed_ids)
|
||||||
"documents.view_document",
|
|
||||||
Document,
|
|
||||||
include_deleted=True,
|
|
||||||
)
|
|
||||||
return duplicates.filter(id__in=allowed)
|
|
||||||
|
|
||||||
|
|
||||||
class DuplicateDocumentSummarySerializer(serializers.Serializer[dict[str, Any]]):
|
class DuplicateDocumentSummarySerializer(serializers.Serializer[dict[str, Any]]):
|
||||||
@@ -1389,6 +1383,7 @@ class SavedViewSerializer(OwnedObjectSerializer):
|
|||||||
fields = [
|
fields = [
|
||||||
"id",
|
"id",
|
||||||
"name",
|
"name",
|
||||||
|
"icon",
|
||||||
"sort_field",
|
"sort_field",
|
||||||
"sort_reverse",
|
"sort_reverse",
|
||||||
"filter_rules",
|
"filter_rules",
|
||||||
@@ -1975,6 +1970,8 @@ class BulkEditSerializer(
|
|||||||
return ownerUser
|
return ownerUser
|
||||||
|
|
||||||
def _validate_parameters_set_permissions(self, parameters) -> None:
|
def _validate_parameters_set_permissions(self, parameters) -> None:
|
||||||
|
if "set_permissions" not in parameters:
|
||||||
|
raise serializers.ValidationError("set_permissions not specified")
|
||||||
parameters["set_permissions"] = self.validate_set_permissions(
|
parameters["set_permissions"] = self.validate_set_permissions(
|
||||||
parameters["set_permissions"],
|
parameters["set_permissions"],
|
||||||
)
|
)
|
||||||
@@ -2672,13 +2669,8 @@ class TaskSerializerV9(serializers.ModelSerializer[PaperlessTask]):
|
|||||||
user = request.user
|
user = request.user
|
||||||
qs = Document.global_objects.filter(pk=dup_of)
|
qs = Document.global_objects.filter(pk=dup_of)
|
||||||
if not user.is_staff:
|
if not user.is_staff:
|
||||||
with_perms = get_objects_for_user(
|
allowed_ids = permitted_document_ids(user, include_deleted=True)
|
||||||
user,
|
qs = qs.filter(pk__in=allowed_ids)
|
||||||
"documents.view_document",
|
|
||||||
qs,
|
|
||||||
accept_global_perms=False,
|
|
||||||
)
|
|
||||||
qs = with_perms | qs.filter(owner=user) | qs.filter(owner__isnull=True)
|
|
||||||
return list(qs.values("id", "title", "deleted_at"))
|
return list(qs.values("id", "title", "deleted_at"))
|
||||||
|
|
||||||
|
|
||||||
@@ -3222,6 +3214,13 @@ class WorkflowActionSerializer(serializers.ModelSerializer[WorkflowAction]):
|
|||||||
{"assign_title": f'Invalid f-string detected: "{e.args[0]}"'},
|
{"assign_title": f'Invalid f-string detected: "{e.args[0]}"'},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
if attrs.get("assign_custom_fields_values"):
|
||||||
|
# Empty strings treated as None to avoid unexpected behavior
|
||||||
|
attrs["assign_custom_fields_values"] = {
|
||||||
|
field_id: (None if value == "" else value)
|
||||||
|
for field_id, value in attrs["assign_custom_fields_values"].items()
|
||||||
|
}
|
||||||
|
|
||||||
if (
|
if (
|
||||||
"type" in attrs
|
"type" in attrs
|
||||||
and attrs["type"] == WorkflowAction.WorkflowActionType.EMAIL
|
and attrs["type"] == WorkflowAction.WorkflowActionType.EMAIL
|
||||||
@@ -3528,8 +3527,6 @@ class StoragePathTestSerializer(SerializerWithPerms):
|
|||||||
document_field = self.fields.get("document")
|
document_field = self.fields.get("document")
|
||||||
if not isinstance(document_field, serializers.PrimaryKeyRelatedField):
|
if not isinstance(document_field, serializers.PrimaryKeyRelatedField):
|
||||||
return
|
return
|
||||||
document_field.queryset = get_objects_for_user_owner_aware(
|
document_field.queryset = Document.objects.filter(
|
||||||
user,
|
id__in=permitted_document_ids(user),
|
||||||
"documents.view_document",
|
|
||||||
Document,
|
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -70,8 +70,7 @@
|
|||||||
]
|
]
|
||||||
</script>
|
</script>
|
||||||
</pngx-root>
|
</pngx-root>
|
||||||
<script src="{% static runtime_js %}" defer></script>
|
<script src="{% static polyfills_js %}" type="module"></script>
|
||||||
<script src="{% static polyfills_js %}" defer></script>
|
<script src="{% static main_js %}" type="module"></script>
|
||||||
<script src="{% static main_js %}" defer></script>
|
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -0,0 +1,327 @@
|
|||||||
|
import io
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import zipfile
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from pytest_django.fixtures import SettingsWrapper
|
||||||
|
|
||||||
|
from documents.export.sinks import DirectoryExportSink
|
||||||
|
from documents.export.sinks import ExportSink
|
||||||
|
from documents.export.sinks import StreamingManifestWriter
|
||||||
|
from documents.export.sinks import ZipExportSink
|
||||||
|
from documents.export.sinks import _dumps
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture()
|
||||||
|
def source_file(tmp_path: Path) -> Path:
|
||||||
|
src: Path = tmp_path / "src" / "doc.pdf"
|
||||||
|
src.parent.mkdir(parents=True)
|
||||||
|
src.write_bytes(b"PDF-CONTENT")
|
||||||
|
return src
|
||||||
|
|
||||||
|
|
||||||
|
class TestDumps:
|
||||||
|
def test_dumps_is_indented_unicode_json(self) -> None:
|
||||||
|
result: str = _dumps({"a": "é", "b": 1})
|
||||||
|
assert '"é"' in result # ensure_ascii=False keeps unicode literal
|
||||||
|
assert "\n" in result # indent=2 produces newlines
|
||||||
|
assert json.loads(result) == {"a": "é", "b": 1}
|
||||||
|
|
||||||
|
|
||||||
|
class TestStreamingManifestWriter:
|
||||||
|
def test_writes_json_array_of_records(self) -> None:
|
||||||
|
handle: io.StringIO = io.StringIO()
|
||||||
|
writer: StreamingManifestWriter = StreamingManifestWriter(handle)
|
||||||
|
writer.write_batch([{"pk": 1}, {"pk": 2}])
|
||||||
|
writer.write_record({"pk": 3})
|
||||||
|
writer.close()
|
||||||
|
assert json.loads(handle.getvalue()) == [{"pk": 1}, {"pk": 2}, {"pk": 3}]
|
||||||
|
|
||||||
|
def test_empty_manifest_is_valid_empty_array(self) -> None:
|
||||||
|
handle: io.StringIO = io.StringIO()
|
||||||
|
writer: StreamingManifestWriter = StreamingManifestWriter(handle)
|
||||||
|
writer.close()
|
||||||
|
assert json.loads(handle.getvalue()) == []
|
||||||
|
|
||||||
|
|
||||||
|
class TestDirectoryExportSink:
|
||||||
|
def test_add_file_copies_to_relative_arcname(
|
||||||
|
self,
|
||||||
|
tmp_path: Path,
|
||||||
|
source_file: Path,
|
||||||
|
) -> None:
|
||||||
|
target: Path = tmp_path / "out"
|
||||||
|
target.mkdir()
|
||||||
|
with DirectoryExportSink(
|
||||||
|
target,
|
||||||
|
compare_checksums=False,
|
||||||
|
compare_json=False,
|
||||||
|
delete=False,
|
||||||
|
) as sink:
|
||||||
|
sink.add_file(source_file, "originals/doc.pdf")
|
||||||
|
assert (target / "originals" / "doc.pdf").read_bytes() == b"PDF-CONTENT"
|
||||||
|
|
||||||
|
def test_add_json_writes_file(self, tmp_path: Path) -> None:
|
||||||
|
target: Path = tmp_path / "out"
|
||||||
|
target.mkdir()
|
||||||
|
with DirectoryExportSink(
|
||||||
|
target,
|
||||||
|
compare_checksums=False,
|
||||||
|
compare_json=False,
|
||||||
|
delete=False,
|
||||||
|
) as sink:
|
||||||
|
sink.add_json({"version": "x"}, "metadata.json")
|
||||||
|
assert json.loads((target / "metadata.json").read_text()) == {"version": "x"}
|
||||||
|
|
||||||
|
def test_stream_writes_manifest(self, tmp_path: Path) -> None:
|
||||||
|
target: Path = tmp_path / "out"
|
||||||
|
target.mkdir()
|
||||||
|
with DirectoryExportSink(
|
||||||
|
target,
|
||||||
|
compare_checksums=False,
|
||||||
|
compare_json=False,
|
||||||
|
delete=False,
|
||||||
|
) as sink:
|
||||||
|
with sink.stream("manifest.json") as handle:
|
||||||
|
writer: StreamingManifestWriter = StreamingManifestWriter(handle)
|
||||||
|
writer.write_record({"pk": 1})
|
||||||
|
writer.close()
|
||||||
|
assert json.loads((target / "manifest.json").read_text()) == [{"pk": 1}]
|
||||||
|
|
||||||
|
def test_add_file_skips_when_size_and_mtime_match(
|
||||||
|
self,
|
||||||
|
tmp_path: Path,
|
||||||
|
source_file: Path,
|
||||||
|
) -> None:
|
||||||
|
# Pre-existing target with identical size+mtime but DIFFERENT content:
|
||||||
|
# if add_file skips (no compare-checksums), the old content survives.
|
||||||
|
target: Path = tmp_path / "out"
|
||||||
|
target.mkdir()
|
||||||
|
existing: Path = target / "originals" / "doc.pdf"
|
||||||
|
existing.parent.mkdir(parents=True)
|
||||||
|
# Same byte length as the source but different content + matching mtime,
|
||||||
|
# so a size/mtime comparison treats it as unchanged and skips the copy.
|
||||||
|
existing.write_bytes(b"X" * len(b"PDF-CONTENT"))
|
||||||
|
stat = source_file.stat()
|
||||||
|
os.utime(existing, (stat.st_atime, stat.st_mtime))
|
||||||
|
with DirectoryExportSink(
|
||||||
|
target,
|
||||||
|
compare_checksums=False,
|
||||||
|
compare_json=False,
|
||||||
|
delete=False,
|
||||||
|
) as sink:
|
||||||
|
sink.add_file(source_file, "originals/doc.pdf", checksum="abc")
|
||||||
|
assert existing.read_bytes() == b"X" * len(b"PDF-CONTENT") # skipped
|
||||||
|
|
||||||
|
def test_add_file_recopies_when_compare_checksums_differ(
|
||||||
|
self,
|
||||||
|
tmp_path: Path,
|
||||||
|
source_file: Path,
|
||||||
|
) -> None:
|
||||||
|
target: Path = tmp_path / "out"
|
||||||
|
target.mkdir()
|
||||||
|
existing: Path = target / "originals" / "doc.pdf"
|
||||||
|
existing.parent.mkdir(parents=True)
|
||||||
|
existing.write_bytes(b"X" * len(b"PDF-CONTENT"))
|
||||||
|
stat = source_file.stat()
|
||||||
|
os.utime(existing, (stat.st_atime, stat.st_mtime))
|
||||||
|
with DirectoryExportSink(
|
||||||
|
target,
|
||||||
|
compare_checksums=True,
|
||||||
|
compare_json=False,
|
||||||
|
delete=False,
|
||||||
|
) as sink:
|
||||||
|
# wrong checksum forces recopy despite matching size/mtime
|
||||||
|
sink.add_file(source_file, "originals/doc.pdf", checksum="not-the-real-sum")
|
||||||
|
assert existing.read_bytes() == b"PDF-CONTENT" # recopied
|
||||||
|
|
||||||
|
def test_delete_prunes_unwritten_snapshot_files(
|
||||||
|
self,
|
||||||
|
tmp_path: Path,
|
||||||
|
source_file: Path,
|
||||||
|
) -> None:
|
||||||
|
target: Path = tmp_path / "out"
|
||||||
|
target.mkdir()
|
||||||
|
stale: Path = target / "stale.pdf"
|
||||||
|
stale.write_bytes(b"STALE")
|
||||||
|
with DirectoryExportSink(
|
||||||
|
target,
|
||||||
|
compare_checksums=False,
|
||||||
|
compare_json=False,
|
||||||
|
delete=True,
|
||||||
|
) as sink:
|
||||||
|
sink.add_file(source_file, "originals/doc.pdf")
|
||||||
|
assert not stale.exists()
|
||||||
|
assert (target / "originals" / "doc.pdf").exists()
|
||||||
|
|
||||||
|
def test_no_delete_keeps_unwritten_files(
|
||||||
|
self,
|
||||||
|
tmp_path: Path,
|
||||||
|
source_file: Path,
|
||||||
|
) -> None:
|
||||||
|
target: Path = tmp_path / "out"
|
||||||
|
target.mkdir()
|
||||||
|
stale: Path = target / "stale.pdf"
|
||||||
|
stale.write_bytes(b"STALE")
|
||||||
|
with DirectoryExportSink(
|
||||||
|
target,
|
||||||
|
compare_checksums=False,
|
||||||
|
compare_json=False,
|
||||||
|
delete=False,
|
||||||
|
) as sink:
|
||||||
|
sink.add_file(source_file, "originals/doc.pdf")
|
||||||
|
assert stale.exists()
|
||||||
|
|
||||||
|
|
||||||
|
class TestZipExportSink:
|
||||||
|
def test_round_trip_files_json_and_stream(
|
||||||
|
self,
|
||||||
|
tmp_path: Path,
|
||||||
|
source_file: Path,
|
||||||
|
) -> None:
|
||||||
|
target: Path = tmp_path / "out"
|
||||||
|
target.mkdir()
|
||||||
|
with ZipExportSink(target, "export", delete=False) as sink:
|
||||||
|
sink.add_file(source_file, "originals/doc.pdf")
|
||||||
|
sink.add_json({"version": "x"}, "metadata.json")
|
||||||
|
with sink.stream("manifest.json") as handle:
|
||||||
|
writer = StreamingManifestWriter(handle)
|
||||||
|
writer.write_record({"pk": 1})
|
||||||
|
writer.close()
|
||||||
|
zip_path: Path = target / "export.zip"
|
||||||
|
assert zip_path.exists()
|
||||||
|
assert not (target / "export.zip.tmp").exists()
|
||||||
|
with zipfile.ZipFile(zip_path) as zf:
|
||||||
|
names = set(zf.namelist())
|
||||||
|
assert {"originals/doc.pdf", "metadata.json", "manifest.json"} <= names
|
||||||
|
assert zf.read("originals/doc.pdf") == b"PDF-CONTENT"
|
||||||
|
assert json.loads(zf.read("manifest.json")) == [{"pk": 1}]
|
||||||
|
|
||||||
|
def test_nested_arcname_emits_directory_marker(
|
||||||
|
self,
|
||||||
|
tmp_path: Path,
|
||||||
|
source_file: Path,
|
||||||
|
) -> None:
|
||||||
|
target: Path = tmp_path / "out"
|
||||||
|
target.mkdir()
|
||||||
|
with ZipExportSink(target, "export", delete=False) as sink:
|
||||||
|
sink.add_file(source_file, "originals/doc.pdf")
|
||||||
|
with zipfile.ZipFile(target / "export.zip") as zf:
|
||||||
|
assert "originals/" in zf.namelist()
|
||||||
|
|
||||||
|
def test_flat_arcname_has_no_directory_markers(
|
||||||
|
self,
|
||||||
|
tmp_path: Path,
|
||||||
|
source_file: Path,
|
||||||
|
) -> None:
|
||||||
|
target: Path = tmp_path / "out"
|
||||||
|
target.mkdir()
|
||||||
|
with ZipExportSink(target, "export", delete=False) as sink:
|
||||||
|
sink.add_file(source_file, "doc.pdf")
|
||||||
|
with zipfile.ZipFile(target / "export.zip") as zf:
|
||||||
|
assert all(not n.endswith("/") for n in zf.namelist())
|
||||||
|
|
||||||
|
def test_exception_leaves_no_zip_and_no_tmp(
|
||||||
|
self,
|
||||||
|
tmp_path: Path,
|
||||||
|
source_file: Path,
|
||||||
|
) -> None:
|
||||||
|
target: Path = tmp_path / "out"
|
||||||
|
target.mkdir()
|
||||||
|
with pytest.raises(RuntimeError):
|
||||||
|
with ZipExportSink(target, "export", delete=False) as sink:
|
||||||
|
sink.add_file(source_file, "doc.pdf")
|
||||||
|
raise RuntimeError("boom")
|
||||||
|
assert not (target / "export.zip").exists()
|
||||||
|
assert not (target / "export.zip.tmp").exists()
|
||||||
|
|
||||||
|
def test_exception_inside_stream_cleans_up_manifest_tmp(
|
||||||
|
self,
|
||||||
|
tmp_path: Path,
|
||||||
|
source_file: Path,
|
||||||
|
settings: SettingsWrapper,
|
||||||
|
) -> None:
|
||||||
|
scratch_dir = tmp_path / "scratch"
|
||||||
|
settings.SCRATCH_DIR = scratch_dir
|
||||||
|
target: Path = tmp_path / "out"
|
||||||
|
target.mkdir()
|
||||||
|
with pytest.raises(RuntimeError):
|
||||||
|
with ZipExportSink(target, "export", delete=False) as sink:
|
||||||
|
sink.add_file(source_file, "doc.pdf")
|
||||||
|
with sink.stream("manifest.json") as handle:
|
||||||
|
handle.write("[")
|
||||||
|
raise RuntimeError("boom")
|
||||||
|
assert list(scratch_dir.glob("export-manifest-*")) == []
|
||||||
|
assert not (target / "export.zip").exists()
|
||||||
|
assert not (target / "export.zip.tmp").exists()
|
||||||
|
|
||||||
|
def test_abort_after_manifest_written_cleans_up_pending_tmp(
|
||||||
|
self,
|
||||||
|
tmp_path: Path,
|
||||||
|
settings: SettingsWrapper,
|
||||||
|
) -> None:
|
||||||
|
scratch_dir = tmp_path / "scratch"
|
||||||
|
settings.SCRATCH_DIR = scratch_dir
|
||||||
|
target: Path = tmp_path / "out"
|
||||||
|
target.mkdir()
|
||||||
|
with pytest.raises(RuntimeError):
|
||||||
|
with ZipExportSink(target, "export", delete=False) as sink:
|
||||||
|
with sink.stream("manifest.json") as handle:
|
||||||
|
handle.write("[]")
|
||||||
|
raise RuntimeError("boom")
|
||||||
|
assert list(scratch_dir.glob("export-manifest-*")) == []
|
||||||
|
assert not (target / "export.zip").exists()
|
||||||
|
|
||||||
|
def test_delete_wipes_destination_on_success(
|
||||||
|
self,
|
||||||
|
tmp_path: Path,
|
||||||
|
source_file: Path,
|
||||||
|
) -> None:
|
||||||
|
target: Path = tmp_path / "out"
|
||||||
|
target.mkdir()
|
||||||
|
(target / "preexisting.txt").write_text("old")
|
||||||
|
(target / "olddir").mkdir()
|
||||||
|
with ZipExportSink(target, "export", delete=True) as sink:
|
||||||
|
sink.add_file(source_file, "doc.pdf")
|
||||||
|
assert (target / "export.zip").exists()
|
||||||
|
assert not (target / "preexisting.txt").exists()
|
||||||
|
assert not (target / "olddir").exists()
|
||||||
|
|
||||||
|
def test_abort_with_delete_does_not_wipe_destination(
|
||||||
|
self,
|
||||||
|
tmp_path: Path,
|
||||||
|
source_file: Path,
|
||||||
|
) -> None:
|
||||||
|
target: Path = tmp_path / "out"
|
||||||
|
target.mkdir()
|
||||||
|
(target / "preexisting.txt").write_text("old")
|
||||||
|
with pytest.raises(RuntimeError):
|
||||||
|
with ZipExportSink(target, "export", delete=True) as sink:
|
||||||
|
sink.add_file(source_file, "doc.pdf")
|
||||||
|
raise RuntimeError("boom")
|
||||||
|
assert (target / "preexisting.txt").exists()
|
||||||
|
assert not (target / "export.zip").exists()
|
||||||
|
|
||||||
|
|
||||||
|
class TestStreamContract:
|
||||||
|
@pytest.fixture(params=["dir", "zip"])
|
||||||
|
def sink(self, request: pytest.FixtureRequest, tmp_path: Path) -> ExportSink:
|
||||||
|
target: Path = tmp_path / "out"
|
||||||
|
target.mkdir()
|
||||||
|
if request.param == "dir":
|
||||||
|
return DirectoryExportSink(
|
||||||
|
target,
|
||||||
|
compare_checksums=False,
|
||||||
|
compare_json=False,
|
||||||
|
delete=False,
|
||||||
|
)
|
||||||
|
return ZipExportSink(target, "export", delete=False)
|
||||||
|
|
||||||
|
def test_second_concurrent_stream_is_rejected(self, sink: ExportSink) -> None:
|
||||||
|
with sink:
|
||||||
|
with sink.stream("manifest.json"):
|
||||||
|
with pytest.raises(RuntimeError, match="already open"):
|
||||||
|
with sink.stream("other.json"):
|
||||||
|
pass
|
||||||
@@ -163,10 +163,55 @@ class TestSearch:
|
|||||||
assert (
|
assert (
|
||||||
len(backend.search_ids("sswo", user=None, search_mode=SearchMode.TEXT)) == 1
|
len(backend.search_ids("sswo", user=None, search_mode=SearchMode.TEXT)) == 1
|
||||||
)
|
)
|
||||||
assert (
|
for query in ["sswo re", "re sswo"]:
|
||||||
len(backend.search_ids("sswo re", user=None, search_mode=SearchMode.TEXT))
|
assert (
|
||||||
== 1
|
len(backend.search_ids(query, user=None, search_mode=SearchMode.TEXT))
|
||||||
|
== 1
|
||||||
|
), query
|
||||||
|
|
||||||
|
def test_text_mode_matches_all_terms_without_requiring_adjacency(
|
||||||
|
self,
|
||||||
|
backend: TantivyBackend,
|
||||||
|
) -> None:
|
||||||
|
"""Simple text mode should match all terms in any order or field."""
|
||||||
|
doc = Document.objects.create(
|
||||||
|
title="complete-medical-history",
|
||||||
|
content="Samsung Odyssey curved monitor",
|
||||||
|
checksum="TXT13",
|
||||||
|
pk=19,
|
||||||
)
|
)
|
||||||
|
backend.add_or_update(doc)
|
||||||
|
|
||||||
|
for query in [
|
||||||
|
"complete history",
|
||||||
|
"history complete",
|
||||||
|
"Samsung curved",
|
||||||
|
"curved Samsung",
|
||||||
|
]:
|
||||||
|
assert backend.search_ids(
|
||||||
|
query,
|
||||||
|
user=None,
|
||||||
|
search_mode=SearchMode.TEXT,
|
||||||
|
) == [doc.pk], query
|
||||||
|
|
||||||
|
def test_text_mode_matches_terms_across_title_and_content(
|
||||||
|
self,
|
||||||
|
backend: TantivyBackend,
|
||||||
|
) -> None:
|
||||||
|
"""Each simple-search term may match either title or content."""
|
||||||
|
doc = Document.objects.create(
|
||||||
|
title="Complete record",
|
||||||
|
content="Patient history",
|
||||||
|
checksum="TXT14",
|
||||||
|
pk=20,
|
||||||
|
)
|
||||||
|
backend.add_or_update(doc)
|
||||||
|
|
||||||
|
assert backend.search_ids(
|
||||||
|
"complete history",
|
||||||
|
user=None,
|
||||||
|
search_mode=SearchMode.TEXT,
|
||||||
|
) == [doc.pk]
|
||||||
|
|
||||||
def test_text_mode_does_not_match_on_partial_term_overlap(
|
def test_text_mode_does_not_match_on_partial_term_overlap(
|
||||||
self,
|
self,
|
||||||
@@ -186,11 +231,11 @@ class TestSearch:
|
|||||||
== 0
|
== 0
|
||||||
)
|
)
|
||||||
|
|
||||||
def test_text_mode_anchors_later_query_tokens_to_token_starts(
|
def test_text_mode_anchors_numeric_tokens_regardless_of_query_order(
|
||||||
self,
|
self,
|
||||||
backend: TantivyBackend,
|
backend: TantivyBackend,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Multi-token simple search should not match later tokens in the middle of a word."""
|
"""Numeric tokens must not match in the middle of a larger number."""
|
||||||
exact_doc = Document.objects.create(
|
exact_doc = Document.objects.create(
|
||||||
title="Z-Berichte 6",
|
title="Z-Berichte 6",
|
||||||
content="monthly report",
|
content="monthly report",
|
||||||
@@ -213,13 +258,14 @@ class TestSearch:
|
|||||||
backend.add_or_update(prefix_doc)
|
backend.add_or_update(prefix_doc)
|
||||||
backend.add_or_update(false_positive)
|
backend.add_or_update(false_positive)
|
||||||
|
|
||||||
result_ids = set(
|
for query in ["Z-Berichte 6", "6 Z-Berichte"]:
|
||||||
backend.search_ids("Z-Berichte 6", user=None, search_mode=SearchMode.TEXT),
|
result_ids = set(
|
||||||
)
|
backend.search_ids(query, user=None, search_mode=SearchMode.TEXT),
|
||||||
|
)
|
||||||
|
|
||||||
assert exact_doc.id in result_ids
|
assert exact_doc.id in result_ids, query
|
||||||
assert prefix_doc.id in result_ids
|
assert prefix_doc.id in result_ids, query
|
||||||
assert false_positive.id not in result_ids
|
assert false_positive.id not in result_ids, query
|
||||||
|
|
||||||
def test_text_mode_ignores_queries_without_searchable_tokens(
|
def test_text_mode_ignores_queries_without_searchable_tokens(
|
||||||
self,
|
self,
|
||||||
|
|||||||
@@ -341,9 +341,11 @@ class TestTranslateQuery:
|
|||||||
("tag:foo,type:bar", "tag:foo AND document_type:bar"),
|
("tag:foo,type:bar", "tag:foo AND document_type:bar"),
|
||||||
(
|
(
|
||||||
"created:[2020 TO 2021],added:[2022 TO 2023]",
|
"created:[2020 TO 2021],added:[2022 TO 2023]",
|
||||||
"created:[2020-01-01T00:00:00Z TO 2022-01-01T00:00:00Z}"
|
(
|
||||||
" AND "
|
"created:[2020-01-01T00:00:00Z TO 2022-01-01T00:00:00Z}"
|
||||||
"added:[2022-01-01T00:00:00Z TO 2024-01-01T00:00:00Z}",
|
" AND "
|
||||||
|
"added:[2022-01-01T00:00:00Z TO 2024-01-01T00:00:00Z}"
|
||||||
|
),
|
||||||
),
|
),
|
||||||
# correspondent is not multi-value: comma stays literal inside the value
|
# correspondent is not multi-value: comma stays literal inside the value
|
||||||
("correspondent:foo,bar", "correspondent:foo,bar"),
|
("correspondent:foo,bar", "correspondent:foo,bar"),
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ from rest_framework import status
|
|||||||
from rest_framework.test import APITestCase
|
from rest_framework.test import APITestCase
|
||||||
|
|
||||||
from documents.tests.utils import DirectoriesMixin
|
from documents.tests.utils import DirectoriesMixin
|
||||||
|
from documents.tests.utils import read_streaming_response
|
||||||
from paperless.models import ApplicationConfiguration
|
from paperless.models import ApplicationConfiguration
|
||||||
from paperless.models import ColorConvertChoices
|
from paperless.models import ColorConvertChoices
|
||||||
|
|
||||||
@@ -71,6 +72,9 @@ class TestApiAppConfig(DirectoriesMixin, APITestCase):
|
|||||||
"barcode_enable_tag": None,
|
"barcode_enable_tag": None,
|
||||||
"barcode_tag_mapping": None,
|
"barcode_tag_mapping": None,
|
||||||
"barcode_tag_split": None,
|
"barcode_tag_split": None,
|
||||||
|
"remote_ocr_engine": None,
|
||||||
|
"remote_ocr_api_key": None,
|
||||||
|
"remote_ocr_endpoint": None,
|
||||||
"ai_enabled": False,
|
"ai_enabled": False,
|
||||||
"llm_embedding_backend": None,
|
"llm_embedding_backend": None,
|
||||||
"llm_embedding_model": None,
|
"llm_embedding_model": None,
|
||||||
@@ -193,6 +197,7 @@ class TestApiAppConfig(DirectoriesMixin, APITestCase):
|
|||||||
response = self.client.get("/logo/simple.jpg")
|
response = self.client.get("/logo/simple.jpg")
|
||||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||||
self.assertIn("image/jpeg", response["Content-Type"])
|
self.assertIn("image/jpeg", response["Content-Type"])
|
||||||
|
response.close()
|
||||||
|
|
||||||
config = ApplicationConfiguration.objects.first()
|
config = ApplicationConfiguration.objects.first()
|
||||||
assert config is not None
|
assert config is not None
|
||||||
@@ -212,6 +217,46 @@ class TestApiAppConfig(DirectoriesMixin, APITestCase):
|
|||||||
)
|
)
|
||||||
self.assertFalse(Path(old_logo.path).exists())
|
self.assertFalse(Path(old_logo.path).exists())
|
||||||
|
|
||||||
|
@override_settings(APP_LOGO="/logo/simple.jpg")
|
||||||
|
def test_serve_app_logo_from_environment_setting(self) -> None:
|
||||||
|
"""
|
||||||
|
GIVEN:
|
||||||
|
- No uploaded app logo
|
||||||
|
- PAPERLESS_APP_LOGO points to a file in the media logo directory
|
||||||
|
WHEN:
|
||||||
|
- The configured logo URL is requested
|
||||||
|
THEN:
|
||||||
|
- The environment-configured logo is served
|
||||||
|
"""
|
||||||
|
logo = self.dirs.media_dir / "logo" / "simple.jpg"
|
||||||
|
logo.parent.mkdir()
|
||||||
|
expected_content = (
|
||||||
|
Path(__file__).parent / "samples" / "simple.jpg"
|
||||||
|
).read_bytes()
|
||||||
|
logo.write_bytes(expected_content)
|
||||||
|
|
||||||
|
response = self.client.get("/logo/simple.jpg")
|
||||||
|
|
||||||
|
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||||
|
self.assertIn("image/jpeg", response["Content-Type"])
|
||||||
|
self.assertEqual(read_streaming_response(response), expected_content)
|
||||||
|
|
||||||
|
@override_settings(APP_LOGO="/logo/../outside-logo.jpg")
|
||||||
|
def test_environment_app_logo_must_be_inside_logo_directory(self) -> None:
|
||||||
|
"""
|
||||||
|
GIVEN:
|
||||||
|
- PAPERLESS_APP_LOGO resolves outside the media logo directory
|
||||||
|
WHEN:
|
||||||
|
- The configured logo URL is requested
|
||||||
|
THEN:
|
||||||
|
- The file is not served
|
||||||
|
"""
|
||||||
|
(self.dirs.media_dir / "outside-logo.jpg").write_bytes(b"not a logo")
|
||||||
|
|
||||||
|
response = self.client.get("/logo/outside-logo.jpg")
|
||||||
|
|
||||||
|
self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND)
|
||||||
|
|
||||||
def test_api_strips_exif_data_from_uploaded_logo(self) -> None:
|
def test_api_strips_exif_data_from_uploaded_logo(self) -> None:
|
||||||
"""
|
"""
|
||||||
GIVEN:
|
GIVEN:
|
||||||
@@ -828,6 +873,49 @@ class TestApiAppConfig(DirectoriesMixin, APITestCase):
|
|||||||
config.refresh_from_db()
|
config.refresh_from_db()
|
||||||
self.assertEqual(config.llm_api_key, None)
|
self.assertEqual(config.llm_api_key, None)
|
||||||
|
|
||||||
|
def test_update_remote_ocr_api_key(self) -> None:
|
||||||
|
"""
|
||||||
|
GIVEN:
|
||||||
|
- Existing config with remote_ocr_api_key specified
|
||||||
|
WHEN:
|
||||||
|
- API to update remote_ocr_api_key is called with all *s
|
||||||
|
- API to update remote_ocr_api_key is called with empty string
|
||||||
|
THEN:
|
||||||
|
- remote_ocr_api_key is unchanged
|
||||||
|
- remote_ocr_api_key is set to None
|
||||||
|
"""
|
||||||
|
config = ApplicationConfiguration.objects.first()
|
||||||
|
assert config is not None
|
||||||
|
config.remote_ocr_api_key = "1234567890"
|
||||||
|
config.save()
|
||||||
|
|
||||||
|
# Test with all *
|
||||||
|
response = self.client.patch(
|
||||||
|
f"{self.ENDPOINT}1/",
|
||||||
|
json.dumps(
|
||||||
|
{
|
||||||
|
"remote_ocr_api_key": "*" * 32,
|
||||||
|
},
|
||||||
|
),
|
||||||
|
content_type="application/json",
|
||||||
|
)
|
||||||
|
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||||
|
config.refresh_from_db()
|
||||||
|
self.assertEqual(config.remote_ocr_api_key, "1234567890")
|
||||||
|
# Test with empty string
|
||||||
|
response = self.client.patch(
|
||||||
|
f"{self.ENDPOINT}1/",
|
||||||
|
json.dumps(
|
||||||
|
{
|
||||||
|
"remote_ocr_api_key": "",
|
||||||
|
},
|
||||||
|
),
|
||||||
|
content_type="application/json",
|
||||||
|
)
|
||||||
|
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||||
|
config.refresh_from_db()
|
||||||
|
self.assertEqual(config.remote_ocr_api_key, None)
|
||||||
|
|
||||||
def test_enable_ai_index_triggers_update(self) -> None:
|
def test_enable_ai_index_triggers_update(self) -> None:
|
||||||
"""
|
"""
|
||||||
GIVEN:
|
GIVEN:
|
||||||
|
|||||||
@@ -1068,6 +1068,30 @@ class TestBulkEditAPI(DirectoriesMixin, APITestCase):
|
|||||||
self.assertCountEqual(args[0], [self.doc2.id, self.doc3.id])
|
self.assertCountEqual(args[0], [self.doc2.id, self.doc3.id])
|
||||||
self.assertEqual(len(kwargs["set_permissions"]["view"]["users"]), 2)
|
self.assertEqual(len(kwargs["set_permissions"]["view"]["users"]), 2)
|
||||||
|
|
||||||
|
@mock.patch("documents.serialisers.bulk_edit.set_permissions")
|
||||||
|
def test_set_permissions_requires_set_permissions_parameter(self, m) -> None:
|
||||||
|
self.setup_mock(m, "set_permissions")
|
||||||
|
|
||||||
|
response = self.client.post(
|
||||||
|
"/api/documents/bulk_edit/",
|
||||||
|
json.dumps(
|
||||||
|
{
|
||||||
|
"documents": [self.doc2.id],
|
||||||
|
"method": "set_permissions",
|
||||||
|
"parameters": {
|
||||||
|
"owner": self.user.id,
|
||||||
|
"merge": True,
|
||||||
|
"permissions": {"view": {"users": [self.user.id]}},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
),
|
||||||
|
content_type="application/json",
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
|
||||||
|
self.assertIn(b"set_permissions not specified", response.content)
|
||||||
|
m.assert_not_called()
|
||||||
|
|
||||||
@mock.patch("documents.serialisers.bulk_edit.set_permissions")
|
@mock.patch("documents.serialisers.bulk_edit.set_permissions")
|
||||||
def test_set_permissions_merge(self, m) -> None:
|
def test_set_permissions_merge(self, m) -> None:
|
||||||
self.setup_mock(m, "set_permissions")
|
self.setup_mock(m, "set_permissions")
|
||||||
|
|||||||
@@ -472,6 +472,31 @@ class TestDocumentApi(DirectoriesMixin, ConsumeTaskMixin, APITestCase):
|
|||||||
self.assertIn("my_document.pdf", response["Content-Disposition"])
|
self.assertIn("my_document.pdf", response["Content-Disposition"])
|
||||||
response.close()
|
response.close()
|
||||||
|
|
||||||
|
@override_settings(FILENAME_FORMAT="")
|
||||||
|
def test_download_filename_normalization_does_not_inject_parameters(
|
||||||
|
self,
|
||||||
|
) -> None:
|
||||||
|
doc = Document.objects.create(
|
||||||
|
title="file.doc\uff02; x=\uff02\uff3c",
|
||||||
|
created=date(2020, 1, 2),
|
||||||
|
filename="source.pdf",
|
||||||
|
mime_type="application/pdf",
|
||||||
|
)
|
||||||
|
Path(doc.source_path).write_bytes(b"This is a test")
|
||||||
|
|
||||||
|
response = self.client.get(
|
||||||
|
f"/api/documents/{doc.pk}/download/?original=true",
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||||
|
self.assertEqual(
|
||||||
|
response["Content-Disposition"],
|
||||||
|
"attachment; "
|
||||||
|
'filename="2020-01-02 file.doc_; x=__.pdf"; '
|
||||||
|
"filename*=utf-8''2020-01-02%20file.doc%EF%BC%82%3B%20x%3D%EF%BC%82%EF%BC%BC.pdf",
|
||||||
|
)
|
||||||
|
response.close()
|
||||||
|
|
||||||
def test_document_actions_not_existing_file(self) -> None:
|
def test_document_actions_not_existing_file(self) -> None:
|
||||||
doc = Document.objects.create(
|
doc = Document.objects.create(
|
||||||
title="none",
|
title="none",
|
||||||
@@ -2880,18 +2905,20 @@ class TestDocumentApi(DirectoriesMixin, ConsumeTaskMixin, APITestCase):
|
|||||||
|
|
||||||
v1 = SavedView.objects.get(name="test")
|
v1 = SavedView.objects.get(name="test")
|
||||||
self.assertEqual(v1.sort_field, "created2")
|
self.assertEqual(v1.sort_field, "created2")
|
||||||
|
self.assertEqual(v1.icon, SavedView.Icon.FUNNEL)
|
||||||
self.assertEqual(v1.filter_rules.count(), 1)
|
self.assertEqual(v1.filter_rules.count(), 1)
|
||||||
self.assertEqual(v1.owner, self.user)
|
self.assertEqual(v1.owner, self.user)
|
||||||
|
|
||||||
response = self.client.patch(
|
response = self.client.patch(
|
||||||
f"/api/saved_views/{v1.id}/",
|
f"/api/saved_views/{v1.id}/",
|
||||||
{"sort_reverse": True},
|
{"sort_reverse": True, "icon": SavedView.Icon.RECEIPT},
|
||||||
format="json",
|
format="json",
|
||||||
)
|
)
|
||||||
|
|
||||||
v1 = SavedView.objects.get(id=v1.id)
|
v1 = SavedView.objects.get(id=v1.id)
|
||||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||||
self.assertTrue(v1.sort_reverse)
|
self.assertTrue(v1.sort_reverse)
|
||||||
|
self.assertEqual(v1.icon, SavedView.Icon.RECEIPT)
|
||||||
self.assertEqual(v1.filter_rules.count(), 1)
|
self.assertEqual(v1.filter_rules.count(), 1)
|
||||||
|
|
||||||
view["filter_rules"] = [{"rule_type": 12, "value": "secret"}]
|
view["filter_rules"] = [{"rule_type": 12, "value": "secret"}]
|
||||||
@@ -2911,6 +2938,13 @@ class TestDocumentApi(DirectoriesMixin, ConsumeTaskMixin, APITestCase):
|
|||||||
v1 = SavedView.objects.get(id=v1.id)
|
v1 = SavedView.objects.get(id=v1.id)
|
||||||
self.assertEqual(v1.filter_rules.count(), 0)
|
self.assertEqual(v1.filter_rules.count(), 0)
|
||||||
|
|
||||||
|
response = self.client.patch(
|
||||||
|
f"/api/saved_views/{v1.id}/",
|
||||||
|
{"icon": "not-an-icon"},
|
||||||
|
format="json",
|
||||||
|
)
|
||||||
|
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
|
||||||
|
|
||||||
def test_saved_view_display_options(self) -> None:
|
def test_saved_view_display_options(self) -> None:
|
||||||
"""
|
"""
|
||||||
GIVEN:
|
GIVEN:
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ from django.contrib.auth.models import User
|
|||||||
from rest_framework.test import APITestCase
|
from rest_framework.test import APITestCase
|
||||||
|
|
||||||
from documents.models import CustomField
|
from documents.models import CustomField
|
||||||
|
from documents.models import CustomFieldInstance
|
||||||
from documents.models import Document
|
from documents.models import Document
|
||||||
from documents.models import SavedView
|
from documents.models import SavedView
|
||||||
from documents.models import SavedViewFilterRule
|
from documents.models import SavedViewFilterRule
|
||||||
@@ -606,6 +607,56 @@ class TestCustomFieldsSearch(DirectoriesMixin, APITestCase):
|
|||||||
match_nothing_ok=True,
|
match_nothing_ok=True,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def test_document_link_contains_unset_reverse_link(self) -> None:
|
||||||
|
# Another edge case: the document in the value list has the same
|
||||||
|
# document link field attached, but it was never given a value
|
||||||
|
# (value_document_ids is None). This must be treated the same as
|
||||||
|
# having no reverse link at all, not raise a TypeError.
|
||||||
|
unset_document = self.documents[6]
|
||||||
|
CustomFieldInstance.objects.create(
|
||||||
|
document=unset_document,
|
||||||
|
field=self.custom_fields["documentlink_field"],
|
||||||
|
value_document_ids=None,
|
||||||
|
)
|
||||||
|
self._assert_query_match_predicate(
|
||||||
|
["documentlink_field", "contains", [unset_document.id]],
|
||||||
|
lambda document: (
|
||||||
|
"documentlink_field" in document
|
||||||
|
and document["documentlink_field"] is not None
|
||||||
|
and set(document["documentlink_field"]) >= {unset_document.id}
|
||||||
|
),
|
||||||
|
match_nothing_ok=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_document_link_contains_ignores_unrelated_document_link_field(
|
||||||
|
self,
|
||||||
|
) -> None:
|
||||||
|
# A document referenced in the value list may have a *different*
|
||||||
|
# Document Link custom field attached with no value set. This must
|
||||||
|
# not be pulled into the reverse-link lookup for the field being
|
||||||
|
# queried, and must not crash.
|
||||||
|
unrelated_field = CustomField.objects.create(
|
||||||
|
name="unrelated_documentlink_field",
|
||||||
|
data_type=CustomField.FieldDataType.DOCUMENTLINK,
|
||||||
|
)
|
||||||
|
# self.documents[0] is reciprocally linked from self.documents[35]
|
||||||
|
# (documentlink_field=[documents[0].id, documents[1].id, documents[2].id]).
|
||||||
|
target_document = self.documents[0]
|
||||||
|
CustomFieldInstance.objects.create(
|
||||||
|
document=target_document,
|
||||||
|
field=unrelated_field,
|
||||||
|
value_document_ids=None,
|
||||||
|
)
|
||||||
|
|
||||||
|
self._assert_query_match_predicate(
|
||||||
|
["documentlink_field", "contains", [target_document.id]],
|
||||||
|
lambda document: (
|
||||||
|
"documentlink_field" in document
|
||||||
|
and document["documentlink_field"] is not None
|
||||||
|
and set(document["documentlink_field"]) >= {target_document.id}
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
# ==========================================================#
|
# ==========================================================#
|
||||||
# Logical expressions #
|
# Logical expressions #
|
||||||
# ==========================================================#
|
# ==========================================================#
|
||||||
|
|||||||
@@ -1057,33 +1057,52 @@ class TestDocumentSearchApi(DirectoriesMixin, APITestCase):
|
|||||||
THEN:
|
THEN:
|
||||||
- The similar documents are returned from the API request
|
- The similar documents are returned from the API request
|
||||||
"""
|
"""
|
||||||
# Distinct created/added dates: documents created at the same instant
|
# Distinct created/added/modified dates: documents sharing a timestamp
|
||||||
# share a timestamp term, and more_like_this (which cannot be scoped to
|
# term (down to the second) would be matched on it by more_like_this
|
||||||
# content fields) would then match on it, surfacing unrelated documents.
|
# (which cannot be scoped to content fields), surfacing unrelated
|
||||||
d1 = DocumentFactory(
|
# documents. `modified` is auto_now, so it can't be set via factory
|
||||||
title="invoice",
|
# kwargs like created/added - freeze time per document instead so all
|
||||||
content="the thing i bought at a shop and paid with bank account",
|
# three date fields land on distinct seconds.
|
||||||
created=datetime.date(2018, 1, 1),
|
with time_machine.travel(
|
||||||
added=timezone.make_aware(datetime.datetime(2018, 1, 1)),
|
timezone.make_aware(datetime.datetime(2018, 1, 1)),
|
||||||
)
|
tick=False,
|
||||||
d2 = DocumentFactory(
|
):
|
||||||
title="bank statement 1",
|
d1 = DocumentFactory(
|
||||||
content="things i paid for in august",
|
title="invoice",
|
||||||
created=datetime.date(2019, 3, 4),
|
content="the thing i bought at a shop and paid with bank account",
|
||||||
added=timezone.make_aware(datetime.datetime(2019, 3, 4)),
|
created=datetime.date(2018, 1, 1),
|
||||||
)
|
added=timezone.make_aware(datetime.datetime(2018, 1, 1)),
|
||||||
d3 = DocumentFactory(
|
)
|
||||||
title="bank statement 3",
|
with time_machine.travel(
|
||||||
content="things i paid for in september",
|
timezone.make_aware(datetime.datetime(2019, 3, 4)),
|
||||||
created=datetime.date(2020, 7, 9),
|
tick=False,
|
||||||
added=timezone.make_aware(datetime.datetime(2020, 7, 9)),
|
):
|
||||||
)
|
d2 = DocumentFactory(
|
||||||
d4 = DocumentFactory(
|
title="bank statement 1",
|
||||||
title="Quarterly Report",
|
content="things i paid for in august",
|
||||||
content="quarterly revenue profit margin earnings growth",
|
created=datetime.date(2019, 3, 4),
|
||||||
created=datetime.date(2021, 11, 30),
|
added=timezone.make_aware(datetime.datetime(2019, 3, 4)),
|
||||||
added=timezone.make_aware(datetime.datetime(2021, 11, 30)),
|
)
|
||||||
)
|
with time_machine.travel(
|
||||||
|
timezone.make_aware(datetime.datetime(2020, 7, 9)),
|
||||||
|
tick=False,
|
||||||
|
):
|
||||||
|
d3 = DocumentFactory(
|
||||||
|
title="bank statement 3",
|
||||||
|
content="things i paid for in september",
|
||||||
|
created=datetime.date(2020, 7, 9),
|
||||||
|
added=timezone.make_aware(datetime.datetime(2020, 7, 9)),
|
||||||
|
)
|
||||||
|
with time_machine.travel(
|
||||||
|
timezone.make_aware(datetime.datetime(2021, 11, 30)),
|
||||||
|
tick=False,
|
||||||
|
):
|
||||||
|
d4 = DocumentFactory(
|
||||||
|
title="Quarterly Report",
|
||||||
|
content="quarterly revenue profit margin earnings growth",
|
||||||
|
created=datetime.date(2021, 11, 30),
|
||||||
|
added=timezone.make_aware(datetime.datetime(2021, 11, 30)),
|
||||||
|
)
|
||||||
backend = get_backend()
|
backend = get_backend()
|
||||||
backend.add_or_update(d1)
|
backend.add_or_update(d1)
|
||||||
backend.add_or_update(d2)
|
backend.add_or_update(d2)
|
||||||
|
|||||||
@@ -422,6 +422,11 @@ class TestApiWorkflows(DirectoriesMixin, APITestCase):
|
|||||||
json.dumps(
|
json.dumps(
|
||||||
{
|
{
|
||||||
"assign_title": "",
|
"assign_title": "",
|
||||||
|
"assign_custom_fields": [self.cf1.id, self.cf2.id],
|
||||||
|
"assign_custom_fields_values": {
|
||||||
|
str(self.cf1.id): "",
|
||||||
|
str(self.cf2.id): 0,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
content_type="application/json",
|
content_type="application/json",
|
||||||
@@ -429,6 +434,10 @@ class TestApiWorkflows(DirectoriesMixin, APITestCase):
|
|||||||
self.assertEqual(response.status_code, status.HTTP_201_CREATED)
|
self.assertEqual(response.status_code, status.HTTP_201_CREATED)
|
||||||
action = WorkflowAction.objects.get(id=response.data["id"])
|
action = WorkflowAction.objects.get(id=response.data["id"])
|
||||||
self.assertIsNone(action.assign_title)
|
self.assertIsNone(action.assign_title)
|
||||||
|
self.assertEqual(
|
||||||
|
action.assign_custom_fields_values,
|
||||||
|
{str(self.cf1.id): None, str(self.cf2.id): 0},
|
||||||
|
)
|
||||||
|
|
||||||
response = self.client.post(
|
response = self.client.post(
|
||||||
self.ENDPOINT_TRIGGERS,
|
self.ENDPOINT_TRIGGERS,
|
||||||
|
|||||||
@@ -160,7 +160,7 @@ class TestDateLocalization:
|
|||||||
)
|
)
|
||||||
def test_localize_date_raises_type_error_for_invalid_input(
|
def test_localize_date_raises_type_error_for_invalid_input(
|
||||||
self,
|
self,
|
||||||
invalid_value: None | list[object] | dict[Any, Any] | Literal[1698330605],
|
invalid_value: list[object] | dict[Any, Any] | Literal[1698330605] | None,
|
||||||
) -> None:
|
) -> None:
|
||||||
with pytest.raises(TypeError) as excinfo:
|
with pytest.raises(TypeError) as excinfo:
|
||||||
localize_date(invalid_value, "medium", "en_US")
|
localize_date(invalid_value, "medium", "en_US")
|
||||||
|
|||||||
@@ -880,6 +880,41 @@ class TestCommandWatch:
|
|||||||
]
|
]
|
||||||
assert call_args.original_file.name == "valid.pdf"
|
assert call_args.original_file.name == "valid.pdf"
|
||||||
|
|
||||||
|
def test_ignores_event_for_already_queued_file(
|
||||||
|
self,
|
||||||
|
consumption_dir: Path,
|
||||||
|
sample_pdf: Path,
|
||||||
|
mock_consume_file_delay: MagicMock,
|
||||||
|
start_consumer: Callable[..., ConsumerThread],
|
||||||
|
) -> None:
|
||||||
|
"""
|
||||||
|
A stray filesystem event (NAS metadata touch, AV scan, etc.) on a
|
||||||
|
file that has already been queued and is awaiting consumption must
|
||||||
|
not cause it to be queued a second time (GH #13511). The task is
|
||||||
|
mocked, so the file is never removed from disk, mirroring a
|
||||||
|
long-running OCR job still holding it.
|
||||||
|
"""
|
||||||
|
thread = start_consumer()
|
||||||
|
|
||||||
|
target = consumption_dir / "document.pdf"
|
||||||
|
shutil.copy(sample_pdf, target)
|
||||||
|
|
||||||
|
wait_for_mock_call(mock_consume_file_delay.apply_async, timeout_s=2.0)
|
||||||
|
|
||||||
|
if thread.exception:
|
||||||
|
raise thread.exception
|
||||||
|
|
||||||
|
assert mock_consume_file_delay.apply_async.call_count == 1
|
||||||
|
|
||||||
|
# Simulate a stray event on the still-present, already-queued file.
|
||||||
|
target.touch()
|
||||||
|
sleep(0.5)
|
||||||
|
|
||||||
|
if thread.exception:
|
||||||
|
raise thread.exception
|
||||||
|
|
||||||
|
assert mock_consume_file_delay.apply_async.call_count == 1
|
||||||
|
|
||||||
@pytest.mark.django_db
|
@pytest.mark.django_db
|
||||||
@pytest.mark.usefixtures("mock_supported_extensions")
|
@pytest.mark.usefixtures("mock_supported_extensions")
|
||||||
def test_stop_flag_stops_consumer(
|
def test_stop_flag_stops_consumer(
|
||||||
|
|||||||
@@ -426,7 +426,7 @@ class TestExportImport(
|
|||||||
st_mtime_1 = (self.target / "manifest.json").stat().st_mtime
|
st_mtime_1 = (self.target / "manifest.json").stat().st_mtime
|
||||||
|
|
||||||
with mock.patch(
|
with mock.patch(
|
||||||
"documents.management.commands.document_exporter.copy_file_with_basic_stats",
|
"documents.export.sinks.copy_file_with_basic_stats",
|
||||||
) as m:
|
) as m:
|
||||||
self._do_export()
|
self._do_export()
|
||||||
m.assert_not_called()
|
m.assert_not_called()
|
||||||
@@ -437,7 +437,7 @@ class TestExportImport(
|
|||||||
Path(self.d1.source_path).touch()
|
Path(self.d1.source_path).touch()
|
||||||
|
|
||||||
with mock.patch(
|
with mock.patch(
|
||||||
"documents.management.commands.document_exporter.copy_file_with_basic_stats",
|
"documents.export.sinks.copy_file_with_basic_stats",
|
||||||
) as m:
|
) as m:
|
||||||
self._do_export()
|
self._do_export()
|
||||||
self.assertEqual(m.call_count, 1)
|
self.assertEqual(m.call_count, 1)
|
||||||
@@ -464,7 +464,7 @@ class TestExportImport(
|
|||||||
self.assertIsFile(self.target / "manifest.json")
|
self.assertIsFile(self.target / "manifest.json")
|
||||||
|
|
||||||
with mock.patch(
|
with mock.patch(
|
||||||
"documents.management.commands.document_exporter.copy_file_with_basic_stats",
|
"documents.export.sinks.copy_file_with_basic_stats",
|
||||||
) as m:
|
) as m:
|
||||||
self._do_export()
|
self._do_export()
|
||||||
m.assert_not_called()
|
m.assert_not_called()
|
||||||
@@ -475,7 +475,7 @@ class TestExportImport(
|
|||||||
self.d2.save()
|
self.d2.save()
|
||||||
|
|
||||||
with mock.patch(
|
with mock.patch(
|
||||||
"documents.management.commands.document_exporter.copy_file_with_basic_stats",
|
"documents.export.sinks.copy_file_with_basic_stats",
|
||||||
) as m:
|
) as m:
|
||||||
self._do_export(compare_checksums=True)
|
self._do_export(compare_checksums=True)
|
||||||
self.assertEqual(m.call_count, 1)
|
self.assertEqual(m.call_count, 1)
|
||||||
@@ -1058,6 +1058,26 @@ class TestExportImport(
|
|||||||
|
|
||||||
self.assertEqual(Document.objects.all().count(), 4)
|
self.assertEqual(Document.objects.all().count(), 4)
|
||||||
|
|
||||||
|
def test_zip_with_compare_flags_raises(self) -> None:
|
||||||
|
"""
|
||||||
|
GIVEN:
|
||||||
|
- A request to export to a zip file
|
||||||
|
WHEN:
|
||||||
|
- --compare-checksums or --compare-json is also passed
|
||||||
|
THEN:
|
||||||
|
- A CommandError is raised (the flags are no-ops in zip mode)
|
||||||
|
"""
|
||||||
|
for flag in ("--compare-checksums", "--compare-json"):
|
||||||
|
with self.subTest(flag=flag):
|
||||||
|
with self.assertRaises(CommandError):
|
||||||
|
call_command(
|
||||||
|
"document_exporter",
|
||||||
|
self.target,
|
||||||
|
"--zip",
|
||||||
|
flag,
|
||||||
|
skip_checks=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.management
|
@pytest.mark.management
|
||||||
class TestCryptExportImport(
|
class TestCryptExportImport(
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import os
|
||||||
from io import StringIO
|
from io import StringIO
|
||||||
from unittest.mock import patch
|
from unittest.mock import patch
|
||||||
|
|
||||||
@@ -41,7 +42,7 @@ class TestFuzzyMatchCommand(TestCase):
|
|||||||
|
|
||||||
def test_invalid_ratio_upper_limit(self) -> None:
|
def test_invalid_ratio_upper_limit(self) -> None:
|
||||||
"""
|
"""
|
||||||
GIVEN:s
|
GIVEN:
|
||||||
- Invalid ratio above upper
|
- Invalid ratio above upper
|
||||||
WHEN:
|
WHEN:
|
||||||
- Command is called
|
- Command is called
|
||||||
@@ -108,6 +109,45 @@ class TestFuzzyMatchCommand(TestCase):
|
|||||||
stdout, _ = self.call_command("--processes", "1")
|
stdout, _ = self.call_command("--processes", "1")
|
||||||
self.assertIn("Found 1 matching pair(s)", stdout)
|
self.assertIn("Found 1 matching pair(s)", stdout)
|
||||||
|
|
||||||
|
def test_with_matches_and_url(self) -> None:
|
||||||
|
"""
|
||||||
|
GIVEN:
|
||||||
|
- 2 documents exist
|
||||||
|
- Similarity between content is 86.667
|
||||||
|
- --url is provided
|
||||||
|
WHEN:
|
||||||
|
- Command is called with --url
|
||||||
|
THEN:
|
||||||
|
- 1 match is returned from doc 1 to doc 2
|
||||||
|
- No match from doc 2 to doc 1 reported
|
||||||
|
- Output contains clickable links to the documents instead of titles
|
||||||
|
"""
|
||||||
|
# Content similarity is 86.667
|
||||||
|
Document.objects.create(
|
||||||
|
checksum="BEEFCAFE",
|
||||||
|
title="A",
|
||||||
|
content="first document scanned by bob",
|
||||||
|
mime_type="application/pdf",
|
||||||
|
filename="test.pdf",
|
||||||
|
)
|
||||||
|
Document.objects.create(
|
||||||
|
checksum="DEADBEAF",
|
||||||
|
title="A",
|
||||||
|
content="first document scanned by alice",
|
||||||
|
mime_type="application/pdf",
|
||||||
|
filename="other_test.pdf",
|
||||||
|
)
|
||||||
|
with patch.dict(os.environ, {"COLUMNS": "200"}):
|
||||||
|
stdout, _ = self.call_command(
|
||||||
|
"--processes",
|
||||||
|
"1",
|
||||||
|
"--url",
|
||||||
|
"http://localhost:8000",
|
||||||
|
)
|
||||||
|
self.assertIn("Found 1 matching pair(s)", stdout)
|
||||||
|
self.assertIn("http://localhost:8000/documents/1/details", stdout)
|
||||||
|
self.assertIn("http://localhost:8000/documents/2/details", stdout)
|
||||||
|
|
||||||
def test_with_3_matches(self) -> None:
|
def test_with_3_matches(self) -> None:
|
||||||
"""
|
"""
|
||||||
GIVEN:
|
GIVEN:
|
||||||
|
|||||||
@@ -0,0 +1,785 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from http import HTTPStatus
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from django.contrib.auth.models import AnonymousUser
|
||||||
|
from django.contrib.auth.models import Group
|
||||||
|
from django.contrib.auth.models import Permission
|
||||||
|
from django.contrib.auth.models import User
|
||||||
|
from django.test import override_settings
|
||||||
|
from guardian.shortcuts import assign_perm
|
||||||
|
from rest_framework.test import APIClient
|
||||||
|
|
||||||
|
from documents.matching import match_correspondents
|
||||||
|
from documents.matching import match_document_types
|
||||||
|
from documents.matching import match_storage_paths
|
||||||
|
from documents.matching import match_tags
|
||||||
|
from documents.models import Correspondent
|
||||||
|
from documents.models import DocumentType
|
||||||
|
from documents.models import StoragePath
|
||||||
|
from documents.models import Tag
|
||||||
|
from documents.permissions import permitted_document_ids
|
||||||
|
from documents.permissions import permitted_object_ids
|
||||||
|
from documents.serialisers import _get_viewable_duplicates
|
||||||
|
from documents.tests.factories import CorrespondentFactory
|
||||||
|
from documents.tests.factories import DocumentFactory
|
||||||
|
from documents.tests.factories import DocumentTypeFactory
|
||||||
|
from documents.tests.factories import StoragePathFactory
|
||||||
|
from documents.tests.factories import TagFactory
|
||||||
|
|
||||||
|
|
||||||
|
def assert_visible_document_ids(actual_ids, *, expected_visible, expected_hidden):
|
||||||
|
actual_ids = set(actual_ids)
|
||||||
|
expected_visible = set(expected_visible)
|
||||||
|
assert actual_ids == expected_visible, (
|
||||||
|
f"visible set mismatch: missing={expected_visible - actual_ids}, "
|
||||||
|
f"unexpected={actual_ids - expected_visible}"
|
||||||
|
)
|
||||||
|
for doc_id in expected_hidden:
|
||||||
|
assert doc_id not in actual_ids, (
|
||||||
|
f"document {doc_id} leaked but should be hidden"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.django_db
|
||||||
|
class TestPermittedDocumentIdsSecurity:
|
||||||
|
def test_owner_sees_own_document(self):
|
||||||
|
user = User.objects.create_user(username="alice")
|
||||||
|
stranger = User.objects.create_user(username="mallory")
|
||||||
|
owned = DocumentFactory(owner=user)
|
||||||
|
strangers_doc = DocumentFactory(owner=stranger)
|
||||||
|
|
||||||
|
visible = permitted_document_ids(user)
|
||||||
|
|
||||||
|
assert_visible_document_ids(
|
||||||
|
visible,
|
||||||
|
expected_visible=[owned.pk],
|
||||||
|
expected_hidden=[strangers_doc.pk],
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_unowned_document_visible_to_everyone(self):
|
||||||
|
user = User.objects.create_user(username="alice")
|
||||||
|
unowned = DocumentFactory(owner=None)
|
||||||
|
|
||||||
|
assert_visible_document_ids(
|
||||||
|
permitted_document_ids(user),
|
||||||
|
expected_visible=[unowned.pk],
|
||||||
|
expected_hidden=[],
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_explicit_user_permission_grants_visibility(self):
|
||||||
|
grantee = User.objects.create_user(username="alice")
|
||||||
|
stranger = User.objects.create_user(username="mallory")
|
||||||
|
owner = User.objects.create_user(username="owner")
|
||||||
|
shared = DocumentFactory(owner=owner)
|
||||||
|
not_shared = DocumentFactory(owner=owner)
|
||||||
|
assign_perm("view_document", grantee, shared)
|
||||||
|
|
||||||
|
assert_visible_document_ids(
|
||||||
|
permitted_document_ids(grantee),
|
||||||
|
expected_visible=[shared.pk],
|
||||||
|
expected_hidden=[not_shared.pk],
|
||||||
|
)
|
||||||
|
assert_visible_document_ids(
|
||||||
|
permitted_document_ids(stranger),
|
||||||
|
expected_visible=[],
|
||||||
|
expected_hidden=[shared.pk, not_shared.pk],
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_explicit_group_permission_grants_visibility_to_members_only(self):
|
||||||
|
owner = User.objects.create_user(username="owner")
|
||||||
|
member = User.objects.create_user(username="member")
|
||||||
|
non_member = User.objects.create_user(username="non_member")
|
||||||
|
group = Group.objects.create(name="finance")
|
||||||
|
member.groups.add(group)
|
||||||
|
shared = DocumentFactory(owner=owner)
|
||||||
|
assign_perm("view_document", group, shared)
|
||||||
|
|
||||||
|
assert_visible_document_ids(
|
||||||
|
permitted_document_ids(member),
|
||||||
|
expected_visible=[shared.pk],
|
||||||
|
expected_hidden=[],
|
||||||
|
)
|
||||||
|
assert_visible_document_ids(
|
||||||
|
permitted_document_ids(non_member),
|
||||||
|
expected_visible=[],
|
||||||
|
expected_hidden=[shared.pk],
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_soft_deleted_document_excluded_by_default(self):
|
||||||
|
owner = User.objects.create_user(username="owner")
|
||||||
|
doc = DocumentFactory(owner=owner)
|
||||||
|
doc.delete() # soft delete
|
||||||
|
doc.refresh_from_db()
|
||||||
|
assert doc.deleted_at is not None, (
|
||||||
|
"document should be soft-deleted, not hard-deleted, for this "
|
||||||
|
"test to actually validate the deleted_at filtering behavior"
|
||||||
|
)
|
||||||
|
|
||||||
|
assert_visible_document_ids(
|
||||||
|
permitted_document_ids(owner),
|
||||||
|
expected_visible=[],
|
||||||
|
expected_hidden=[doc.pk],
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_superuser_sees_everything_including_no_perm_documents(self):
|
||||||
|
superuser = User.objects.create_superuser(username="root")
|
||||||
|
owner = User.objects.create_user(username="owner")
|
||||||
|
doc = DocumentFactory(owner=owner)
|
||||||
|
|
||||||
|
assert_visible_document_ids(
|
||||||
|
permitted_document_ids(superuser),
|
||||||
|
expected_visible=[doc.pk],
|
||||||
|
expected_hidden=[],
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_anonymous_user_sees_only_unowned_documents(self):
|
||||||
|
owner = User.objects.create_user(username="owner")
|
||||||
|
owned = DocumentFactory(owner=owner)
|
||||||
|
unowned = DocumentFactory(owner=None)
|
||||||
|
|
||||||
|
assert_visible_document_ids(
|
||||||
|
permitted_document_ids(AnonymousUser()),
|
||||||
|
expected_visible=[unowned.pk],
|
||||||
|
expected_hidden=[owned.pk],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.django_db
|
||||||
|
class TestPermittedDocumentIdsIncludeDeleted:
|
||||||
|
def test_include_deleted_true_reveals_soft_deleted_owned_document(self):
|
||||||
|
owner = User.objects.create_user(username="owner")
|
||||||
|
doc = DocumentFactory(owner=owner)
|
||||||
|
doc.delete()
|
||||||
|
|
||||||
|
assert_visible_document_ids(
|
||||||
|
permitted_document_ids(owner, include_deleted=True),
|
||||||
|
expected_visible=[doc.pk],
|
||||||
|
expected_hidden=[],
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_include_deleted_true_still_respects_permission_boundary(self):
|
||||||
|
owner = User.objects.create_user(username="owner")
|
||||||
|
stranger = User.objects.create_user(username="mallory")
|
||||||
|
doc = DocumentFactory(owner=owner)
|
||||||
|
doc.delete()
|
||||||
|
|
||||||
|
assert_visible_document_ids(
|
||||||
|
permitted_document_ids(stranger, include_deleted=True),
|
||||||
|
expected_visible=[],
|
||||||
|
expected_hidden=[doc.pk],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.django_db
|
||||||
|
class TestAiChatAllDocumentsPermissionBoundary:
|
||||||
|
"""
|
||||||
|
Regression test pinning the "ask across all documents" AI chat behavior
|
||||||
|
(ChatStreamingView.post, no document_id) to the same owner/permission
|
||||||
|
boundary enforced by permitted_document_ids(). This call site was
|
||||||
|
migrated from get_objects_for_user_owner_aware() to
|
||||||
|
permitted_document_ids(); this test must stay green across that swap.
|
||||||
|
"""
|
||||||
|
|
||||||
|
ENDPOINT = "/api/documents/chat/"
|
||||||
|
|
||||||
|
@override_settings(AI_ENABLED=True)
|
||||||
|
@patch("documents.views.stream_chat_with_documents")
|
||||||
|
def test_chat_all_documents_excludes_unshared_document(self, mock_stream_chat):
|
||||||
|
mock_stream_chat.return_value = iter([b"data"])
|
||||||
|
|
||||||
|
owner = User.objects.create_user(username="owner")
|
||||||
|
asker = User.objects.create_user(username="asker")
|
||||||
|
asker.user_permissions.add(
|
||||||
|
*Permission.objects.filter(codename="view_document"),
|
||||||
|
)
|
||||||
|
shared = DocumentFactory(owner=owner)
|
||||||
|
not_shared = DocumentFactory(owner=owner)
|
||||||
|
assign_perm("view_document", asker, shared)
|
||||||
|
|
||||||
|
client = APIClient()
|
||||||
|
client.force_authenticate(user=asker)
|
||||||
|
response = client.post(
|
||||||
|
self.ENDPOINT,
|
||||||
|
data={"q": "question"},
|
||||||
|
format="json",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == HTTPStatus.OK
|
||||||
|
mock_stream_chat.assert_called_once()
|
||||||
|
_, kwargs = mock_stream_chat.call_args
|
||||||
|
visible_ids = {doc.pk for doc in kwargs["documents"]}
|
||||||
|
assert shared.pk in visible_ids
|
||||||
|
assert not_shared.pk not in visible_ids
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.django_db
|
||||||
|
class TestDuplicateDocumentsPermissionBoundary:
|
||||||
|
def test_get_viewable_duplicates_includes_soft_deleted_but_respects_perms(self):
|
||||||
|
owner = User.objects.create_user(username="owner")
|
||||||
|
stranger = User.objects.create_user(username="mallory")
|
||||||
|
original = DocumentFactory(owner=owner, checksum="dupe-checksum")
|
||||||
|
dup_visible = DocumentFactory(owner=owner, checksum="dupe-checksum")
|
||||||
|
dup_hidden = DocumentFactory(owner=owner, checksum="dupe-checksum")
|
||||||
|
dup_hidden.delete() # soft delete, should still be found (include_deleted=True)
|
||||||
|
assign_perm("view_document", stranger, dup_visible)
|
||||||
|
|
||||||
|
result_owner = _get_viewable_duplicates(original, owner)
|
||||||
|
assert {d.pk for d in result_owner} == {dup_visible.pk, dup_hidden.pk}
|
||||||
|
|
||||||
|
result_stranger = _get_viewable_duplicates(original, stranger)
|
||||||
|
assert {d.pk for d in result_stranger} == {dup_visible.pk}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.django_db
|
||||||
|
class TestPermittedDocumentIdsArbitraryPermission:
|
||||||
|
def test_change_document_permission_is_distinct_from_view(self):
|
||||||
|
owner = User.objects.create_user(username="owner")
|
||||||
|
viewer_only = User.objects.create_user(username="viewer")
|
||||||
|
editor = User.objects.create_user(username="editor")
|
||||||
|
doc = DocumentFactory(owner=owner)
|
||||||
|
assign_perm("view_document", viewer_only, doc)
|
||||||
|
assign_perm("change_document", editor, doc)
|
||||||
|
assign_perm("view_document", editor, doc)
|
||||||
|
|
||||||
|
assert_visible_document_ids(
|
||||||
|
permitted_document_ids(editor, perm="change_document"),
|
||||||
|
expected_visible=[doc.pk],
|
||||||
|
expected_hidden=[],
|
||||||
|
)
|
||||||
|
assert_visible_document_ids(
|
||||||
|
permitted_document_ids(viewer_only, perm="change_document"),
|
||||||
|
expected_visible=[],
|
||||||
|
expected_hidden=[doc.pk],
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_qualified_permission_string_is_normalized_to_codename(self):
|
||||||
|
owner = User.objects.create_user(username="owner")
|
||||||
|
editor = User.objects.create_user(username="editor")
|
||||||
|
doc = DocumentFactory(owner=owner)
|
||||||
|
assign_perm("change_document", editor, doc)
|
||||||
|
|
||||||
|
assert_visible_document_ids(
|
||||||
|
permitted_document_ids(editor, perm="documents.change_document"),
|
||||||
|
expected_visible=[doc.pk],
|
||||||
|
expected_hidden=[],
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_delete_permission_with_include_deleted_for_trash_restore(self):
|
||||||
|
owner = User.objects.create_user(username="owner")
|
||||||
|
stranger = User.objects.create_user(username="mallory")
|
||||||
|
view_only = User.objects.create_user(username="viewer")
|
||||||
|
doc = DocumentFactory(owner=owner)
|
||||||
|
assign_perm("view_document", view_only, doc)
|
||||||
|
doc.delete()
|
||||||
|
|
||||||
|
assert_visible_document_ids(
|
||||||
|
permitted_document_ids(owner, perm="delete_document", include_deleted=True),
|
||||||
|
expected_visible=[doc.pk],
|
||||||
|
expected_hidden=[],
|
||||||
|
)
|
||||||
|
assert_visible_document_ids(
|
||||||
|
permitted_document_ids(
|
||||||
|
stranger,
|
||||||
|
perm="delete_document",
|
||||||
|
include_deleted=True,
|
||||||
|
),
|
||||||
|
expected_visible=[],
|
||||||
|
expected_hidden=[doc.pk],
|
||||||
|
)
|
||||||
|
assert_visible_document_ids(
|
||||||
|
permitted_document_ids(
|
||||||
|
view_only,
|
||||||
|
perm="delete_document",
|
||||||
|
include_deleted=True,
|
||||||
|
),
|
||||||
|
expected_visible=[],
|
||||||
|
expected_hidden=[doc.pk],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.django_db
|
||||||
|
class TestEmailDocumentPermissionBoundary:
|
||||||
|
def test_email_action_rejects_document_without_view_permission(
|
||||||
|
self,
|
||||||
|
rest_api_client,
|
||||||
|
):
|
||||||
|
owner = User.objects.create_user(username="owner")
|
||||||
|
requester = User.objects.create_user(username="requester")
|
||||||
|
rest_api_client.force_authenticate(user=requester)
|
||||||
|
hidden = DocumentFactory(owner=owner)
|
||||||
|
|
||||||
|
response = rest_api_client.post(
|
||||||
|
"/api/documents/email/",
|
||||||
|
{
|
||||||
|
"documents": [hidden.pk],
|
||||||
|
"addresses": "someone@example.com",
|
||||||
|
"subject": "test",
|
||||||
|
"message": "test",
|
||||||
|
},
|
||||||
|
format="json",
|
||||||
|
)
|
||||||
|
assert response.status_code == HTTPStatus.FORBIDDEN
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.django_db
|
||||||
|
class TestBulkEditChangePermissionBoundary:
|
||||||
|
def test_bulk_edit_rejects_mixed_batch_when_any_document_lacks_change_permission(
|
||||||
|
self,
|
||||||
|
rest_api_client,
|
||||||
|
):
|
||||||
|
# A bulk-edit request containing both a document the requester CAN
|
||||||
|
# change and one they CANNOT should be rejected as a whole: the
|
||||||
|
# permitted document must not be partially applied just because it
|
||||||
|
# was bundled with a forbidden one, proving the endpoint checks
|
||||||
|
# every document in the batch rather than only the first/last.
|
||||||
|
owner = User.objects.create_user(username="owner")
|
||||||
|
requester = User.objects.create_user(username="requester")
|
||||||
|
# grant the global change_document permission so the object-level
|
||||||
|
# check (not the global has_perm check) is what's under test
|
||||||
|
requester.user_permissions.add(
|
||||||
|
Permission.objects.get(codename="change_document"),
|
||||||
|
)
|
||||||
|
rest_api_client.force_authenticate(user=requester)
|
||||||
|
changeable = DocumentFactory(owner=owner)
|
||||||
|
assign_perm("view_document", requester, changeable)
|
||||||
|
assign_perm("change_document", requester, changeable) # fully permitted
|
||||||
|
target = DocumentFactory(owner=owner)
|
||||||
|
assign_perm("view_document", requester, target) # view only, NOT change
|
||||||
|
|
||||||
|
response = rest_api_client.post(
|
||||||
|
"/api/documents/bulk_edit/",
|
||||||
|
{
|
||||||
|
"documents": [changeable.pk, target.pk],
|
||||||
|
"method": "modify_tags",
|
||||||
|
"parameters": {"add_tags": [], "remove_tags": []},
|
||||||
|
},
|
||||||
|
format="json",
|
||||||
|
)
|
||||||
|
assert response.status_code == HTTPStatus.FORBIDDEN
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.django_db
|
||||||
|
class TestBulkDownloadPermissionChecksRootDocument:
|
||||||
|
def test_permission_checked_on_root_not_on_version(
|
||||||
|
self,
|
||||||
|
rest_api_client,
|
||||||
|
paperless_dirs,
|
||||||
|
_media_settings,
|
||||||
|
):
|
||||||
|
owner = User.objects.create_user(username="owner")
|
||||||
|
requester = User.objects.create_user(username="requester")
|
||||||
|
rest_api_client.force_authenticate(user=requester)
|
||||||
|
root = DocumentFactory(owner=owner)
|
||||||
|
# a version of root that the requester has NOT been individually granted
|
||||||
|
version = DocumentFactory(owner=owner, root_document=root, version_index=1)
|
||||||
|
version.source_path.write_bytes(b"%PDF-1.4 test")
|
||||||
|
assign_perm("view_document", requester, root) # granted on ROOT only
|
||||||
|
|
||||||
|
response = rest_api_client.post(
|
||||||
|
"/api/documents/bulk_download/",
|
||||||
|
{"documents": [version.pk]},
|
||||||
|
format="json",
|
||||||
|
)
|
||||||
|
assert (
|
||||||
|
response.status_code == HTTPStatus.OK
|
||||||
|
) # visible because root is permitted
|
||||||
|
|
||||||
|
# Granted on the VERSION itself, but NOT on the root. If the endpoint
|
||||||
|
# ever regressed to checking "root OR version" instead of root-only,
|
||||||
|
# this grant would incorrectly unlock access. This is the case that
|
||||||
|
# actually discriminates correct (root-only) enforcement from a
|
||||||
|
# root-or-version bug; a user with no grant at all (the old
|
||||||
|
# `stranger` case) can't tell the two apart, since they're denied
|
||||||
|
# either way.
|
||||||
|
version_only_grantee = User.objects.create_user(username="version_only_grantee")
|
||||||
|
assign_perm("view_document", version_only_grantee, version)
|
||||||
|
rest_api_client.force_authenticate(user=version_only_grantee)
|
||||||
|
response = rest_api_client.post(
|
||||||
|
"/api/documents/bulk_download/",
|
||||||
|
{"documents": [version.pk]},
|
||||||
|
format="json",
|
||||||
|
)
|
||||||
|
assert (
|
||||||
|
response.status_code == HTTPStatus.FORBIDDEN
|
||||||
|
) # version-only grant must not substitute for root permission
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.django_db
|
||||||
|
class TestTrashRestorePermissionBoundary:
|
||||||
|
def test_restore_rejects_document_without_delete_permission(
|
||||||
|
self,
|
||||||
|
rest_api_client,
|
||||||
|
):
|
||||||
|
owner = User.objects.create_user(username="owner")
|
||||||
|
requester = User.objects.create_user(username="requester")
|
||||||
|
rest_api_client.force_authenticate(user=requester)
|
||||||
|
doc = DocumentFactory(owner=owner)
|
||||||
|
assign_perm("view_document", requester, doc) # view only, NOT delete
|
||||||
|
doc.delete()
|
||||||
|
|
||||||
|
response = rest_api_client.post(
|
||||||
|
"/api/trash/",
|
||||||
|
{"documents": [doc.pk], "action": "restore"},
|
||||||
|
format="json",
|
||||||
|
)
|
||||||
|
assert response.status_code == HTTPStatus.FORBIDDEN
|
||||||
|
|
||||||
|
def test_restore_allows_document_with_explicit_delete_permission(
|
||||||
|
self,
|
||||||
|
rest_api_client,
|
||||||
|
):
|
||||||
|
owner = User.objects.create_user(username="owner")
|
||||||
|
requester = User.objects.create_user(username="requester")
|
||||||
|
rest_api_client.force_authenticate(user=requester)
|
||||||
|
doc = DocumentFactory(owner=owner)
|
||||||
|
assign_perm("delete_document", requester, doc)
|
||||||
|
doc.delete()
|
||||||
|
|
||||||
|
response = rest_api_client.post(
|
||||||
|
"/api/trash/",
|
||||||
|
{"documents": [doc.pk], "action": "restore"},
|
||||||
|
format="json",
|
||||||
|
)
|
||||||
|
assert response.status_code == HTTPStatus.OK
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.django_db
|
||||||
|
class TestTrashViewExcludesExplicitlyGrantedDocuments:
|
||||||
|
"""
|
||||||
|
Regression test pinning TrashView's use of
|
||||||
|
``_TrashPermittedObjectsFilter`` (``include_granted = False``). If that
|
||||||
|
flag were ever flipped to the default ``True``, or the subclass removed
|
||||||
|
in favor of the base ``PermittedObjectsFilter``, a trashed document
|
||||||
|
would leak into ``/api/trash/`` results for any user holding an
|
||||||
|
explicit guardian grant on it, even though they are neither the owner
|
||||||
|
nor a superuser.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def test_explicit_grant_does_not_leak_trashed_document(self, rest_api_client):
|
||||||
|
owner = User.objects.create_user(username="trash_owner")
|
||||||
|
grantee = User.objects.create_user(username="trash_grantee")
|
||||||
|
doc = DocumentFactory(owner=owner)
|
||||||
|
doc.delete() # soft delete
|
||||||
|
assign_perm("view_document", grantee, doc)
|
||||||
|
|
||||||
|
rest_api_client.force_authenticate(user=grantee)
|
||||||
|
response = rest_api_client.get("/api/trash/")
|
||||||
|
|
||||||
|
assert response.status_code == HTTPStatus.OK
|
||||||
|
result_ids = {result["id"] for result in response.data["results"]}
|
||||||
|
assert doc.pk not in result_ids
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.django_db
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
("model", "factory", "perm"),
|
||||||
|
[
|
||||||
|
(Tag, TagFactory, "view_tag"),
|
||||||
|
(Correspondent, CorrespondentFactory, "view_correspondent"),
|
||||||
|
(DocumentType, DocumentTypeFactory, "view_documenttype"),
|
||||||
|
(StoragePath, StoragePathFactory, "view_storagepath"),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
class TestPermittedObjectIdsGenericModels:
|
||||||
|
def test_owner_sees_own_object(self, model, factory, perm):
|
||||||
|
owner = User.objects.create_user(username=f"owner_{model.__name__}")
|
||||||
|
stranger = User.objects.create_user(username=f"stranger_{model.__name__}")
|
||||||
|
owned = factory(owner=owner)
|
||||||
|
strangers = factory(owner=stranger)
|
||||||
|
|
||||||
|
assert_visible_document_ids(
|
||||||
|
permitted_object_ids(owner, model, perm),
|
||||||
|
expected_visible=[owned.pk],
|
||||||
|
expected_hidden=[strangers.pk],
|
||||||
|
)
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("is_superuser", [False, True])
|
||||||
|
def test_inactive_user_sees_nothing(self, model, factory, perm, is_superuser):
|
||||||
|
suffix = f"{model.__name__}_{is_superuser}"
|
||||||
|
user = User.objects.create_user(
|
||||||
|
username=f"inactive_{suffix}",
|
||||||
|
is_active=False,
|
||||||
|
is_superuser=is_superuser,
|
||||||
|
)
|
||||||
|
other = User.objects.create_user(username=f"other_{suffix}")
|
||||||
|
granted = factory(owner=other)
|
||||||
|
assign_perm(perm, user, granted)
|
||||||
|
|
||||||
|
assert_visible_document_ids(
|
||||||
|
permitted_object_ids(user, model, perm),
|
||||||
|
expected_visible=[],
|
||||||
|
expected_hidden=[
|
||||||
|
factory(owner=None).pk,
|
||||||
|
factory(owner=user).pk,
|
||||||
|
granted.pk,
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_unowned_object_visible_to_everyone(self, model, factory, perm):
|
||||||
|
user = User.objects.create_user(username=f"user_{model.__name__}")
|
||||||
|
unowned = factory(owner=None)
|
||||||
|
|
||||||
|
assert_visible_document_ids(
|
||||||
|
permitted_object_ids(user, model, perm),
|
||||||
|
expected_visible=[unowned.pk],
|
||||||
|
expected_hidden=[],
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_explicit_permission_grants_visibility(self, model, factory, perm):
|
||||||
|
owner = User.objects.create_user(username=f"owner2_{model.__name__}")
|
||||||
|
grantee = User.objects.create_user(username=f"grantee_{model.__name__}")
|
||||||
|
stranger = User.objects.create_user(username=f"stranger2_{model.__name__}")
|
||||||
|
shared = factory(owner=owner)
|
||||||
|
not_shared = factory(owner=owner)
|
||||||
|
assign_perm(perm, grantee, shared)
|
||||||
|
|
||||||
|
assert_visible_document_ids(
|
||||||
|
permitted_object_ids(grantee, model, perm),
|
||||||
|
expected_visible=[shared.pk],
|
||||||
|
expected_hidden=[not_shared.pk],
|
||||||
|
)
|
||||||
|
assert_visible_document_ids(
|
||||||
|
permitted_object_ids(stranger, model, perm),
|
||||||
|
expected_visible=[],
|
||||||
|
expected_hidden=[shared.pk, not_shared.pk],
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_group_permission_grants_visibility_to_members_only(
|
||||||
|
self,
|
||||||
|
model,
|
||||||
|
factory,
|
||||||
|
perm,
|
||||||
|
):
|
||||||
|
owner = User.objects.create_user(username=f"owner3_{model.__name__}")
|
||||||
|
member = User.objects.create_user(username=f"member_{model.__name__}")
|
||||||
|
non_member = User.objects.create_user(username=f"nonmember_{model.__name__}")
|
||||||
|
group = Group.objects.create(name=f"group_{model.__name__}")
|
||||||
|
member.groups.add(group)
|
||||||
|
shared = factory(owner=owner)
|
||||||
|
assign_perm(perm, group, shared)
|
||||||
|
|
||||||
|
assert_visible_document_ids(
|
||||||
|
permitted_object_ids(member, model, perm),
|
||||||
|
expected_visible=[shared.pk],
|
||||||
|
expected_hidden=[],
|
||||||
|
)
|
||||||
|
assert_visible_document_ids(
|
||||||
|
permitted_object_ids(non_member, model, perm),
|
||||||
|
expected_visible=[],
|
||||||
|
expected_hidden=[shared.pk],
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_superuser_sees_everything(self, model, factory, perm):
|
||||||
|
superuser = User.objects.create_superuser(username=f"root_{model.__name__}")
|
||||||
|
owner = User.objects.create_user(username=f"owner4_{model.__name__}")
|
||||||
|
obj = factory(owner=owner)
|
||||||
|
|
||||||
|
assert_visible_document_ids(
|
||||||
|
permitted_object_ids(superuser, model, perm),
|
||||||
|
expected_visible=[obj.pk],
|
||||||
|
expected_hidden=[],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.django_db
|
||||||
|
class TestMatchingRespectsObjectPermissions:
|
||||||
|
def test_match_tags_only_considers_tags_visible_to_user(self):
|
||||||
|
owner = User.objects.create_user(username="tag_owner")
|
||||||
|
classifying_user = User.objects.create_user(username="classifier_user")
|
||||||
|
visible_tag = TagFactory(
|
||||||
|
owner=owner,
|
||||||
|
match="invoice",
|
||||||
|
matching_algorithm=Tag.MATCH_LITERAL,
|
||||||
|
)
|
||||||
|
hidden_tag = TagFactory(
|
||||||
|
owner=owner,
|
||||||
|
match="invoice",
|
||||||
|
matching_algorithm=Tag.MATCH_LITERAL,
|
||||||
|
)
|
||||||
|
assign_perm("view_tag", classifying_user, visible_tag)
|
||||||
|
doc = DocumentFactory(owner=classifying_user, content="an invoice document")
|
||||||
|
|
||||||
|
matched = match_tags(doc, classifier=None, user=classifying_user)
|
||||||
|
matched_ids = {t.pk for t in matched}
|
||||||
|
assert visible_tag.pk in matched_ids
|
||||||
|
assert hidden_tag.pk not in matched_ids
|
||||||
|
|
||||||
|
def test_match_correspondents_only_considers_correspondents_visible_to_user(self):
|
||||||
|
owner = User.objects.create_user(username="correspondent_owner")
|
||||||
|
classifying_user = User.objects.create_user(username="classifier_user2")
|
||||||
|
visible_correspondent = CorrespondentFactory(
|
||||||
|
owner=owner,
|
||||||
|
match="invoice",
|
||||||
|
matching_algorithm=Correspondent.MATCH_LITERAL,
|
||||||
|
)
|
||||||
|
hidden_correspondent = CorrespondentFactory(
|
||||||
|
owner=owner,
|
||||||
|
match="invoice",
|
||||||
|
matching_algorithm=Correspondent.MATCH_LITERAL,
|
||||||
|
)
|
||||||
|
assign_perm("view_correspondent", classifying_user, visible_correspondent)
|
||||||
|
doc = DocumentFactory(owner=classifying_user, content="an invoice document")
|
||||||
|
|
||||||
|
matched = match_correspondents(doc, classifier=None, user=classifying_user)
|
||||||
|
matched_ids = {c.pk for c in matched}
|
||||||
|
assert visible_correspondent.pk in matched_ids
|
||||||
|
assert hidden_correspondent.pk not in matched_ids
|
||||||
|
|
||||||
|
def test_match_document_types_only_considers_document_types_visible_to_user(self):
|
||||||
|
owner = User.objects.create_user(username="document_type_owner")
|
||||||
|
classifying_user = User.objects.create_user(username="classifier_user3")
|
||||||
|
visible_document_type = DocumentTypeFactory(
|
||||||
|
owner=owner,
|
||||||
|
match="invoice",
|
||||||
|
matching_algorithm=DocumentType.MATCH_LITERAL,
|
||||||
|
)
|
||||||
|
hidden_document_type = DocumentTypeFactory(
|
||||||
|
owner=owner,
|
||||||
|
match="invoice",
|
||||||
|
matching_algorithm=DocumentType.MATCH_LITERAL,
|
||||||
|
)
|
||||||
|
assign_perm("view_documenttype", classifying_user, visible_document_type)
|
||||||
|
doc = DocumentFactory(owner=classifying_user, content="an invoice document")
|
||||||
|
|
||||||
|
matched = match_document_types(doc, classifier=None, user=classifying_user)
|
||||||
|
matched_ids = {dt.pk for dt in matched}
|
||||||
|
assert visible_document_type.pk in matched_ids
|
||||||
|
assert hidden_document_type.pk not in matched_ids
|
||||||
|
|
||||||
|
def test_match_storage_paths_only_considers_storage_paths_visible_to_user(self):
|
||||||
|
owner = User.objects.create_user(username="storage_path_owner")
|
||||||
|
classifying_user = User.objects.create_user(username="classifier_user4")
|
||||||
|
visible_storage_path = StoragePathFactory(
|
||||||
|
owner=owner,
|
||||||
|
match="invoice",
|
||||||
|
matching_algorithm=StoragePath.MATCH_LITERAL,
|
||||||
|
)
|
||||||
|
hidden_storage_path = StoragePathFactory(
|
||||||
|
owner=owner,
|
||||||
|
match="invoice",
|
||||||
|
matching_algorithm=StoragePath.MATCH_LITERAL,
|
||||||
|
)
|
||||||
|
assign_perm("view_storagepath", classifying_user, visible_storage_path)
|
||||||
|
doc = DocumentFactory(owner=classifying_user, content="an invoice document")
|
||||||
|
|
||||||
|
matched = match_storage_paths(doc, classifier=None, user=classifying_user)
|
||||||
|
matched_ids = {sp.pk for sp in matched}
|
||||||
|
assert visible_storage_path.pk in matched_ids
|
||||||
|
assert hidden_storage_path.pk not in matched_ids
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.django_db
|
||||||
|
class TestBulkEditObjectsApplyToAllPermissionBoundary:
|
||||||
|
def test_apply_to_all_tags_excludes_unpermitted_tag(self, rest_api_client):
|
||||||
|
owner = User.objects.create_user(username="tags_owner")
|
||||||
|
requester = User.objects.create_user(username="tags_requester")
|
||||||
|
# grant the global change_tag permission so the object-level
|
||||||
|
# filtering (not the global has_perm check) is what's under test
|
||||||
|
requester.user_permissions.add(
|
||||||
|
Permission.objects.get(codename="change_tag"),
|
||||||
|
)
|
||||||
|
rest_api_client.force_authenticate(user=requester)
|
||||||
|
visible = TagFactory(owner=owner)
|
||||||
|
hidden = TagFactory(owner=owner)
|
||||||
|
assign_perm("view_tag", requester, visible)
|
||||||
|
assign_perm("change_tag", requester, visible)
|
||||||
|
|
||||||
|
response = rest_api_client.post(
|
||||||
|
"/api/bulk_edit_objects/",
|
||||||
|
{
|
||||||
|
"object_type": "tags",
|
||||||
|
"operation": "set_permissions",
|
||||||
|
"all": True,
|
||||||
|
"filters": {},
|
||||||
|
"owner": requester.pk,
|
||||||
|
},
|
||||||
|
format="json",
|
||||||
|
)
|
||||||
|
assert response.status_code == HTTPStatus.OK
|
||||||
|
|
||||||
|
# The apply_to_all dispatch must resolve permitted objects up front:
|
||||||
|
# the visible tag (object-level change_tag granted) gets its owner
|
||||||
|
# reassigned, while the hidden tag (no object-level grant) is
|
||||||
|
# excluded entirely and keeps its original owner.
|
||||||
|
visible.refresh_from_db()
|
||||||
|
hidden.refresh_from_db()
|
||||||
|
assert visible.owner == requester
|
||||||
|
assert hidden.owner == owner
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.django_db
|
||||||
|
class TestBulkEditObjectsTagDescendantPartialPermission:
|
||||||
|
def test_apply_to_all_descendant_expansion_respects_per_object_permissions(
|
||||||
|
self,
|
||||||
|
rest_api_client,
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
GIVEN:
|
||||||
|
- A tag hierarchy (parent -> permitted_child, unpermitted_child)
|
||||||
|
- A non-superuser requester with object-level change_tag granted
|
||||||
|
on the parent and on only ONE of the two children
|
||||||
|
WHEN:
|
||||||
|
- bulk_edit_objects is called with all=True and a filter that
|
||||||
|
matches only the root (parent) tag, engaging the
|
||||||
|
tag-descendant-expansion logic in BulkEditObjectsView.post
|
||||||
|
THEN:
|
||||||
|
- The descendant expansion only pulls in descendants the
|
||||||
|
requester actually has permission on: the permitted child's
|
||||||
|
owner is reassigned alongside the parent's, while the
|
||||||
|
unpermitted child keeps its original owner. This pins that the
|
||||||
|
expansion checks per-object permissions (editable_ids), not
|
||||||
|
merely "is a descendant of a filter match".
|
||||||
|
|
||||||
|
NOTE: this uses ``set_permissions`` (owner reassignment) rather than
|
||||||
|
``delete`` as the operation, because Tag.tn_parent (django-treenode)
|
||||||
|
cascades deletes to descendants at the database/ORM level regardless
|
||||||
|
of which tags the view resolved into ``objs`` -- a delete-based test
|
||||||
|
would pass/fail based on FK cascade behavior, not on whether the
|
||||||
|
descendant-expansion logic itself respected per-object permissions.
|
||||||
|
"""
|
||||||
|
owner = User.objects.create_user(username="tag_hierarchy_owner")
|
||||||
|
requester = User.objects.create_user(username="tag_hierarchy_requester")
|
||||||
|
# global change_tag permission so the has_perm() gate passes and the
|
||||||
|
# object-level permitted_object_ids filtering is what's under test
|
||||||
|
requester.user_permissions.add(
|
||||||
|
Permission.objects.get(codename="change_tag"),
|
||||||
|
)
|
||||||
|
rest_api_client.force_authenticate(user=requester)
|
||||||
|
|
||||||
|
parent = TagFactory(owner=owner, name="parent-tag")
|
||||||
|
permitted_child = TagFactory(
|
||||||
|
owner=owner,
|
||||||
|
name="permitted-child-tag",
|
||||||
|
tn_parent=parent,
|
||||||
|
)
|
||||||
|
unpermitted_child = TagFactory(
|
||||||
|
owner=owner,
|
||||||
|
name="unpermitted-child-tag",
|
||||||
|
tn_parent=parent,
|
||||||
|
)
|
||||||
|
assign_perm("change_tag", requester, parent)
|
||||||
|
assign_perm("change_tag", requester, permitted_child)
|
||||||
|
# unpermitted_child is intentionally NOT granted change_tag
|
||||||
|
|
||||||
|
response = rest_api_client.post(
|
||||||
|
"/api/bulk_edit_objects/",
|
||||||
|
{
|
||||||
|
"object_type": "tags",
|
||||||
|
"operation": "set_permissions",
|
||||||
|
"all": True,
|
||||||
|
"filters": {"is_root": True},
|
||||||
|
"owner": requester.pk,
|
||||||
|
},
|
||||||
|
format="json",
|
||||||
|
)
|
||||||
|
assert response.status_code == HTTPStatus.OK
|
||||||
|
|
||||||
|
parent.refresh_from_db()
|
||||||
|
permitted_child.refresh_from_db()
|
||||||
|
unpermitted_child.refresh_from_db()
|
||||||
|
assert parent.owner == requester
|
||||||
|
assert permitted_child.owner == requester
|
||||||
|
assert unpermitted_child.owner == owner
|
||||||
@@ -0,0 +1,111 @@
|
|||||||
|
import pytest
|
||||||
|
from django.contrib.auth.models import User
|
||||||
|
from guardian.shortcuts import assign_perm
|
||||||
|
from rest_framework.test import APIRequestFactory
|
||||||
|
|
||||||
|
from documents.filters import PermittedObjectsFilter
|
||||||
|
from documents.models import Tag
|
||||||
|
from documents.tests.factories import TagFactory
|
||||||
|
|
||||||
|
|
||||||
|
class _DummyView:
|
||||||
|
queryset = Tag.objects.all()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.django_db
|
||||||
|
class TestPermittedObjectsFilter:
|
||||||
|
def test_superuser_bypasses_filtering_entirely(self):
|
||||||
|
superuser = User.objects.create_superuser(username="root")
|
||||||
|
owner = User.objects.create_user(username="owner")
|
||||||
|
TagFactory(owner=owner)
|
||||||
|
request = APIRequestFactory().get("/")
|
||||||
|
request.user = superuser
|
||||||
|
|
||||||
|
result = PermittedObjectsFilter().filter_queryset(
|
||||||
|
request,
|
||||||
|
Tag.objects.all(),
|
||||||
|
_DummyView(),
|
||||||
|
)
|
||||||
|
assert result.count() == Tag.objects.count()
|
||||||
|
|
||||||
|
def test_non_superuser_sees_only_owned_unowned_and_granted(self):
|
||||||
|
owner = User.objects.create_user(username="owner")
|
||||||
|
grantee = User.objects.create_user(username="grantee")
|
||||||
|
owned = TagFactory(owner=grantee)
|
||||||
|
unowned = TagFactory(owner=None)
|
||||||
|
granted = TagFactory(owner=owner)
|
||||||
|
hidden = TagFactory(owner=owner)
|
||||||
|
assign_perm("view_tag", grantee, granted)
|
||||||
|
request = APIRequestFactory().get("/")
|
||||||
|
request.user = grantee
|
||||||
|
|
||||||
|
result = PermittedObjectsFilter().filter_queryset(
|
||||||
|
request,
|
||||||
|
Tag.objects.all(),
|
||||||
|
_DummyView(),
|
||||||
|
)
|
||||||
|
visible_ids = set(result.values_list("id", flat=True))
|
||||||
|
assert visible_ids == {owned.pk, unowned.pk, granted.pk}
|
||||||
|
assert hidden.pk not in visible_ids
|
||||||
|
|
||||||
|
def test_include_granted_false_excludes_explicitly_shared_objects(self):
|
||||||
|
owner = User.objects.create_user(username="owner2")
|
||||||
|
grantee = User.objects.create_user(username="grantee2")
|
||||||
|
owned = TagFactory(owner=grantee)
|
||||||
|
granted = TagFactory(owner=owner)
|
||||||
|
assign_perm("view_tag", grantee, granted)
|
||||||
|
request = APIRequestFactory().get("/")
|
||||||
|
request.user = grantee
|
||||||
|
|
||||||
|
class _OwnerOnlyFilter(PermittedObjectsFilter):
|
||||||
|
include_granted = False
|
||||||
|
|
||||||
|
result = _OwnerOnlyFilter().filter_queryset(
|
||||||
|
request,
|
||||||
|
Tag.objects.all(),
|
||||||
|
_DummyView(),
|
||||||
|
)
|
||||||
|
visible_ids = set(result.values_list("id", flat=True))
|
||||||
|
assert visible_ids == {owned.pk}
|
||||||
|
assert granted.pk not in visible_ids
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
("username", "is_superuser"),
|
||||||
|
[("inactive", False), ("inactive_super", True)],
|
||||||
|
)
|
||||||
|
def test_inactive_user_sees_nothing(self, username: str, *, is_superuser: bool):
|
||||||
|
user = User.objects.create_user(
|
||||||
|
username=username,
|
||||||
|
is_active=False,
|
||||||
|
is_superuser=is_superuser,
|
||||||
|
)
|
||||||
|
TagFactory(owner=None)
|
||||||
|
TagFactory(owner=user)
|
||||||
|
granted = TagFactory(owner=User.objects.create_user(username=f"o_{username}"))
|
||||||
|
assign_perm("view_tag", user, granted)
|
||||||
|
request = APIRequestFactory().get("/")
|
||||||
|
request.user = user
|
||||||
|
|
||||||
|
result = PermittedObjectsFilter().filter_queryset(
|
||||||
|
request,
|
||||||
|
Tag.objects.all(),
|
||||||
|
_DummyView(),
|
||||||
|
)
|
||||||
|
assert result.count() == 0
|
||||||
|
|
||||||
|
def test_inactive_user_sees_nothing_with_include_granted_false(self):
|
||||||
|
user = User.objects.create_user(username="inactive_owner", is_active=False)
|
||||||
|
TagFactory(owner=user)
|
||||||
|
TagFactory(owner=None)
|
||||||
|
request = APIRequestFactory().get("/")
|
||||||
|
request.user = user
|
||||||
|
|
||||||
|
class _OwnerOnlyFilter(PermittedObjectsFilter):
|
||||||
|
include_granted = False
|
||||||
|
|
||||||
|
result = _OwnerOnlyFilter().filter_queryset(
|
||||||
|
request,
|
||||||
|
Tag.objects.all(),
|
||||||
|
_DummyView(),
|
||||||
|
)
|
||||||
|
assert result.count() == 0
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user