Compare commits

..
107 Commits
Author SHA1 Message Date
shamoon e5561ba06f Fix: correctly scope mail account enumeration (#12636) 2026-04-24 10:15:59 -07:00
github-actions[bot]andshamoon 1692c916f8 Documentation: Add v2.20.14 changelog (#12577)
* Changelog v2.20.14 - GHA

* Reorganize

---------

Co-authored-by: github-actions <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: shamoon <4887959+shamoon@users.noreply.github.com>
2026-04-14 14:03:26 -07:00
shamoon ffd886eae0 Bump version to 2.20.14 2026-04-14 13:11:48 -07:00
shamoon 12c0dc635e Merge branch 'release/v2.20.x' 2026-04-14 13:10:52 -07:00
shamoon 1e01ce42c0 Update usage.md 2026-04-14 13:10:40 -07:00
shamoon e46f4a5aaa Fix: do not submit permissions for non-owners (#12571) 2026-04-13 12:43:06 -07:00
shamoon 1ba6c31385 Clarify User permission scope in docs 2026-04-07 13:38:24 -07:00
shamoon f3ee820fa4 Fix: prevent duplicate parent tag IDs (#12522) 2026-04-06 21:59:11 -07:00
shamoon 2f5bcdf66e Fix: dont defer tag change application in workflows (#12478) 2026-04-02 11:54:37 -07:00
shamoon 501cdd92d2 Fix: limit share link viewset actions (#12461) 2026-03-30 09:34:13 -07:00
shamoon 66c5c46913 Fix: add fallback ordering for documents by id after created (#12440) 2026-03-26 06:16:14 -07:00
shamoon 782634d912 Documentation: clarify barcode file type support 2026-03-23 15:45:27 -07:00
shamoon 3cfe9fa2a8 Fixhancement: default mail -created correspondent matching to exact (#12414) 2026-03-21 16:15:18 -07:00
github-actions[bot]andshamoon 784fed447f Documentation: Add v2.20.13 changelog (#12408)
* Changelog v2.20.13 - GHA

* Update changelog for version 2.20.13

Added bug fixes for version 2.20.13 in changelog.

---------

Co-authored-by: github-actions <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: shamoon <4887959+shamoon@users.noreply.github.com>
2026-03-21 08:50:27 -07:00
shamoon 3292a0e7cc Fix: validate date CF value in serializer (#12410) 2026-03-21 08:49:52 -07:00
shamoon 9646b8c67d Bump version to 2.20.13 2026-03-21 01:50:04 -07:00
shamoon e590d7df69 Merge branch 'release/v2.20.x' 2026-03-21 01:49:32 -07:00
shamoon cc71aad058 Fix: suggest corrections only if visible results 2026-03-21 01:24:23 -07:00
shamoon 3cbdf5d0b7 Fix: require view permission for more-like search 2026-03-21 01:20:59 -07:00
shamoon f84e0097e5 Fix validate document link targets 2026-03-21 00:55:36 -07:00
shamoon 7dbf8bdd4a Fix: enforce permissions when attaching accounts to mail rules 2026-03-21 00:44:28 -07:00
github-actions[bot]andshamoon d2a752a196 Documentation: Add v2.20.12 changelog (#12407)
* Changelog v2.20.12 - GHA

* Update changelog for version 2.20.12

Added security advisory for GHSA-96jx-fj7m-qh6x and fixed workflow saves and usermod/groupmod issues.

---------

Co-authored-by: github-actions <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: shamoon <4887959+shamoon@users.noreply.github.com>
2026-03-20 19:23:06 -07:00
shamoon 2cb155e717 Bump version to 2.20.12 2026-03-20 15:47:37 -07:00
shamoon 9e9fc6213c Resolve GHSA-96jx-fj7m-qh6x 2026-03-20 15:39:15 -07:00
Trenton HandClaude Sonnet 4.6 0f7c02de5e Fix: test: add regression test for workflow save clobbering filename (#12390)
Add test_workflow_document_updated_does_not_overwrite_filename to
verify that run_workflows (DOCUMENT_UPDATED path) does not revert a
DB filename that was updated by a concurrent bulk_update_documents
task's update_filename_and_move_files call.

The test replicates the race window by:
  - Updating the DB filename directly (simulating BUD-1 completing)
  - Mocking refresh_from_db so the stale in-memory filename persists
  - Asserting the DB filename is not clobbered after run_workflows

Relates to: https://github.com/paperless-ngx/paperless-ngx/issues/12386

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-18 13:31:09 -07:00
Trenton H 95dea787f2 Fix: don't try to usermod/groupmod when non-root + update docs (#12365) (#12391) 2026-03-18 10:38:45 -07:00
shamoon b6501b0c47 Fix: avoid moving files if already moved (#12389) 2026-03-18 09:51:48 -07:00
shamoon 87ebd13abc Fix: remove pagination from document notes api spec (#12388) 2026-03-18 06:48:05 -07:00
shamoon a86c9d32fe Fix: fix file button hover color in dark mode (#12367) 2026-03-17 09:26:41 -07:00
shamoon 7942edfdf4 Fixhancement: only offer basic auth for appropriate requests (#12362) 2026-03-16 22:07:12 -07:00
github-actions[bot]andshamoon 1e00ad5f30 Documentation: Add v2.20.11 changelog (#12356)
* Changelog v2.20.11 - GHA

* Update changelog for version 2.20.11

Added security advisory and fixed dropdown list issues.

---------

Co-authored-by: github-actions <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: shamoon <4887959+shamoon@users.noreply.github.com>
2026-03-15 18:47:18 -07:00
shamoon 5f26c01c6f Bump version to 2.20.11 2026-03-15 17:16:11 -07:00
shamoon 92e133eeb0 Merge branch 'release/v2.20.x' 2026-03-15 17:15:20 -07:00
shamoon 06b2d5102c Fix GHSA-59xh-5vwx-4c4q 2026-03-15 17:13:08 -07:00
shamoon 40255cfdbb Fix: correct dropdown list active color in dark mode (#12328) 2026-03-12 14:06:16 -07:00
shamoon d919c341b1 Fix: clear descendant selections in dropdown when parent toggled (#12326) 2026-03-12 11:57:35 -07:00
shamoon ba0a80a8ad Fix: prevent wrapping with larger amounts of tags on small cards, reset moreTags setting to correct count (#12302) 2026-03-11 07:39:32 -07:00
shamoon 60319c6d37 Fix: prevent stale db filename during workflow actions (#12289) 2026-03-09 19:32:46 -07:00
shamoon 1e8622494d Documentation: remove broken link 2026-03-07 23:29:42 -08:00
github-actions[bot] d6a316b1df Changelog v2.20.10 - GHA (#12247)
Co-authored-by: github-actions <41898282+github-actions[bot]@users.noreply.github.com>
2026-03-04 11:25:44 -08:00
shamoon 8f311c4b6b Bump version to 2.20.10 2026-03-04 10:38:14 -08:00
shamoon f25322600d Merge branch 'release/v2.20.x' 2026-03-04 10:09:01 -08:00
shamoon 615f27e6fb Fix: support string coercion in filepath jinja templates (#12244) 2026-03-04 08:32:34 -08:00
shamoon 5b809122b5 Fix: apply ordering after annotating tag document count (#12238) 2026-03-04 00:33:13 -08:00
shamoon 8b8307571a Fix: enforce path limit for db filename fields (#12235) 2026-03-03 13:19:56 -08:00
shamoon 0e97419e0e Chore: add existing logo for temporary url resolution 2026-03-02 13:43:24 -08:00
shamoon 7ff51452f0 Documentation: small note re filename vs original_filename 2026-03-01 11:45:06 -08:00
github-actions[bot]andshamoon a6c974589f Documentation: Add v2.20.9 changelog (#12200)
* Changelog v2.20.9 - GHA

* Update changelog for paperless-ngx 2.20.9

Added security advisory and bug fixes for version 2.20.9.

---------

Co-authored-by: github-actions <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: shamoon <4887959+shamoon@users.noreply.github.com>
2026-02-28 02:25:53 -08:00
shamoon 47f9f642a9 Bump version to 2.20.9 2026-02-28 01:35:26 -08:00
shamoon 8bfebc3b9b Merge branch 'release/v2.20.x' 2026-02-28 01:34:33 -08:00
shamoon c7f83212a3 Enforce on selection_data too 2026-02-28 01:27:40 -08:00
shamoon b010f65ae7 Fix GHSA-386h-chg4-cfw9 2026-02-28 01:16:53 -08:00
shamoon 89d3a53603 Documentation: note GHSAs in changelog 2026-02-26 23:26:35 -08:00
shamoon 9601b3d597 Fixhancement: config option reset (#12176) 2026-02-26 10:03:54 -08:00
shamoon 13e07844fe Fix: separate displayed and API collection sizes for tags (#12170) 2026-02-25 17:25:36 -08:00
shamoon be82fcb70a Documentation: docs cleanup (#12158) 2026-02-24 15:10:38 -08:00
github-actions[bot] 98298e37cd Changelog v2.20.8 - GHA (#12135)
Co-authored-by: github-actions <41898282+github-actions[bot]@users.noreply.github.com>
2026-02-21 17:43:19 -08:00
shamoon 35be0850ec Bump version to 2.20.8 2026-02-21 16:49:52 -08:00
shamoon 1bb4b9b473 More permissions on mail account test endpoint 2026-02-21 16:47:55 -08:00
shamoon f85094dc2b Set owner on OAuth mail credentials 2026-02-21 16:37:32 -08:00
shamoon 65ca78e9e7 Security: fix/GHSA-7qqc-wrcw-2fj9 2026-02-21 16:34:33 -08:00
github-actions[bot]andshamoon 5c1bbcd06d Documentation: Add v2.20.7 changelog (#12100)
---------

Co-authored-by: github-actions <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: shamoon <4887959+shamoon@users.noreply.github.com>
2026-02-16 09:00:19 -08:00
shamoon bc734798e3 Add permissions 2026-02-16 08:12:45 -08:00
shamoon 5ecbfc9df7 Split build vs deploy docs 2026-02-16 07:46:09 -08:00
shamoon e63b62d531 Bump version to 2.20.7 2026-02-16 07:26:59 -08:00
shamoon dd3ec83569 Fix: correct user dropdown button icon styling (#12092) 2026-02-16 07:26:52 -08:00
shamoon 7a23356898 Merge branch 'release/v2.20.x' 2026-02-16 07:24:37 -08:00
shamoon afaf39e43a Fix/GHSA-x395-6h48-wr8v 2026-02-16 00:02:15 -08:00
shamoon 0dc58cf686 Update GitHub Pages artifact naming in CI workflow 2026-02-06 11:54:43 -08:00
shamoon 505ff31748 Update CI workflow with additional permissions
Add permissions for contents, pages, and id-token.
2026-02-06 10:04:51 -08:00
shamoon 3c51b3f9cd Chore: move to Zensical for docs (#12011) 2026-02-06 08:34:15 -08:00
shamoon 5b45b89d35 Performance fix: use subqueries to improve object retrieval in large installs (#11950) 2026-02-05 08:46:32 -08:00
Jason Lingohrandshamoon 1188a89369 Documentation: update FAQ about file extension handling (#12000)
---------

Co-authored-by: shamoon <4887959+shamoon@users.noreply.github.com>
2026-02-05 02:05:47 +00:00
shamoon 5b9bb147cf Tweakhancement: tweak bulk delete text (#11967) 2026-02-01 12:16:30 -08:00
shamoon c278f52fb2 Fix: fix broken docker create_classifier command in 2.20.6 (#11965) 2026-02-01 12:09:52 -08:00
github-actions[bot] d27a5f688a Changelog v2.20.6 - GHA (#11952)
Co-authored-by: github-actions <41898282+github-actions[bot]@users.noreply.github.com>
2026-01-30 23:34:22 -08:00
shamoon c5bb5b237b Fix commitish config 2026-01-30 23:10:53 -08:00
shamoon 11a5714cba Merge branch 'release/v2.20.x' 2026-01-30 21:51:07 -08:00
shamoon 4363567fa7 Remove commitish 2026-01-30 21:11:25 -08:00
shamoon 3e41d99a82 Bump version to 2.20.6 2026-01-30 17:59:55 -08:00
shamoon 5cc3c087d9 Security: enforce ownership for permission updates 2026-01-30 13:55:55 -08:00
shamoon c8c4c7c749 Security: enforce permissions for post_document 2026-01-30 12:14:18 -08:00
shamoon 836c81e037 Chore: add note about ordering 2026-01-30 11:49:22 -08:00
shamoon e4b861d76f Fix: prevent note deletion outside doc 2026-01-29 13:35:01 -08:00
shamoon 6913f9d79c Fix: fix user checks in management scripts (#11928) 2026-01-28 13:45:12 -08:00
shamoon 891f4a2faf Fix: correctly extract all ids for nested tags (#11888) 2026-01-26 09:12:03 -08:00
shamoon 2312314aa7 Performance: improve treenode inefficiencies (#11606) 2026-01-25 21:47:08 -08:00
shamoon 72e8b73108 Fix test 2026-01-25 17:08:15 -08:00
shamoon 5c9ff367e3 Fixhancement: change date calculation for 'this year' to include future documents (#11884) 2026-01-25 16:56:51 -08:00
Trenton H 94f6b8d36d Fixes the management scripts under a non-root install where the user ID is something besides 1000 (#11870) 2026-01-23 16:08:28 -08:00
shamoon 32d04e1fd3 Fix: use correct field id for overrides (#11869) 2026-01-23 15:49:22 -08:00
Trenton H 56c744fd56 Fixes the spelling of the commitish argument to the action 2026-01-23 15:49:00 -08:00
shamoon 1f432a3378 Merge branch 'release/v2.20.x' 2026-01-20 16:30:16 -08:00
shamoon d1aa76e4ce Narrow scope of these css rules 2026-01-20 12:30:06 -08:00
shamoon 5381bc5907 Fix: fix tag list horizontal scroll, again (#11839) 2026-01-20 12:30:06 -08:00
shamoon 16cc704539 Merge branch 'release/v2.20.x' 2026-01-19 10:58:39 -08:00
github-actions[bot]andshamoon 245d9fb4a1 Documentation: Add v2.20.5 changelog (#11824)
---------

Co-authored-by: github-actions <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: shamoon <4887959+shamoon@users.noreply.github.com>
2026-01-19 10:57:26 -08:00
shamoon 771f3f150a Bump version to 2.20.5 2026-01-19 09:18:23 -08:00
shamoon ecfeff5054 Chore: reverse migration order (#11813) 2026-01-18 11:21:35 -08:00
shamoon 37477d391e Fix: ensure horizontal scroll for long tag names in list, wrap tags without parent (#11811) 2026-01-18 08:22:01 -08:00
Trenton H 2f1cd31e31 Adds the release-drafter commitish filtering to perhaps generate the release notes better 2026-01-16 07:42:54 -08:00
shamoon 742c136773 Fix: use explicit order field for workflow actions (#11781) 2026-01-16 07:39:00 -08:00
github-actions[bot]andshamoon 71ecdc528e Documentation: Add v2.20.4 changelog (#11772)
---------

Co-authored-by: github-actions <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: shamoon <4887959+shamoon@users.noreply.github.com>
2026-01-13 11:51:37 -08:00
shamoon 00ec8a577b Merge branch 'hotfix/v2.20.4' 2026-01-13 11:45:55 -08:00
Antoine Mérino 65aed2405c Documentation: update notes for DB pool size (#11600) 2025-12-30 13:06:21 -08:00
shamoon 985dc9be31 Documentation: default bool value consistency 2025-12-26 08:17:44 -08:00
github-actions[bot] 305d764805 Changelog v2.20.3 - GHA (#11623) 2025-12-18 08:12:05 -08:00
102 changed files with 3523 additions and 868 deletions

No files matched your search

+2 -2
View File
@@ -116,9 +116,9 @@
}, },
{ {
"label": "Maintenance: Build Documentation", "label": "Maintenance: Build Documentation",
"description": "Build the documentation with MkDocs", "description": "Build the documentation with Zensical",
"type": "shell", "type": "shell",
"command": "uv run mkdocs build --config-file mkdocs.yml && uv run mkdocs serve", "command": "uv run zensical build && uv run zensical serve",
"group": "none", "group": "none",
"presentation": { "presentation": {
"echo": true, "echo": true,
+1 -1
View File
@@ -46,7 +46,7 @@ updates:
patterns: patterns:
- "*pytest*" - "*pytest*"
- "ruff" - "ruff"
- "mkdocs-material" - "zensical"
- "pre-commit*" - "pre-commit*"
# Django & DRF Ecosystem # Django & DRF Ecosystem
django-ecosystem: django-ecosystem:
+1
View File
@@ -44,6 +44,7 @@ include-labels:
- 'notable' - 'notable'
exclude-labels: exclude-labels:
- 'skip-changelog' - 'skip-changelog'
filter-by-commitish: true
category-template: '### $TITLE' category-template: '### $TITLE'
change-template: '- $TITLE @$AUTHOR ([#$NUMBER]($URL))' change-template: '- $TITLE @$AUTHOR ([#$NUMBER]($URL))'
change-title-escapes: '\<*_&#@' change-title-escapes: '\<*_&#@'
+36 -15
View File
@@ -11,6 +11,10 @@ on:
pull_request: pull_request:
branches-ignore: branches-ignore:
- 'translations**' - 'translations**'
permissions:
contents: read
pages: write
id-token: write
env: env:
DEFAULT_UV_VERSION: "0.9.x" DEFAULT_UV_VERSION: "0.9.x"
# This is the default version of Python to use in most steps which aren't specific # This is the default version of Python to use in most steps which aren't specific
@@ -75,11 +79,12 @@ jobs:
- name: Check files - name: Check files
uses: pre-commit/action@v3.0.1 uses: pre-commit/action@v3.0.1
documentation: documentation:
name: "Build & Deploy Documentation" name: "Build Documentation"
runs-on: ubuntu-24.04 runs-on: ubuntu-24.04
needs: needs:
- pre-commit - pre-commit
steps: steps:
- uses: actions/configure-pages@v5
- name: Checkout - name: Checkout
uses: actions/checkout@v6 uses: actions/checkout@v6
- name: Set up Python - name: Set up Python
@@ -102,24 +107,30 @@ jobs:
--python ${{ steps.setup-python.outputs.python-version }} \ --python ${{ steps.setup-python.outputs.python-version }} \
--dev \ --dev \
--frozen \ --frozen \
mkdocs build --config-file ./mkdocs.yml zensical build --clean
- name: Deploy documentation - name: Upload documentation artifact
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
run: |
echo "docs.paperless-ngx.com" > "${{ github.workspace }}/docs/CNAME"
git config --global user.name "${{ github.actor }}"
git config --global user.email "${{ github.actor }}@users.noreply.github.com"
uv run \
--python ${{ steps.setup-python.outputs.python-version }} \
--dev \
--frozen \
mkdocs gh-deploy --force --no-history
- name: Upload artifact
uses: actions/upload-artifact@v5 uses: actions/upload-artifact@v5
with: with:
name: documentation name: documentation
path: site/ path: site/
retention-days: 7 - uses: actions/upload-pages-artifact@v4
with:
path: site
name: github-pages-${{ github.run_id }}-${{ github.run_attempt }}
deploy-documentation:
name: "Deploy Documentation"
runs-on: ubuntu-24.04
needs:
- documentation
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
environment:
name: github-pages
url: ${{ steps.deployment.outputs.page_url }}
steps:
- uses: actions/deploy-pages@v4
id: deployment
with:
artifact_name: github-pages-${{ github.run_id }}-${{ github.run_attempt }}
tests-backend: tests-backend:
name: "Backend Tests (Python ${{ matrix.python-version }})" name: "Backend Tests (Python ${{ matrix.python-version }})"
runs-on: ubuntu-24.04 runs-on: ubuntu-24.04
@@ -348,6 +359,9 @@ jobs:
build-docker-image: build-docker-image:
name: Build Docker image for ${{ github.event_name == 'pull_request' && github.head_ref || github.ref_name }} name: Build Docker image for ${{ github.event_name == 'pull_request' && github.head_ref || github.ref_name }}
runs-on: ubuntu-24.04 runs-on: ubuntu-24.04
permissions:
contents: read
packages: write
if: (github.event_name == 'push' && (startsWith(github.ref, 'refs/heads/feature-') || startsWith(github.ref, 'refs/heads/fix-') || github.ref == 'refs/heads/dev' || github.ref == 'refs/heads/beta' || contains(github.ref, 'beta.rc') || startsWith(github.ref, 'refs/tags/v') || startsWith(github.ref, 'refs/heads/l10n_'))) || (github.event_name == 'pull_request' && (startsWith(github.head_ref, 'feature-') || startsWith(github.head_ref, 'fix-') || github.head_ref == 'dev' || github.head_ref == 'beta' || contains(github.head_ref, 'beta.rc') || startsWith(github.head_ref, 'l10n_'))) if: (github.event_name == 'push' && (startsWith(github.ref, 'refs/heads/feature-') || startsWith(github.ref, 'refs/heads/fix-') || github.ref == 'refs/heads/dev' || github.ref == 'refs/heads/beta' || contains(github.ref, 'beta.rc') || startsWith(github.ref, 'refs/tags/v') || startsWith(github.ref, 'refs/heads/l10n_'))) || (github.event_name == 'pull_request' && (startsWith(github.head_ref, 'feature-') || startsWith(github.head_ref, 'fix-') || github.head_ref == 'dev' || github.head_ref == 'beta' || contains(github.head_ref, 'beta.rc') || startsWith(github.head_ref, 'l10n_')))
concurrency: concurrency:
group: ${{ github.workflow }}-build-docker-image-${{ github.ref_name }} group: ${{ github.workflow }}-build-docker-image-${{ github.ref_name }}
@@ -586,6 +600,8 @@ jobs:
publish-release: publish-release:
name: "Publish Release" name: "Publish Release"
runs-on: ubuntu-24.04 runs-on: ubuntu-24.04
permissions:
contents: write
outputs: outputs:
prerelease: ${{ steps.get_version.outputs.prerelease }} prerelease: ${{ steps.get_version.outputs.prerelease }}
changelog: ${{ steps.create-release.outputs.body }} changelog: ${{ steps.create-release.outputs.body }}
@@ -617,6 +633,7 @@ jobs:
version: ${{ steps.get_version.outputs.version }} version: ${{ steps.get_version.outputs.version }}
prerelease: ${{ steps.get_version.outputs.prerelease }} prerelease: ${{ steps.get_version.outputs.prerelease }}
publish: true # ensures release is not marked as draft publish: true # ensures release is not marked as draft
commitish: main
env: env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Upload release archive - name: Upload release archive
@@ -631,6 +648,10 @@ jobs:
append-changelog: append-changelog:
name: "Append Changelog" name: "Append Changelog"
runs-on: ubuntu-24.04 runs-on: ubuntu-24.04
permissions:
contents: write
pull-requests: write
issues: write
needs: needs:
- publish-release - publish-release
if: needs.publish-release.outputs.prerelease == 'false' if: needs.publish-release.outputs.prerelease == 'false'
+1 -1
View File
@@ -53,7 +53,7 @@ junit.xml
# Django stuff: # Django stuff:
*.log *.log
# MkDocs documentation # Zensical documentation
site/ site/
# PyBuilder # PyBuilder
+4 -1
View File
@@ -5,10 +5,13 @@ set -e
cd "${PAPERLESS_SRC_DIR}" cd "${PAPERLESS_SRC_DIR}"
if [[ $(id -u) == 0 ]]; then if [[ -n "${USER_IS_NON_ROOT}" ]]; then
python3 manage.py management_command "$@"
elif [[ $(id -u) == 0 ]]; then
s6-setuidgid paperless python3 manage.py management_command "$@" s6-setuidgid paperless python3 manage.py management_command "$@"
elif [[ $(id -un) == "paperless" ]]; then elif [[ $(id -un) == "paperless" ]]; then
python3 manage.py management_command "$@" python3 manage.py management_command "$@"
else else
echo "Unknown user." echo "Unknown user."
exit 1
fi fi
@@ -2,6 +2,17 @@
# shellcheck shell=bash # shellcheck shell=bash
declare -r log_prefix="[init-user]" declare -r log_prefix="[init-user]"
# When the container is started as a non-root user (e.g. via `user: 999:999`
# in Docker Compose), usermod/groupmod require root and are meaningless.
# USERMAP_* variables only apply to the root-started path.
if [[ -n "${USER_IS_NON_ROOT}" ]]; then
if [[ -n "${USERMAP_UID}" || -n "${USERMAP_GID}" ]]; then
echo "${log_prefix} WARNING: USERMAP_UID/USERMAP_GID are set but have no effect when the container is started as a non-root user"
fi
echo "${log_prefix} Running as non-root user ($(id --user):$(id --group)), skipping UID/GID remapping"
exit 0
fi
declare -r usermap_original_uid=$(id -u paperless) declare -r usermap_original_uid=$(id -u paperless)
declare -r usermap_original_gid=$(id -g paperless) declare -r usermap_original_gid=$(id -g paperless)
declare -r usermap_new_uid=${USERMAP_UID:-$usermap_original_uid} declare -r usermap_new_uid=${USERMAP_UID:-$usermap_original_uid}
@@ -5,10 +5,13 @@ set -e
cd "${PAPERLESS_SRC_DIR}" cd "${PAPERLESS_SRC_DIR}"
if [[ $(id -u) == 0 ]]; then if [[ -n "${USER_IS_NON_ROOT}" ]]; then
python3 manage.py convert_mariadb_uuid "$@"
elif [[ $(id -u) == 0 ]]; then
s6-setuidgid paperless python3 manage.py convert_mariadb_uuid "$@" s6-setuidgid paperless python3 manage.py convert_mariadb_uuid "$@"
elif [[ $(id -un) == "paperless" ]]; then elif [[ $(id -un) == "paperless" ]]; then
python3 manage.py convert_mariadb_uuid "$@" python3 manage.py convert_mariadb_uuid "$@"
else else
echo "Unknown user." echo "Unknown user."
exit 1
fi fi
+4 -1
View File
@@ -5,10 +5,13 @@ set -e
cd "${PAPERLESS_SRC_DIR}" cd "${PAPERLESS_SRC_DIR}"
if [[ $(id -u) == 0 ]]; then if [[ -n "${USER_IS_NON_ROOT}" ]]; then
python3 manage.py createsuperuser "$@"
elif [[ $(id -u) == 0 ]]; then
s6-setuidgid paperless python3 manage.py createsuperuser "$@" s6-setuidgid paperless python3 manage.py createsuperuser "$@"
elif [[ $(id -un) == "paperless" ]]; then elif [[ $(id -un) == "paperless" ]]; then
python3 manage.py createsuperuser "$@" python3 manage.py createsuperuser "$@"
else else
echo "Unknown user." echo "Unknown user."
exit 1
fi fi
@@ -5,10 +5,13 @@ set -e
cd "${PAPERLESS_SRC_DIR}" cd "${PAPERLESS_SRC_DIR}"
if [[ $(id -u) == 0 ]]; then if [[ -n "${USER_IS_NON_ROOT}" ]]; then
python3 manage.py decrypt_documents "$@"
elif [[ $(id -u) == 0 ]]; then
s6-setuidgid paperless python3 manage.py decrypt_documents "$@" s6-setuidgid paperless python3 manage.py decrypt_documents "$@"
elif [[ $(id -un) == "paperless" ]]; then elif [[ $(id -un) == "paperless" ]]; then
python3 manage.py decrypt_documents "$@" python3 manage.py decrypt_documents "$@"
else else
echo "Unknown user." echo "Unknown user."
exit 1
fi fi
@@ -5,10 +5,13 @@ set -e
cd "${PAPERLESS_SRC_DIR}" cd "${PAPERLESS_SRC_DIR}"
if [[ $(id -u) == 0 ]]; then if [[ -n "${USER_IS_NON_ROOT}" ]]; then
python3 manage.py document_archiver "$@"
elif [[ $(id -u) == 0 ]]; then
s6-setuidgid paperless python3 manage.py document_archiver "$@" s6-setuidgid paperless python3 manage.py document_archiver "$@"
elif [[ $(id -un) == "paperless" ]]; then elif [[ $(id -un) == "paperless" ]]; then
python3 manage.py document_archiver "$@" python3 manage.py document_archiver "$@"
else else
echo "Unknown user." echo "Unknown user."
exit 1
fi fi
@@ -5,10 +5,13 @@ set -e
cd "${PAPERLESS_SRC_DIR}" cd "${PAPERLESS_SRC_DIR}"
if [[ $(id -u) == 0 ]]; then if [[ -n "${USER_IS_NON_ROOT}" ]]; then
python3 manage.py document_create_classifier "$@"
elif [[ $(id -u) == 0 ]]; then
s6-setuidgid paperless python3 manage.py document_create_classifier "$@" s6-setuidgid paperless python3 manage.py document_create_classifier "$@"
elif [[ $(id -un) == "paperless" ]]; then elif [[ $(id -un) == "paperless" ]]; then
python3 manage.py document_create_classifier "$@" python3 manage.py document_create_classifier "$@"
else else
echo "Unknown user." echo "Unknown user."
exit 1
fi fi
@@ -5,10 +5,13 @@ set -e
cd "${PAPERLESS_SRC_DIR}" cd "${PAPERLESS_SRC_DIR}"
if [[ $(id -u) == 0 ]]; then if [[ -n "${USER_IS_NON_ROOT}" ]]; then
python3 manage.py document_exporter "$@"
elif [[ $(id -u) == 0 ]]; then
s6-setuidgid paperless python3 manage.py document_exporter "$@" s6-setuidgid paperless python3 manage.py document_exporter "$@"
elif [[ $(id -un) == "paperless" ]]; then elif [[ $(id -un) == "paperless" ]]; then
python3 manage.py document_exporter "$@" python3 manage.py document_exporter "$@"
else else
echo "Unknown user." echo "Unknown user."
exit 1
fi fi
@@ -5,10 +5,13 @@ set -e
cd "${PAPERLESS_SRC_DIR}" cd "${PAPERLESS_SRC_DIR}"
if [[ $(id -u) == 0 ]]; then if [[ -n "${USER_IS_NON_ROOT}" ]]; then
python3 manage.py document_fuzzy_match "$@"
elif [[ $(id -u) == 0 ]]; then
s6-setuidgid paperless python3 manage.py document_fuzzy_match "$@" s6-setuidgid paperless python3 manage.py document_fuzzy_match "$@"
elif [[ $(id -un) == "paperless" ]]; then elif [[ $(id -un) == "paperless" ]]; then
python3 manage.py document_fuzzy_match "$@" python3 manage.py document_fuzzy_match "$@"
else else
echo "Unknown user." echo "Unknown user."
exit 1
fi fi
@@ -5,10 +5,13 @@ set -e
cd "${PAPERLESS_SRC_DIR}" cd "${PAPERLESS_SRC_DIR}"
if [[ $(id -u) == 0 ]]; then if [[ -n "${USER_IS_NON_ROOT}" ]]; then
python3 manage.py document_importer "$@"
elif [[ $(id -u) == 0 ]]; then
s6-setuidgid paperless python3 manage.py document_importer "$@" s6-setuidgid paperless python3 manage.py document_importer "$@"
elif [[ $(id -un) == "paperless" ]]; then elif [[ $(id -un) == "paperless" ]]; then
python3 manage.py document_importer "$@" python3 manage.py document_importer "$@"
else else
echo "Unknown user." echo "Unknown user."
exit 1
fi fi
+4 -1
View File
@@ -5,10 +5,13 @@ set -e
cd "${PAPERLESS_SRC_DIR}" cd "${PAPERLESS_SRC_DIR}"
if [[ $(id -u) == 0 ]]; then if [[ -n "${USER_IS_NON_ROOT}" ]]; then
python3 manage.py document_index "$@"
elif [[ $(id -u) == 0 ]]; then
s6-setuidgid paperless python3 manage.py document_index "$@" s6-setuidgid paperless python3 manage.py document_index "$@"
elif [[ $(id -un) == "paperless" ]]; then elif [[ $(id -un) == "paperless" ]]; then
python3 manage.py document_index "$@" python3 manage.py document_index "$@"
else else
echo "Unknown user." echo "Unknown user."
exit 1
fi fi
+4 -1
View File
@@ -5,10 +5,13 @@ set -e
cd "${PAPERLESS_SRC_DIR}" cd "${PAPERLESS_SRC_DIR}"
if [[ $(id -u) == 0 ]]; then if [[ -n "${USER_IS_NON_ROOT}" ]]; then
python3 manage.py document_renamer "$@"
elif [[ $(id -u) == 0 ]]; then
s6-setuidgid paperless python3 manage.py document_renamer "$@" s6-setuidgid paperless python3 manage.py document_renamer "$@"
elif [[ $(id -un) == "paperless" ]]; then elif [[ $(id -un) == "paperless" ]]; then
python3 manage.py document_renamer "$@" python3 manage.py document_renamer "$@"
else else
echo "Unknown user." echo "Unknown user."
exit 1
fi fi
@@ -5,10 +5,13 @@ set -e
cd "${PAPERLESS_SRC_DIR}" cd "${PAPERLESS_SRC_DIR}"
if [[ $(id -u) == 0 ]]; then if [[ -n "${USER_IS_NON_ROOT}" ]]; then
python3 manage.py document_retagger "$@"
elif [[ $(id -u) == 0 ]]; then
s6-setuidgid paperless python3 manage.py document_retagger "$@" s6-setuidgid paperless python3 manage.py document_retagger "$@"
elif [[ $(id -un) == "paperless" ]]; then elif [[ $(id -un) == "paperless" ]]; then
python3 manage.py document_retagger "$@" python3 manage.py document_retagger "$@"
else else
echo "Unknown user." echo "Unknown user."
exit 1
fi fi
@@ -5,10 +5,13 @@ set -e
cd "${PAPERLESS_SRC_DIR}" cd "${PAPERLESS_SRC_DIR}"
if [[ $(id -u) == 0 ]]; then if [[ -n "${USER_IS_NON_ROOT}" ]]; then
python3 manage.py document_sanity_checker "$@"
elif [[ $(id -u) == 0 ]]; then
s6-setuidgid paperless python3 manage.py document_sanity_checker "$@" s6-setuidgid paperless python3 manage.py document_sanity_checker "$@"
elif [[ $(id -un) == "paperless" ]]; then elif [[ $(id -un) == "paperless" ]]; then
python3 manage.py document_sanity_checker "$@" python3 manage.py document_sanity_checker "$@"
else else
echo "Unknown user." echo "Unknown user."
exit 1
fi fi
@@ -5,10 +5,13 @@ set -e
cd "${PAPERLESS_SRC_DIR}" cd "${PAPERLESS_SRC_DIR}"
if [[ $(id -u) == 0 ]]; then if [[ -n "${USER_IS_NON_ROOT}" ]]; then
python3 manage.py document_thumbnails "$@"
elif [[ $(id -u) == 0 ]]; then
s6-setuidgid paperless python3 manage.py document_thumbnails "$@" s6-setuidgid paperless python3 manage.py document_thumbnails "$@"
elif [[ $(id -un) == "paperless" ]]; then elif [[ $(id -un) == "paperless" ]]; then
python3 manage.py document_thumbnails "$@" python3 manage.py document_thumbnails "$@"
else else
echo "Unknown user." echo "Unknown user."
exit 1
fi fi
+4 -1
View File
@@ -5,10 +5,13 @@ set -e
cd "${PAPERLESS_SRC_DIR}" cd "${PAPERLESS_SRC_DIR}"
if [[ $(id -u) == 0 ]]; then if [[ -n "${USER_IS_NON_ROOT}" ]]; then
python3 manage.py mail_fetcher "$@"
elif [[ $(id -u) == 0 ]]; then
s6-setuidgid paperless python3 manage.py mail_fetcher "$@" s6-setuidgid paperless python3 manage.py mail_fetcher "$@"
elif [[ $(id -un) == "paperless" ]]; then elif [[ $(id -un) == "paperless" ]]; then
python3 manage.py mail_fetcher "$@" python3 manage.py mail_fetcher "$@"
else else
echo "Unknown user." echo "Unknown user."
exit 1
fi fi
+4 -1
View File
@@ -5,10 +5,13 @@ set -e
cd "${PAPERLESS_SRC_DIR}" cd "${PAPERLESS_SRC_DIR}"
if [[ $(id -u) == 0 ]]; then if [[ -n "${USER_IS_NON_ROOT}" ]]; then
python3 manage.py manage_superuser "$@"
elif [[ $(id -u) == 0 ]]; then
s6-setuidgid paperless python3 manage.py manage_superuser "$@" s6-setuidgid paperless python3 manage.py manage_superuser "$@"
elif [[ $(id -un) == "paperless" ]]; then elif [[ $(id -un) == "paperless" ]]; then
python3 manage.py manage_superuser "$@" python3 manage.py manage_superuser "$@"
else else
echo "Unknown user." echo "Unknown user."
exit 1
fi fi
+4 -1
View File
@@ -5,10 +5,13 @@ set -e
cd "${PAPERLESS_SRC_DIR}" cd "${PAPERLESS_SRC_DIR}"
if [[ $(id -u) == 0 ]]; then if [[ -n "${USER_IS_NON_ROOT}" ]]; then
python3 manage.py prune_audit_logs "$@"
elif [[ $(id -u) == 0 ]]; then
s6-setuidgid paperless python3 manage.py prune_audit_logs "$@" s6-setuidgid paperless python3 manage.py prune_audit_logs "$@"
elif [[ $(id -un) == "paperless" ]]; then elif [[ $(id -un) == "paperless" ]]; then
python3 manage.py prune_audit_logs "$@" python3 manage.py prune_audit_logs "$@"
else else
echo "Unknown user." echo "Unknown user."
exit 1
fi fi
+5 -3
View File
@@ -431,8 +431,10 @@ This allows for complex logic to be included in the format, including [logical s
and [filters](https://jinja.palletsprojects.com/en/3.1.x/templates/#id11) to manipulate the [variables](#filename-format-variables) and [filters](https://jinja.palletsprojects.com/en/3.1.x/templates/#id11) to manipulate the [variables](#filename-format-variables)
provided. The template is provided as a string, potentially multiline, and rendered into a single line. provided. The template is provided as a string, potentially multiline, and rendered into a single line.
In addition, the entire Document instance is available to be utilized in a more advanced way, as well as some variables which only make sense to be accessed In addition, a limited `document` object is available for advanced templates.
with more complex logic. This object includes common metadata fields such as `id`, `pk`, `title`, `content`, `page_count`, `created`, `added`, `modified`, `mime_type`,
`checksum`, `archive_checksum`, `archive_serial_number`, `filename`, `archive_filename`, and `original_filename`.
Related values are available as nested objects with limited fields, for example document.correspondent.name, etc.
#### Custom Jinja2 Filters #### Custom Jinja2 Filters
@@ -759,7 +761,7 @@ MariaDB: `mariadb-tzinfo-to-sql /usr/share/zoneinfo | mariadb -u root mysql -p`
## Barcodes {#barcodes} ## Barcodes {#barcodes}
Paperless is able to utilize barcodes for automatically performing some tasks. Paperless is able to utilize barcodes for automatically performing some tasks. Barcodes are only supported for PDF documents or TIFF, [if enabled](configuration.md#PAPERLESS_CONSUMER_BARCODE_TIFF_SUPPORT).
At this time, the library utilized for detection of barcodes supports the following types: At this time, the library utilized for detection of barcodes supports the following types:
+1 -1
View File
@@ -1,4 +1,4 @@
# The REST API # REST API
Paperless-ngx now ships with a fully-documented REST API and a browsable Paperless-ngx now ships with a fully-documented REST API and a browsable
web interface to explore it. The API browsable interface is available at web interface to explore it. The API browsable interface is available at
+25 -22
View File
@@ -1,13 +1,31 @@
:root > * { :root>* {
--md-primary-fg-color: #17541f; --paperless-green: #17541f;
--md-primary-fg-color--dark: #17541f; --paperless-green-accent: #2b8a38;
--md-primary-fg-color--light: #17541f; --md-primary-fg-color: var(--paperless-green);
--md-accent-fg-color: #2b8a38; --md-primary-fg-color--dark: var(--paperless-green);
--md-primary-fg-color--light: var(--paperless-green-accent);
--md-accent-fg-color: var(--paperless-green-accent);
--md-typeset-a-color: #21652a; --md-typeset-a-color: #21652a;
} }
.md-header,
.md-tabs {
background-color: var(--paperless-green);
color: #fff;
}
.md-tabs__link {
color: rgba(255, 255, 255, 0.82);
}
.md-tabs__link:hover,
.md-tabs__link--active {
color: #fff;
}
[data-md-color-scheme="slate"] { [data-md-color-scheme="slate"] {
--md-hue: 222; --md-hue: 222;
--md-default-bg-color: hsla(var(--md-hue), 15%, 10%, 1);
} }
@media (min-width: 768px) { @media (min-width: 768px) {
@@ -69,8 +87,8 @@ h4 code {
} }
/* Hide config vars from sidebar, toc and move the border on mobile case their hidden */ /* Hide config vars from sidebar, toc and move the border on mobile case their hidden */
.md-nav.md-nav--secondary .md-nav__item .md-nav__link[href*="PAPERLESS_"], .md-nav.md-nav--secondary .md-nav__item:has(> .md-nav__link[href*="PAPERLESS_"]),
.md-nav.md-nav--secondary .md-nav__item .md-nav__link[href*="USERMAP_"] { .md-nav.md-nav--secondary .md-nav__item:has(> .md-nav__link[href*="USERMAP_"]) {
display: none; display: none;
} }
@@ -83,18 +101,3 @@ h4 code {
border-top: .05rem solid var(--md-default-fg-color--lightest); border-top: .05rem solid var(--md-default-fg-color--lightest);
} }
} }
/* Show search shortcut key */
[data-md-toggle="search"]:not(:checked) ~ .md-header .md-search__form::after {
position: absolute;
top: .3rem;
right: .3rem;
display: block;
padding: .1rem .4rem;
color: var(--md-default-fg-color--lighter);
font-weight: bold;
font-size: .8rem;
border: .05rem solid var(--md-default-fg-color--lighter);
border-radius: .1rem;
content: "/";
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 57 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 61 KiB

+228
View File
@@ -1,7 +1,235 @@
# Changelog # Changelog
## paperless-ngx 2.20.14
### Bug Fixes
- Fix: do not submit permissions for non-owners [@shamoon](https://github.com/shamoon) ([#12571](https://github.com/paperless-ngx/paperless-ngx/pull/12571))
- Fix: prevent duplicate parent tag IDs [@shamoon](https://github.com/shamoon) ([#12522](https://github.com/paperless-ngx/paperless-ngx/pull/12522))
- Fix: dont defer tag change application in workflows [@shamoon](https://github.com/shamoon) ([#12478](https://github.com/paperless-ngx/paperless-ngx/pull/12478))
- Fix: limit share link viewset actions [@shamoon](https://github.com/shamoon) ([#12461](https://github.com/paperless-ngx/paperless-ngx/pull/12461))
- Fix: add fallback ordering for documents by id after created [@shamoon](https://github.com/shamoon) ([#12440](https://github.com/paperless-ngx/paperless-ngx/pull/12440))
- Fixhancement: default mail-created correspondent matching to exact [@shamoon](https://github.com/shamoon) ([#12414](https://github.com/paperless-ngx/paperless-ngx/pull/12414))
- Fix: validate date CF value in serializer [@shamoon](https://github.com/shamoon) ([#12410](https://github.com/paperless-ngx/paperless-ngx/pull/12410))
### All App Changes
<details>
<summary>7 changes</summary>
- Fix: do not submit permissions for non-owners [@shamoon](https://github.com/shamoon) ([#12571](https://github.com/paperless-ngx/paperless-ngx/pull/12571))
- Fix: prevent duplicate parent tag IDs [@shamoon](https://github.com/shamoon) ([#12522](https://github.com/paperless-ngx/paperless-ngx/pull/12522))
- Fix: dont defer tag change application in workflows [@shamoon](https://github.com/shamoon) ([#12478](https://github.com/paperless-ngx/paperless-ngx/pull/12478))
- Fix: limit share link viewset actions [@shamoon](https://github.com/shamoon) ([#12461](https://github.com/paperless-ngx/paperless-ngx/pull/12461))
- Fix: add fallback ordering for documents by id after created [@shamoon](https://github.com/shamoon) ([#12440](https://github.com/paperless-ngx/paperless-ngx/pull/12440))
- Fixhancement: default mail-created correspondent matching to exact [@shamoon](https://github.com/shamoon) ([#12414](https://github.com/paperless-ngx/paperless-ngx/pull/12414))
- Fix: validate date CF value in serializer [@shamoon](https://github.com/shamoon) ([#12410](https://github.com/paperless-ngx/paperless-ngx/pull/12410))
</details>
## paperless-ngx 2.20.13
### Bug Fixes
- Fix: suggest corrections only if visible results
- Fix: require view permission for more-like search
- Fix: validate document link targets
- Fix: enforce permissions when attaching accounts to mail rules
## paperless-ngx 2.20.12
### Security
- Resolve [GHSA-96jx-fj7m-qh6x](https://github.com/paperless-ngx/paperless-ngx/security/advisories/GHSA-96jx-fj7m-qh6x)
### Bug Fixes
- Fix: Scope the workflow saves to prevent clobbering filename/archive_filename [@stumpylog](https://github.com/stumpylog) ([#12390](https://github.com/paperless-ngx/paperless-ngx/pull/12390))
- Fix: don't try to usermod/groupmod when non-root + update docs (#<!---->12365) [@stumpylog](https://github.com/stumpylog) ([#12391](https://github.com/paperless-ngx/paperless-ngx/pull/12391))
- Fix: avoid moving files if already moved [@shamoon](https://github.com/shamoon) ([#12389](https://github.com/paperless-ngx/paperless-ngx/pull/12389))
- Fix: remove pagination from document notes api spec [@shamoon](https://github.com/shamoon) ([#12388](https://github.com/paperless-ngx/paperless-ngx/pull/12388))
- Fix: fix file button hover color in dark mode [@shamoon](https://github.com/shamoon) ([#12367](https://github.com/paperless-ngx/paperless-ngx/pull/12367))
- Fixhancement: only offer basic auth for appropriate requests [@shamoon](https://github.com/shamoon) ([#12362](https://github.com/paperless-ngx/paperless-ngx/pull/12362))
### All App Changes
<details>
<summary>5 changes</summary>
- Fix: Scope the workflow saves to prevent clobbering filename/archive_filename [@stumpylog](https://github.com/stumpylog) ([#12390](https://github.com/paperless-ngx/paperless-ngx/pull/12390))
- Fix: avoid moving files if already moved [@shamoon](https://github.com/shamoon) ([#12389](https://github.com/paperless-ngx/paperless-ngx/pull/12389))
- Fix: remove pagination from document notes api spec [@shamoon](https://github.com/shamoon) ([#12388](https://github.com/paperless-ngx/paperless-ngx/pull/12388))
- Fix: fix file button hover color in dark mode [@shamoon](https://github.com/shamoon) ([#12367](https://github.com/paperless-ngx/paperless-ngx/pull/12367))
- Fixhancement: only offer basic auth for appropriate requests [@shamoon](https://github.com/shamoon) ([#12362](https://github.com/paperless-ngx/paperless-ngx/pull/12362))
</details>
## paperless-ngx 2.20.11
### Security
- Resolve [GHSA-59xh-5vwx-4c4q](https://github.com/paperless-ngx/paperless-ngx/security/advisories/GHSA-59xh-5vwx-4c4q)
### Bug Fixes
- Fix: correct dropdown list active color in dark mode [@shamoon](https://github.com/shamoon) ([#12328](https://github.com/paperless-ngx/paperless-ngx/pull/12328))
- Fixhancement: clear descendant selections in dropdown when parent toggled [@shamoon](https://github.com/shamoon) ([#12326](https://github.com/paperless-ngx/paperless-ngx/pull/12326))
- Fix: prevent wrapping with larger amounts of tags on small cards, reset moreTags setting to correct count [@shamoon](https://github.com/shamoon) ([#12302](https://github.com/paperless-ngx/paperless-ngx/pull/12302))
- Fix: prevent stale db filename during workflow actions [@shamoon](https://github.com/shamoon) ([#12289](https://github.com/paperless-ngx/paperless-ngx/pull/12289))
### All App Changes
<details>
<summary>4 changes</summary>
- Fix: correct dropdown list active color in dark mode [@shamoon](https://github.com/shamoon) ([#12328](https://github.com/paperless-ngx/paperless-ngx/pull/12328))
- Fixhancement: clear descendant selections in dropdown when parent toggled [@shamoon](https://github.com/shamoon) ([#12326](https://github.com/paperless-ngx/paperless-ngx/pull/12326))
- Fix: prevent wrapping with larger amounts of tags on small cards, reset moreTags setting to correct count [@shamoon](https://github.com/shamoon) ([#12302](https://github.com/paperless-ngx/paperless-ngx/pull/12302))
- Fix: prevent stale db filename during workflow actions [@shamoon](https://github.com/shamoon) ([#12289](https://github.com/paperless-ngx/paperless-ngx/pull/12289))
</details>
## paperless-ngx 2.20.10
### Bug Fixes
- Fix: support string coercion in filepath jinja templates [@shamoon](https://github.com/shamoon) ([#12244](https://github.com/paperless-ngx/paperless-ngx/pull/12244))
- Fix: apply ordering after annotating tag document count [@shamoon](https://github.com/shamoon) ([#12238](https://github.com/paperless-ngx/paperless-ngx/pull/12238))
- Fix: enforce path limit for db filename fields [@shamoon](https://github.com/shamoon) ([#12235](https://github.com/paperless-ngx/paperless-ngx/pull/12235))
### All App Changes
<details>
<summary>3 changes</summary>
- Fix: support string coercion in filepath jinja templates [@shamoon](https://github.com/shamoon) ([#12244](https://github.com/paperless-ngx/paperless-ngx/pull/12244))
- Fix: apply ordering after annotating tag document count [@shamoon](https://github.com/shamoon) ([#12238](https://github.com/paperless-ngx/paperless-ngx/pull/12238))
- Fix: enforce path limit for db filename fields [@shamoon](https://github.com/shamoon) ([#12235](https://github.com/paperless-ngx/paperless-ngx/pull/12235))
</details>
## paperless-ngx 2.20.9
### Security
- Resolve [GHSA-386h-chg4-cfw9](https://github.com/paperless-ngx/paperless-ngx/security/advisories/GHSA-386h-chg4-cfw9)
### Bug Fixes
- Fixhancement: config option reset [@shamoon](https://github.com/shamoon) ([#12176](https://github.com/paperless-ngx/paperless-ngx/pull/12176))
- Fix: correct page count by separating display vs collection sizes for tags [@shamoon](https://github.com/shamoon) ([#12170](https://github.com/paperless-ngx/paperless-ngx/pull/12170))
### All App Changes
<details>
<summary>2 changes</summary>
- Fixhancement: config option reset [@shamoon](https://github.com/shamoon) ([#12176](https://github.com/paperless-ngx/paperless-ngx/pull/12176))
- Fix: correct page count by separating display vs collection sizes for tags [@shamoon](https://github.com/shamoon) ([#12170](https://github.com/paperless-ngx/paperless-ngx/pull/12170))
</details>
## paperless-ngx 2.20.8
### Security
- Resolve [GHSA-7qqc-wrcw-2fj9](https://github.com/paperless-ngx/paperless-ngx/security/advisories/GHSA-7qqc-wrcw-2fj9)
## paperless-ngx 2.20.7
### Security
- Resolve [GHSA-x395-6h48-wr8v](https://github.com/paperless-ngx/paperless-ngx/security/advisories/GHSA-x395-6h48-wr8v)
### Bug Fixes
- Performance fix: use subqueries to improve object retrieval in large installs [@shamoon](https://github.com/shamoon) ([#11950](https://github.com/paperless-ngx/paperless-ngx/pull/11950))
- Fix: correct user dropdown button icon styling [@shamoon](https://github.com/shamoon) ([#12092](https://github.com/paperless-ngx/paperless-ngx/issues/12092))
- Fix: fix broken docker create_classifier command in 2.20.6 [@shamoon](https://github.com/shamoon) ([#11965](https://github.com/paperless-ngx/paperless-ngx/issues/11965))
### All App Changes
<details>
<summary>3 changes</summary>
- Performance fix: use subqueries to improve object retrieval in large installs [@shamoon](https://github.com/shamoon) ([#11950](https://github.com/paperless-ngx/paperless-ngx/pull/11950))
- Fix: correct user dropdown button icon styling [@shamoon](https://github.com/shamoon) ([#12092](https://github.com/paperless-ngx/paperless-ngx/issues/12092))
- Fix: fix broken docker create_classifier command in 2.20.6 [@shamoon](https://github.com/shamoon) ([#11965](https://github.com/paperless-ngx/paperless-ngx/issues/11965))
</details>
## paperless-ngx 2.20.6
### Security
- Resolve [GHSA-jqwv-hx7q-fxh3](https://github.com/paperless-ngx/paperless-ngx/security/advisories/GHSA-jqwv-hx7q-fxh3) and [GHSA-w47q-3m69-84v8](https://github.com/paperless-ngx/paperless-ngx/security/advisories/GHSA-w47q-3m69-84v8)
### Bug Fixes
- Fix: extract all ids for nested tags [@shamoon](https://github.com/shamoon) ([#11888](https://github.com/paperless-ngx/paperless-ngx/pull/11888))
- Fixhancement: change date calculation for 'this year' to include future documents [@shamoon](https://github.com/shamoon) ([#11884](https://github.com/paperless-ngx/paperless-ngx/pull/11884))
- Fix: Running management scripts under rootless could fail [@stumpylog](https://github.com/stumpylog) ([#11870](https://github.com/paperless-ngx/paperless-ngx/pull/11870))
- Fix: use correct field id for overrides [@shamoon](https://github.com/shamoon) ([#11869](https://github.com/paperless-ngx/paperless-ngx/pull/11869))
### All App Changes
<details>
<summary>3 changes</summary>
- Fix: extract all ids for nested tags [@shamoon](https://github.com/shamoon) ([#11888](https://github.com/paperless-ngx/paperless-ngx/pull/11888))
- Fixhancement: change date calculation for 'this year' to include future documents [@shamoon](https://github.com/shamoon) ([#11884](https://github.com/paperless-ngx/paperless-ngx/pull/11884))
- Fix: use correct field id for overrides [@shamoon](https://github.com/shamoon) ([#11869](https://github.com/paperless-ngx/paperless-ngx/pull/11869))
</details>
## paperless-ngx 2.20.5
### Bug Fixes
- Fix: ensure horizontal scroll for long tag names in list, wrap tags without parent [@shamoon](https://github.com/shamoon) ([#11811](https://github.com/paperless-ngx/paperless-ngx/pull/11811))
- Fix: use explicit order field for workflow actions [@shamoon](https://github.com/shamoon) [@stumpylog](https://github.com/stumpylog) ([#11781](https://github.com/paperless-ngx/paperless-ngx/pull/11781))
### All App Changes
<details>
<summary>2 changes</summary>
- Fix: ensure horizontal scroll for long tag names in list, wrap tags without parent [@shamoon](https://github.com/shamoon) ([#11811](https://github.com/paperless-ngx/paperless-ngx/pull/11811))
- Fix: use explicit order field for workflow actions [@shamoon](https://github.com/shamoon) [@stumpylog](https://github.com/stumpylog) ([#11781](https://github.com/paperless-ngx/paperless-ngx/pull/11781))
</details>
## paperless-ngx 2.20.4
### Security
- Resolve [GHSA-28cf-xvcf-hw6m](https://github.com/paperless-ngx/paperless-ngx/security/advisories/GHSA-28cf-xvcf-hw6m)
### Bug Fixes
- Fix: propagate metadata override created value [@shamoon](https://github.com/shamoon) ([#11659](https://github.com/paperless-ngx/paperless-ngx/pull/11659))
- Fix: support ordering by storage path name [@shamoon](https://github.com/shamoon) ([#11661](https://github.com/paperless-ngx/paperless-ngx/pull/11661))
- Fix: validate cf integer values within PostgreSQL range [@shamoon](https://github.com/shamoon) ([#11666](https://github.com/paperless-ngx/paperless-ngx/pull/11666))
- Fixhancement: add error handling and retry when opening index [@shamoon](https://github.com/shamoon) ([#11731](https://github.com/paperless-ngx/paperless-ngx/pull/11731))
- Fix: fix recurring workflow to respect latest run time [@shamoon](https://github.com/shamoon) ([#11735](https://github.com/paperless-ngx/paperless-ngx/pull/11735))
### All App Changes
<details>
<summary>5 changes</summary>
- Fix: propagate metadata override created value [@shamoon](https://github.com/shamoon) ([#11659](https://github.com/paperless-ngx/paperless-ngx/pull/11659))
- Fix: support ordering by storage path name [@shamoon](https://github.com/shamoon) ([#11661](https://github.com/paperless-ngx/paperless-ngx/pull/11661))
- Fix: validate cf integer values within PostgreSQL range [@shamoon](https://github.com/shamoon) ([#11666](https://github.com/paperless-ngx/paperless-ngx/pull/11666))
- Fixhancement: add error handling and retry when opening index [@shamoon](https://github.com/shamoon) ([#11731](https://github.com/paperless-ngx/paperless-ngx/pull/11731))
- Fix: fix recurring workflow to respect latest run time [@shamoon](https://github.com/shamoon) ([#11735](https://github.com/paperless-ngx/paperless-ngx/pull/11735))
</details>
## paperless-ngx 2.20.3
### Security
- Resolve [GHSA-7cq3-mhxq-w946](https://github.com/paperless-ngx/paperless-ngx/security/advisories/GHSA-7cq3-mhxq-w946)
## paperless-ngx 2.20.2 ## paperless-ngx 2.20.2
### Security
- Resolve [GHSA-6653-vcx4-69mc](https://github.com/paperless-ngx/paperless-ngx/security/advisories/GHSA-6653-vcx4-69mc)
- Resolve [GHSA-24x5-wp64-9fcc](https://github.com/paperless-ngx/paperless-ngx/security/advisories/GHSA-24x5-wp64-9fcc)
### Features / Enhancements ### Features / Enhancements
- Tweakhancement: dim inactive users in users-groups list [@shamoon](https://github.com/shamoon) ([#11537](https://github.com/paperless-ngx/paperless-ngx/pull/11537)) - Tweakhancement: dim inactive users in users-groups list [@shamoon](https://github.com/shamoon) ([#11537](https://github.com/paperless-ngx/paperless-ngx/pull/11537))
+14 -7
View File
@@ -170,11 +170,18 @@ Available options are `postgresql` and `mariadb`.
!!! note !!! note
A small pool is typically sufficient — for example, a size of 4. A pool of 8-10 connections per worker is typically sufficient.
Make sure your PostgreSQL server's max_connections setting is large enough to handle: If you encounter error messages such as `couldn't get a connection`
```(Paperless workers + Celery workers) × pool size + safety margin``` or database connection timeouts, you probably need to increase the pool size.
For example, with 4 Paperless workers and 2 Celery workers, and a pool size of 4:
(4 + 2) × 4 + 10 = 34 connections required. !!! warning
Make sure your PostgreSQL `max_connections` setting is large enough to handle the connection pools:
`(NB_PAPERLESS_WORKERS + NB_CELERY_WORKERS) × POOL_SIZE + SAFETY_MARGIN`. For example, with
4 Paperless workers and 2 Celery workers, and a pool size of 8:``(4 + 2) × 8 + 10 = 58`,
so `max_connections = 60` (or even more) is appropriate.
This assumes only Paperless-ngx connects to your PostgreSQL instance. If you have other applications,
you should increase `max_connections` accordingly.
#### [`PAPERLESS_DB_READ_CACHE_ENABLED=<bool>`](#PAPERLESS_DB_READ_CACHE_ENABLED) {#PAPERLESS_DB_READ_CACHE_ENABLED} #### [`PAPERLESS_DB_READ_CACHE_ENABLED=<bool>`](#PAPERLESS_DB_READ_CACHE_ENABLED) {#PAPERLESS_DB_READ_CACHE_ENABLED}
@@ -1007,7 +1014,7 @@ still perform some basic text pre-processing before matching.
: See also `PAPERLESS_NLTK_DIR`. : See also `PAPERLESS_NLTK_DIR`.
Defaults to 1. Defaults to true, enabling the feature.
#### [`PAPERLESS_DATE_PARSER_LANGUAGES=<lang>`](#PAPERLESS_DATE_PARSER_LANGUAGES) {#PAPERLESS_DATE_PARSER_LANGUAGES} #### [`PAPERLESS_DATE_PARSER_LANGUAGES=<lang>`](#PAPERLESS_DATE_PARSER_LANGUAGES) {#PAPERLESS_DATE_PARSER_LANGUAGES}
@@ -1074,7 +1081,7 @@ valid crontab(5) expression describing when to run.
: Enables compression of the responses from the webserver. : Enables compression of the responses from the webserver.
: Defaults to 1, enabling compression. : Defaults to true, enabling compression.
!!! note !!! note
+4 -4
View File
@@ -338,13 +338,13 @@ LANGUAGES = [
## Building the documentation ## Building the documentation
The documentation is built using material-mkdocs, see their [documentation](https://squidfunk.github.io/mkdocs-material/reference/). The documentation is built using Zensical, see their [documentation](https://zensical.org/docs/).
If you want to build the documentation locally, this is how you do it: If you want to build the documentation locally, this is how you do it:
1. Build the documentation 1. Build the documentation
```bash ```bash
$ uv run mkdocs build --config-file mkdocs.yml $ uv run zensical build
``` ```
_alternatively..._ _alternatively..._
@@ -355,10 +355,10 @@ If you want to build the documentation locally, this is how you do it:
something. something.
```bash ```bash
$ uv run mkdocs serve $ uv run zensical serve
``` ```
## Building the Docker image ## Building the Docker image {#docker_build}
The docker image is primarily built by the GitHub actions workflow, but The docker image is primarily built by the GitHub actions workflow, but
it can be faster when developing to build and tag an image locally. it can be faster when developing to build and tag an image locally.
+8 -2
View File
@@ -1,3 +1,7 @@
---
title: FAQs
---
# Frequently Asked Questions # Frequently Asked Questions
## _What's the general plan for Paperless-ngx?_ ## _What's the general plan for Paperless-ngx?_
@@ -63,8 +67,10 @@ elsewhere. Here are a couple notes about that.
Paperless also supports various Office documents (.docx, .doc, odt, Paperless also supports various Office documents (.docx, .doc, odt,
.ppt, .pptx, .odp, .xls, .xlsx, .ods). .ppt, .pptx, .odp, .xls, .xlsx, .ods).
Paperless-ngx determines the type of a file by inspecting its content. Paperless-ngx determines the type of a file by inspecting its content
The file extensions do not matter. rather than its file extensions. However, files processed via the
consumption directory will be rejected if they have a file extension that
not supported by any of the available parsers.
## _Will paperless-ngx run on Raspberry Pi?_ ## _Will paperless-ngx run on Raspberry Pi?_
+4
View File
@@ -1,3 +1,7 @@
---
title: Home
---
<div class="grid-left" markdown> <div class="grid-left" markdown>
![image](assets/logo_full_black.svg#only-light){.index-logo} ![image](assets/logo_full_black.svg#only-light){.index-logo}
![image](assets/logo_full_white.svg#only-dark){.index-logo} ![image](assets/logo_full_white.svg#only-dark){.index-logo}
+252 -309
View File
@@ -1,52 +1,77 @@
## Installation ---
title: Setup
---
You can go multiple routes to setup and run Paperless: # Installation
- [Use the script to setup a Docker install](#docker_script) !!! tip "Quick Start"
- [Use the Docker compose templates](#docker)
- [Build the Docker image yourself](#docker_build)
- [Install Paperless-ngx directly on your system manually ("bare metal")](#bare_metal)
- A user-maintained list of commercial hosting providers can be found [in the wiki](https://github.com/paperless-ngx/paperless-ngx/wiki/Related-Projects)
The Docker routes are quick & easy. These are the recommended routes.
This configures all the stuff from the above automatically so that it
just works and uses sensible defaults for all configuration options.
Here you find a cheat-sheet for docker beginners: [CLI
Basics](https://www.sehn.tech/refs/devops-with-docker/)
The bare metal route is complicated to setup but makes it easier should
you want to contribute some code back. You need to configure and run the
above mentioned components yourself.
### Use the Installation Script {#docker_script}
Paperless provides an interactive installation script to setup a Docker Compose
installation. The script asks for a couple configuration options, and will then create the
necessary configuration files, pull the docker image, start Paperless-ngx and create your superuser
account. The script essentially automatically performs the steps described in [Docker setup](#docker).
1. Make sure that Docker and Docker Compose are [installed](https://docs.docker.com/engine/install/){:target="\_blank"}.
2. Download and run the installation script:
If you just want Paperless-ngx running quickly, use our installation script:
```shell-session ```shell-session
bash -c "$(curl --location --silent --show-error https://raw.githubusercontent.com/paperless-ngx/paperless-ngx/main/install-paperless-ngx.sh)" bash -c "$(curl --location --silent --show-error https://raw.githubusercontent.com/paperless-ngx/paperless-ngx/main/install-paperless-ngx.sh)"
``` ```
_If piping into a shell directly from the internet makes you nervous, inspect [the script](https://github.com/paperless-ngx/paperless-ngx/blob/main/install-paperless-ngx.sh) first!_
!!! note ## Overview
macOS users will need to install [gnu-sed](https://formulae.brew.sh/formula/gnu-sed) with support Choose the installation route that best fits your setup:
for running as `sed` as well as [wget](https://formulae.brew.sh/formula/wget).
### Use Docker Compose {#docker} | Route | Best for | Effort |
| ----------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------- | ------ |
| [Installation script](#docker_script) | Fastest first-time setup with guided prompts (recommended for most users) | Low |
| [Docker Compose templates](#docker) | Manual control over compose files and settings | Medium |
| [Bare metal](#bare_metal) | Advanced setups, packaging, and development-adjacent workflows | High |
| [Hosted providers (wiki)](https://github.com/paperless-ngx/paperless-ngx/wiki/Related-Projects#hosting-providers) | Managed hosting options maintained by the community &mdash; check details carefully | Varies |
1. Make sure that Docker and Docker Compose are [installed](https://docs.docker.com/engine/install/){:target="\_blank"}. For most users, Docker is the best option. It is faster to set up,
easier to maintain, and ships with sensible defaults.
2. Go to the [/docker/compose directory on the project The bare-metal route gives you more control, but it requires manual
installation and operation of all components. It is usually best suited
for advanced users and contributors.
!!! info
Because [superuser](usage.md#superusers) accounts have full access to all objects and documents, you may want to create a separate user account for daily use,
or "downgrade" your superuser account to a normal user account after setup.
## Installation Script {#docker_script}
Paperless-ngx provides an interactive script for Docker Compose setups.
It asks a few configuration questions, then creates the required files,
pulls the image, starts the containers, and creates your [superuser](usage.md#superusers)
account. In short, it automates the [Docker Compose setup](#docker) described below.
#### Prerequisites
- Docker and Docker Compose must be [installed](https://docs.docker.com/engine/install/){:target="\_blank"}.
- macOS users will need [GNU sed](https://formulae.brew.sh/formula/gnu-sed) with support for running as `sed` as well as [wget](https://formulae.brew.sh/formula/wget).
#### Run the installation script
```shell-session
bash -c "$(curl --location --silent --show-error https://raw.githubusercontent.com/paperless-ngx/paperless-ngx/main/install-paperless-ngx.sh)"
```
#### After installation
Paperless-ngx should be available at `http://127.0.0.1:8000` (or similar,
depending on your configuration) and you will be able to login with the
credentials you provided during the installation script.
## Docker Compose Install {#docker}
#### Prerequisites
- Docker and Docker Compose must be [installed](https://docs.docker.com/engine/install/){:target="\_blank"}.
#### Installation
1. Go to the [/docker/compose directory on the project
page](https://github.com/paperless-ngx/paperless-ngx/tree/main/docker/compose){:target="\_blank"} page](https://github.com/paperless-ngx/paperless-ngx/tree/main/docker/compose){:target="\_blank"}
and download one of the `docker-compose.*.yml` files, depending on which database backend and download one `docker-compose.*.yml` file for your preferred
you want to use. Place the files in a local directory and rename it `docker-compose.yml`. Download the database backend. Save it in a local directory as `docker-compose.yml`.
`docker-compose.env` file and the `.env` file as well in the same directory. Also download `docker-compose.env` and `.env` into that same directory.
If you want to enable optional support for Office and other documents, download a If you want to enable optional support for Office and other documents, download a
file with `-tika` in the file name. file with `-tika` in the file name.
@@ -56,15 +81,16 @@ account. The script essentially automatically performs the steps described in [D
For new installations, it is recommended to use PostgreSQL as the For new installations, it is recommended to use PostgreSQL as the
database backend. database backend.
3. Modify `docker-compose.yml` as needed. For example, you may want to change the paths to the 2. Modify `docker-compose.yml` as needed. For example, you may want to
consumption, media etc. directories to use 'bind mounts'. change the paths for `consume`, `media`, and other directories to
use bind mounts.
Find the line that specifies where to mount the directory, e.g.: Find the line that specifies where to mount the directory, e.g.:
```yaml ```yaml
- ./consume:/usr/src/paperless/consume - ./consume:/usr/src/paperless/consume
``` ```
Replace the part _before_ the colon with a local directory of your choice: Replace the part _before_ the colon with your local directory:
```yaml ```yaml
- /home/jonaswinkler/paperless-inbox:/usr/src/paperless/consume - /home/jonaswinkler/paperless-inbox:/usr/src/paperless/consume
@@ -78,38 +104,15 @@ account. The script essentially automatically performs the steps described in [D
- 8010:8000 - 8010:8000
``` ```
**Rootless** 3. Modify `docker-compose.env` with any configuration options you need.
!!! warning
It is currently not possible to run the container rootless if additional languages are specified via `PAPERLESS_OCR_LANGUAGES`.
If you want to run Paperless as a rootless container, you will need
to do the following in your `docker-compose.yml`:
- set the `user` running the container to map to the `paperless`
user in the container. This value (`user_id` below), should be
the same id that `USERMAP_UID` and `USERMAP_GID` are set to in
the next step. See `USERMAP_UID` and `USERMAP_GID`
[here](configuration.md#docker).
Your entry for Paperless should contain something like:
> ```
> webserver:
> image: ghcr.io/paperless-ngx/paperless-ngx:latest
> user: <user_id>
> ```
4. Modify `docker-compose.env` with any configuration options you'd like.
See the [configuration documentation](configuration.md) for all options. See the [configuration documentation](configuration.md) for all options.
You may also need to set `USERMAP_UID` and `USERMAP_GID` to You may also need to set `USERMAP_UID` and `USERMAP_GID` to
the uid and gid of your user on the host system. Use `id -u` and the UID and GID of your user on the host system. Use `id -u` and
`id -g` to get these. This ensures that both the container and the host `id -g` to get these values. This ensures both the container and the
user have write access to the consumption directory. If your UID host user can write to the consumption directory. If your UID and
and GID on the host system is 1000 (the default for the first normal GID are `1000` (the default for the first normal user on many
user on most systems), it will work out of the box without any systems), this usually works out of the box without
modifications. Run `id "username"` to check. modifications. Run `id "username"` to check.
!!! note !!! note
@@ -118,79 +121,55 @@ account. The script essentially automatically performs the steps described in [D
appending `_FILE` to configuration values. For example [`PAPERLESS_DBUSER`](configuration.md#PAPERLESS_DBUSER) appending `_FILE` to configuration values. For example [`PAPERLESS_DBUSER`](configuration.md#PAPERLESS_DBUSER)
can be set using `PAPERLESS_DBUSER_FILE=/var/run/secrets/password.txt`. can be set using `PAPERLESS_DBUSER_FILE=/var/run/secrets/password.txt`.
!!! warning 4. Run `docker compose pull`. This pulls the image from the GitHub container registry
by default, but you can pull from Docker Hub by changing the `image`
Some file systems such as NFS network shares don't support file
system notifications with `inotify`. When storing the consumption
directory on such a file system, paperless will not pick up new
files with the default configuration. You will need to use
[`PAPERLESS_CONSUMER_POLLING`](configuration.md#PAPERLESS_CONSUMER_POLLING), which will disable inotify. See
[here](configuration.md#polling).
5. Run `docker compose pull`. This will pull the image from the GitHub container registry
by default but you can change the image to pull from Docker Hub by changing the `image`
line to `image: paperlessngx/paperless-ngx:latest`. line to `image: paperlessngx/paperless-ngx:latest`.
6. Run `docker compose up -d`. This will create and start the necessary containers. 5. Run `docker compose up -d`. This will create and start the necessary containers.
7. Congratulations! Your Paperless-ngx instance should now be accessible at `http://127.0.0.1:8000` #### After installation
(or similar, depending on your configuration). When you first access the web interface, you will be
prompted to create a superuser account.
### Build the Docker image yourself {#docker_build} Your Paperless-ngx instance should now be accessible at
`http://127.0.0.1:8000` (or similar, depending on your configuration).
When you first access the web interface, you will be prompted to create
a [superuser](usage.md#superusers) account.
1. Clone the entire repository of paperless: #### Optional Advanced Compose Configurations {#advanced_compose data-toc-label="Advanced Compose Configurations"}
```shell-session **Rootless**
git clone https://github.com/paperless-ngx/paperless-ngx
```
The main branch always reflects the latest stable version. !!! warning
2. Copy one of the `docker/compose/docker-compose.*.yml` to It is not possible to run the container rootless if additional languages are specified via `PAPERLESS_OCR_LANGUAGES`.
`docker-compose.yml` in the root folder, depending on which database
backend you want to use. Copy `docker-compose.env` into the project
root as well.
3. In the `docker-compose.yml` file, find the line that instructs If you want to run Paperless as a rootless container, set `user:` in `docker-compose.yml` to the UID and GID of your host user (use `id -u` and `id -g` to find these values). The container process starts directly as that user with no internal privilege remapping:
Docker Compose to pull the paperless image from Docker Hub:
```yaml ```yaml
webserver: webserver:
image: ghcr.io/paperless-ngx/paperless-ngx:latest image: ghcr.io/paperless-ngx/paperless-ngx:latest
``` user: '1000:1000'
```
and replace it with a line that instructs Docker Compose to build Do not combine this with `USERMAP_UID` or `USERMAP_GID`, which are intended for the non-rootless case described in step 3.
the image from the current working directory instead:
```yaml **File systems without inotify support (e.g. NFS)**
webserver:
build:
context: .
```
4. Follow the [Docker setup](#docker) above except when asked to run Some file systems, such as NFS network shares, don't support file system
`docker compose pull` to pull the image, run notifications with `inotify`. When the consumption directory is on such a
file system, Paperless-ngx will not pick up new files with the default
configuration. Use [`PAPERLESS_CONSUMER_POLLING`](configuration.md#PAPERLESS_CONSUMER_POLLING)
to enable polling and disable inotify. See [here](configuration.md#polling).
```shell-session ## Bare Metal Install {#bare_metal}
docker compose build
```
instead to build the image. #### Prerequisites
### Bare Metal Route {#bare_metal} - Paperless runs on Linux only, Windows is not supported.
- Python 3 is required with versions 3.10 - 3.12 currently supported. Newer versions may work, but some dependencies may not be fully compatible.
Paperless runs on linux only. The following procedure has been tested on #### Installation
a minimal installation of Debian/Buster, which is the current stable
release at the time of writing. Windows is not and will never be
supported.
Paperless requires Python 3. At this time, 3.10 - 3.12 are tested versions. 1. Install dependencies. Paperless requires the following packages:
Newer versions may work, but some dependencies may not fully support newer versions.
Support for older Python versions may be dropped as they reach end of life or as newer versions
are released, dependency support is confirmed, etc.
1. Install dependencies. Paperless requires the following packages.
- `python3` - `python3`
- `python3-pip` - `python3-pip`
@@ -254,8 +233,8 @@ are released, dependency support is confirmed, etc.
2. Install `redis` >= 6.0 and configure it to start automatically. 2. Install `redis` >= 6.0 and configure it to start automatically.
3. Optional. Install `postgresql` and configure a database, user and 3. Optional: Install `postgresql` and configure a database, user, and
password for paperless. If you do not wish to use PostgreSQL, password for Paperless-ngx. If you do not wish to use PostgreSQL,
MariaDB and SQLite are available as well. MariaDB and SQLite are available as well.
!!! note !!! note
@@ -264,61 +243,60 @@ are released, dependency support is confirmed, etc.
extension](https://code.djangoproject.com/wiki/JSON1Extension) is extension](https://code.djangoproject.com/wiki/JSON1Extension) is
enabled. This is usually the case, but not always. enabled. This is usually the case, but not always.
4. Create a system user with a new home folder under which you wish 4. Create a system user with a new home folder in which you want
to run paperless. to run Paperless-ngx.
```shell-session ```shell-session
adduser paperless --system --home /opt/paperless --group adduser paperless --system --home /opt/paperless --group
``` ```
5. Get the release archive from 5. Download a release archive from
<https://github.com/paperless-ngx/paperless-ngx/releases> for example with <https://github.com/paperless-ngx/paperless-ngx/releases>. For example:
```shell-session ```shell-session
curl -O -L https://github.com/paperless-ngx/paperless-ngx/releases/download/v1.10.2/paperless-ngx-v1.10.2.tar.xz curl -O -L https://github.com/paperless-ngx/paperless-ngx/releases/download/vX.Y.Z/paperless-ngx-vX.Y.Z.tar.xz
``` ```
Extract the archive with Extract the archive with
```shell-session ```shell-session
tar -xf paperless-ngx-v1.10.2.tar.xz tar -xf paperless-ngx-vX.Y.Z.tar.xz
``` ```
and copy the contents to the and copy the contents to the home directory of the user you created
home folder of the user you created before (`/opt/paperless`). earlier (`/opt/paperless`).
Optional: If you cloned the git repo, you will have to Optional: If you cloned the Git repository, you will need to
compile the frontend yourself, see [here](development.md#front-end-development) compile the frontend yourself. See [here](development.md#front-end-development)
and use the `build` step, not `serve`. and use the `build` step, not `serve`.
6. Configure paperless. See [configuration](configuration.md) for details. 6. Configure Paperless-ngx. See [configuration](configuration.md) for details.
Edit the included `paperless.conf` and adjust the settings to your Edit the included `paperless.conf` and adjust the settings to your
needs. Required settings for getting needs. Required settings for getting Paperless-ngx running are:
paperless running are:
- [`PAPERLESS_REDIS`](configuration.md#PAPERLESS_REDIS) should point to your redis server, such as - [`PAPERLESS_REDIS`](configuration.md#PAPERLESS_REDIS) should point to your Redis server, such as
<redis://localhost:6379>. `redis://localhost:6379`.
- [`PAPERLESS_DBENGINE`](configuration.md#PAPERLESS_DBENGINE) optional, and should be one of `postgres`, - [`PAPERLESS_DBENGINE`](configuration.md#PAPERLESS_DBENGINE) is optional, and should be one of `postgres`,
`mariadb`, or `sqlite` `mariadb`, or `sqlite`
- [`PAPERLESS_DBHOST`](configuration.md#PAPERLESS_DBHOST) should be the hostname on which your - [`PAPERLESS_DBHOST`](configuration.md#PAPERLESS_DBHOST) should be the hostname on which your
PostgreSQL server is running. Do not configure this to use PostgreSQL server is running. Do not configure this to use
SQLite instead. Also configure port, database name, user and SQLite instead. Also configure port, database name, user and
password as necessary. password as necessary.
- [`PAPERLESS_CONSUMPTION_DIR`](configuration.md#PAPERLESS_CONSUMPTION_DIR) should point to a folder which - [`PAPERLESS_CONSUMPTION_DIR`](configuration.md#PAPERLESS_CONSUMPTION_DIR) should point to the folder
paperless should watch for documents. You might want to have that Paperless-ngx should watch for incoming documents.
this somewhere else. Likewise, [`PAPERLESS_DATA_DIR`](configuration.md#PAPERLESS_DATA_DIR) and Likewise, [`PAPERLESS_DATA_DIR`](configuration.md#PAPERLESS_DATA_DIR) and
[`PAPERLESS_MEDIA_ROOT`](configuration.md#PAPERLESS_MEDIA_ROOT) define where paperless stores its data. [`PAPERLESS_MEDIA_ROOT`](configuration.md#PAPERLESS_MEDIA_ROOT) define where Paperless-ngx stores its data.
If you like, you can point both to the same directory. If needed, these can point to the same directory.
- [`PAPERLESS_SECRET_KEY`](configuration.md#PAPERLESS_SECRET_KEY) should be a random sequence of - [`PAPERLESS_SECRET_KEY`](configuration.md#PAPERLESS_SECRET_KEY) should be a random sequence of
characters. It's used for authentication. Failure to do so characters. It's used for authentication. Failure to do so
allows third parties to forge authentication credentials. allows third parties to forge authentication credentials.
- [`PAPERLESS_URL`](configuration.md#PAPERLESS_URL) if you are behind a reverse proxy. This should - Set [`PAPERLESS_URL`](configuration.md#PAPERLESS_URL) if you are behind a reverse proxy. This should
point to your domain. Please see point to your domain. Please see
[configuration](configuration.md) for more [configuration](configuration.md) for more
information. information.
Many more adjustments can be made to paperless, especially the OCR You can make many more adjustments, especially for OCR.
part. The following options are recommended for everyone: The following options are recommended for most users:
- Set [`PAPERLESS_OCR_LANGUAGE`](configuration.md#PAPERLESS_OCR_LANGUAGE) to the language most of your - Set [`PAPERLESS_OCR_LANGUAGE`](configuration.md#PAPERLESS_OCR_LANGUAGE) to the language most of your
documents are written in. documents are written in.
@@ -328,15 +306,14 @@ are released, dependency support is confirmed, etc.
Ensure your Redis instance [is secured](https://redis.io/docs/latest/operate/oss_and_stack/management/security/). Ensure your Redis instance [is secured](https://redis.io/docs/latest/operate/oss_and_stack/management/security/).
7. Create the following directories if they are missing: 7. Create the following directories if they do not already exist:
- `/opt/paperless/media` - `/opt/paperless/media`
- `/opt/paperless/data` - `/opt/paperless/data`
- `/opt/paperless/consume` - `/opt/paperless/consume`
Adjust as necessary if you configured different folders. Adjust these paths if you configured different folders.
Ensure that the paperless user has write permissions for every one Then verify that the `paperless` user has write permissions:
of these folders with
```shell-session ```shell-session
ls -l -d /opt/paperless/media ls -l -d /opt/paperless/media
@@ -350,45 +327,44 @@ are released, dependency support is confirmed, etc.
sudo chown paperless:paperless /opt/paperless/consume sudo chown paperless:paperless /opt/paperless/consume
``` ```
8. Install python requirements from the `requirements.txt` file. 8. Install Python dependencies from `requirements.txt`.
```shell-session ```shell-session
sudo -Hu paperless pip3 install -r requirements.txt sudo -Hu paperless pip3 install -r requirements.txt
``` ```
This will install all python dependencies in the home directory of This will install all Python dependencies in the home directory of
the new paperless user. the new paperless user.
!!! tip !!! tip
It is up to you if you wish to use a virtual environment or not for the Python You can use a virtual environment if you prefer. If you do,
dependencies. This is an alternative to the above and may require adjusting you may need to adjust the example scripts for your virtual
the example scripts to utilize the virtual environment paths environment paths.
!!! tip !!! tip
If you use modern Python tooling, such as `uv`, installation will not include If you use modern Python tooling, such as `uv`, installation will not include
dependencies for Postgres or Mariadb. You can select those extras with `--extra <EXTRA>` dependencies for PostgreSQL or MariaDB. You can select those
or all with `--all-extras` extras with `--extra <EXTRA>`, or install all extras with
`--all-extras`.
9. Go to `/opt/paperless/src`, and execute the following command: 9. Go to `/opt/paperless/src` and execute the following command:
```bash ```bash
# This creates the database schema. # This creates the database schema.
sudo -Hu paperless python3 manage.py migrate sudo -Hu paperless python3 manage.py migrate
``` ```
When you first access the web interface you will be prompted to create a superuser account. 10. Optional: Test that Paperless-ngx is working by running
10. Optional: Test that paperless is working by executing
```bash ```bash
# Manually starts the webserver # Manually starts the webserver
sudo -Hu paperless python3 manage.py runserver sudo -Hu paperless python3 manage.py runserver
``` ```
and pointing your browser to http://localhost:8000 if Then point your browser to `http://localhost:8000` if
accessing from the same devices on which paperless is installed. accessing from the same device on which Paperless-ngx is installed.
If accessing from another machine, set up systemd services. You may need If accessing from another machine, set up systemd services. You may need
to set `PAPERLESS_DEBUG=true` in order for the development server to work to set `PAPERLESS_DEBUG=true` in order for the development server to work
normally in your browser. normally in your browser.
@@ -396,23 +372,24 @@ are released, dependency support is confirmed, etc.
!!! warning !!! warning
This is a development server which should not be used in production. This is a development server which should not be used in production.
It is not audited for security and performance is inferior to It is not audited for security, and performance is inferior to
production ready web servers. production-ready web servers.
!!! tip !!! tip
This will not start the consumer. Paperless does this in a separate This will not start the consumer. Paperless does this in a separate
process. process.
11. Setup systemd services to run paperless automatically. You may use 11. Set up systemd services to run Paperless-ngx automatically. You may use
the service definition files included in the `scripts` folder as a the service definition files included in the `scripts` folder as a
starting point. starting point.
Paperless needs the `webserver` script to run the webserver, the Paperless needs:
`consumer` script to watch the input folder, `taskqueue` for the
background workers used to handle things like document consumption - The `webserver` script to run the webserver.
and the `scheduler` script to run tasks such as email checking at - The `consumer` script to watch the input folder.
certain times . - The `taskqueue` script for background workers (document consumption, etc.).
- The `scheduler` script for periodic tasks such as email checking.
!!! note !!! note
@@ -421,9 +398,9 @@ are released, dependency support is confirmed, etc.
`Require=paperless-webserver.socket` in the `webserver` script `Require=paperless-webserver.socket` in the `webserver` script
and configure `granian` to listen on port 80 (set `GRANIAN_PORT`). and configure `granian` to listen on port 80 (set `GRANIAN_PORT`).
These services rely on redis and optionally the database server, but These services rely on Redis and optionally the database server, but
don't need to be started in any particular order. The example files don't need to be started in any particular order. The example files
depend on redis being started. If you use a database server, you depend on Redis being started. If you use a database server, you
should add additional dependencies. should add additional dependencies.
!!! note !!! note
@@ -433,18 +410,15 @@ are released, dependency support is confirmed, etc.
!!! warning !!! warning
If celery won't start (check with If Celery won't start, check
`sudo systemctl status paperless-task-queue.service` for `sudo systemctl status paperless-task-queue.service` for
paperless-task-queue.service and paperless-scheduler.service `paperless-task-queue.service` and `paperless-scheduler.service`.
) you need to change the path in the files. Example: You may need to change the path in the files. Example:
`ExecStart=/opt/paperless/.local/bin/celery --app paperless worker --loglevel INFO` `ExecStart=/opt/paperless/.local/bin/celery --app paperless worker --loglevel INFO`
12. Optional: Install a samba server and make the consumption folder 12. Configure ImageMagick to allow processing of PDF documents. Most
available as a network share.
13. Configure ImageMagick to allow processing of PDF documents. Most
distributions have this disabled by default, since PDF documents can distributions have this disabled by default, since PDF documents can
contain malware. If you don't do this, paperless will fall back to contain malware. If you don't do this, Paperless-ngx will fall back to
Ghostscript for certain steps such as thumbnail generation. Ghostscript for certain steps such as thumbnail generation.
Edit `/etc/ImageMagick-6/policy.xml` and adjust Edit `/etc/ImageMagick-6/policy.xml` and adjust
@@ -459,32 +433,38 @@ are released, dependency support is confirmed, etc.
<policy domain="coder" rights="read|write" pattern="PDF" /> <policy domain="coder" rights="read|write" pattern="PDF" />
``` ```
14. Optional: Install the **Optional: Install the [jbig2enc](https://ocrmypdf.readthedocs.io/en/latest/jbig2.html) encoder.**
[jbig2enc](https://ocrmypdf.readthedocs.io/en/latest/jbig2.html) This will reduce the size of generated PDF documents. You'll most likely need to compile this yourself, because this
encoder. This will reduce the size of generated PDF documents. software has been patented until around 2017 and binary packages are not available for most distributions.
You'll most likely need to compile this by yourself, because this
software has been patented until around 2017 and binary packages are
not available for most distributions.
15. Optional: If using the NLTK machine learning processing (see **Optional: download the NLTK data**
[`PAPERLESS_ENABLE_NLTK`](configuration.md#PAPERLESS_ENABLE_NLTK) for details), If using the NLTK machine-learning processing (see [`PAPERLESS_ENABLE_NLTK`](configuration.md#PAPERLESS_ENABLE_NLTK) for details),
download the NLTK data for the Snowball download the NLTK data for the Snowball Stemmer, Stopwords and Punkt tokenizer to `/usr/share/nltk_data`. Refer to the [NLTK
Stemmer, Stopwords and Punkt tokenizer to `/usr/share/nltk_data`. Refer to the [NLTK instructions](https://www.nltk.org/data.html) for details on how to download the data.
instructions](https://www.nltk.org/data.html) for details on how to
download the data.
# Migrating to Paperless-ngx #### After installation
Migration is possible both from Paperless-ng or directly from the Your Paperless-ngx instance should now be accessible at `http://localhost:8000` (or similar, depending on your configuration).
'original' Paperless. When you first access the web interface you will be prompted to create a [superuser](usage.md#superusers) account.
## Migrating from Paperless-ng ## Build the Docker image yourself {#docker_build data-toc-label="Building the Docker image"}
Paperless-ngx is meant to be a drop-in replacement for Paperless-ng and Building the Docker image yourself is typically used for development, but it can also be used for production
thus upgrading should be trivial for most users, especially when using if you want to customize the image. See [Building the Docker image](development.md#docker_build) in the
docker. However, as with any major change, it is recommended to take a development documentation.
## Migrating to Paperless-ngx
You can migrate to Paperless-ngx from Paperless-ng or from the original
Paperless project.
<h3 id="migration_ng">Migrating from Paperless-ng</h3>
Paperless-ngx is meant to be a drop-in replacement for Paperless-ng, and
upgrading should be trivial for most users, especially when using
Docker. However, as with any major change, it is recommended to take a
full backup first. Once you are ready, simply change the docker image to full backup first. Once you are ready, simply change the docker image to
point to the new source. E.g. if using Docker Compose, edit point to the new source. For example, if using Docker Compose, edit
`docker-compose.yml` and change: `docker-compose.yml` and change:
``` ```
@@ -497,66 +477,64 @@ to
image: ghcr.io/paperless-ngx/paperless-ngx:latest image: ghcr.io/paperless-ngx/paperless-ngx:latest
``` ```
and then run `docker compose up -d` which will pull the new image and then run `docker compose up -d`, which will pull the new image and
recreate the container. That's it! recreate the container. That's it.
Users who installed with the bare-metal route should also update their Users who installed with the bare-metal route should also update their
Git clone to point to `https://github.com/paperless-ngx/paperless-ngx`, Git clone to point to `https://github.com/paperless-ngx/paperless-ngx`,
e.g. using the command for example using:
`git remote set-url origin https://github.com/paperless-ngx/paperless-ngx` `git remote set-url origin https://github.com/paperless-ngx/paperless-ngx`
and then pull the latest version. and then pull the latest version.
## Migrating from Paperless <h3 id="migration_paperless">Migrating from Paperless</h3>
At its core, paperless-ngx is still paperless and fully compatible. At its core, Paperless-ngx is still Paperless and fully compatible.
However, some things have changed under the hood, so you need to adapt However, some things have changed under the hood, so you need to adapt
your setup depending on how you installed paperless. your setup depending on how you installed Paperless.
This setup describes how to update an existing paperless Docker This section describes how to update an existing Paperless Docker
installation. The important things to keep in mind are as follows: installation. Keep these points in mind:
- Read the [changelog](changelog.md) and - Read the [changelog](changelog.md) and
take note of breaking changes. take note of breaking changes.
- You should decide if you want to stick with SQLite or want to - Decide whether to stay on SQLite or migrate to PostgreSQL.
migrate your database to PostgreSQL. See [documentation](#sqlite_to_psql) Both work fine with Paperless-ngx.
for details on However, if you already have a database server running
how to move your data from SQLite to PostgreSQL. Both work fine with for other services, you might as well use it for Paperless as well.
paperless. However, if you already have a database server running - The task scheduler of Paperless, which is used to execute periodic
for other services, you might as well use it for paperless as well.
- The task scheduler of paperless, which is used to execute periodic
tasks such as email checking and maintenance, requires a tasks such as email checking and maintenance, requires a
[redis](https://redis.io/) message broker instance. The [Redis](https://redis.io/) message broker instance. The
Docker Compose route takes care of that. Docker Compose route takes care of that.
- The layout of the folder structure for your documents and data - The layout of the folder structure for your documents and data
remains the same, so you can just plug your old docker volumes into remains the same, so you can plug your old Docker volumes into
paperless-ngx and expect it to find everything where it should be. paperless-ngx and expect it to find everything where it should be.
Migration to paperless-ngx is then performed in a few simple steps: Migration to Paperless-ngx is then performed in a few simple steps:
1. Stop paperless. 1. Stop Paperless.
```bash ```bash
cd /path/to/current/paperless cd /path/to/current/paperless
docker compose down docker compose down
``` ```
2. Do a backup for two purposes: If something goes wrong, you still 2. Create a backup for two reasons: if something goes wrong, you still
have your data. Second, if you don't like paperless-ngx, you can have your data; and if you don't like paperless-ngx, you can
switch back to paperless. switch back to Paperless.
3. Download the latest release of paperless-ngx. You can either go with 3. Download the latest release of Paperless-ngx. You can either use
the Docker Compose files from the Docker Compose files from
[here](https://github.com/paperless-ngx/paperless-ngx/tree/main/docker/compose) [here](https://github.com/paperless-ngx/paperless-ngx/tree/main/docker/compose)
or clone the repository to build the image yourself (see or clone the repository to build the image yourself (see
[above](#docker_build)). You can [development docs](development.md#docker_build)). You can either replace your current paperless
either replace your current paperless folder or put paperless-ngx in folder or put Paperless-ngx in
a different location. a different location.
!!! warning !!! warning
Paperless-ngx includes a `.env` file. This will set the project name Paperless-ngx includes a `.env` file. This will set the project name
for docker compose to `paperless`, which will also define the name for Docker Compose to `paperless`, which will also define the
of the volumes by paperless-ngx. However, if you experience that volume names created by Paperless-ngx. However, if you notice that
paperless-ngx is not using your old paperless volumes, verify the paperless-ngx is not using your old paperless volumes, verify the
names of your volumes with names of your volumes with
@@ -572,10 +550,10 @@ Migration to paperless-ngx is then performed in a few simple steps:
after you migrated your existing SQLite database. after you migrated your existing SQLite database.
5. Adjust `docker-compose.yml` and `docker-compose.env` to your needs. 5. Adjust `docker-compose.yml` and `docker-compose.env` to your needs.
See [Docker setup](#docker) details on See [Docker setup](#docker) for details on
which edits are advised. which edits are recommended.
6. [Update paperless.](administration.md#updating) 6. Follow the update procedure in [Update paperless](administration.md#updating).
7. In order to find your existing documents with the new search 7. In order to find your existing documents with the new search
feature, you need to invoke a one-time operation that will create feature, you need to invoke a one-time operation that will create
@@ -586,136 +564,99 @@ Migration to paperless-ngx is then performed in a few simple steps:
``` ```
This will migrate your database and create the search index. After This will migrate your database and create the search index. After
that, paperless will take care of maintaining the index by itself. that, Paperless-ngx will maintain the index automatically.
8. Start paperless-ngx. 8. Start Paperless-ngx.
```bash ```bash
docker compose up -d docker compose up -d
``` ```
This will run paperless in the background and automatically start it This will run Paperless-ngx in the background and automatically start it
on system boot. on system boot.
9. Paperless installed a permanent redirect to `admin/` in your 9. Paperless may have installed a permanent redirect to `admin/` in your
browser. This redirect is still in place and prevents access to the browser. This redirect is still in place and prevents access to the
new UI. Clear your browsing cache in order to fix this. new UI. Clear your browser cache to fix this.
10. Optionally, follow the instructions below to migrate your existing 10. Optionally, follow the instructions below to migrate your existing
data to PostgreSQL. data to PostgreSQL.
## Migrating from LinuxServer.io Docker Image <h3 id="migration_lsio">Migrating from LinuxServer.io Docker Image</h3>
As with any upgrades and large changes, it is highly recommended to As with any upgrade or large change, it is highly recommended to
create a backup before starting. This assumes the image was running create a backup before starting. This assumes the image was running
using Docker Compose, but the instructions are translatable to Docker using Docker Compose, but the instructions are translatable to Docker
commands as well. commands as well.
1. Stop and remove the paperless container 1. Stop and remove the Paperless container.
2. If using an external database, stop the container 2. If using an external database, stop that container.
3. Update Redis configuration 3. Update Redis configuration.
1. If `REDIS_URL` is already set, change it to [`PAPERLESS_REDIS`](configuration.md#PAPERLESS_REDIS) 1. If `REDIS_URL` is already set, change it to [`PAPERLESS_REDIS`](configuration.md#PAPERLESS_REDIS)
and continue to step 4. and continue to step 4.
1. Otherwise, in the `docker-compose.yml` add a new service for 1. Otherwise, add a new Redis service in `docker-compose.yml`,
Redis, following [the example compose following [the example compose
files](https://github.com/paperless-ngx/paperless-ngx/tree/main/docker/compose) files](https://github.com/paperless-ngx/paperless-ngx/tree/main/docker/compose)
1. Set the environment variable [`PAPERLESS_REDIS`](configuration.md#PAPERLESS_REDIS) so it points to 1. Set the environment variable [`PAPERLESS_REDIS`](configuration.md#PAPERLESS_REDIS) so it points to
the new Redis container the new Redis container.
4. Update user mapping 4. Update user mapping.
1. If set, change the environment variable `PUID` to `USERMAP_UID` 1. If set, change the environment variable `PUID` to `USERMAP_UID`.
1. If set, change the environment variable `PGID` to `USERMAP_GID` 1. If set, change the environment variable `PGID` to `USERMAP_GID`.
5. Update configuration paths 5. Update configuration paths.
1. Set the environment variable [`PAPERLESS_DATA_DIR`](configuration.md#PAPERLESS_DATA_DIR) to `/config` 1. Set the environment variable [`PAPERLESS_DATA_DIR`](configuration.md#PAPERLESS_DATA_DIR) to `/config`.
6. Update media paths 6. Update media paths.
1. Set the environment variable [`PAPERLESS_MEDIA_ROOT`](configuration.md#PAPERLESS_MEDIA_ROOT) to 1. Set the environment variable [`PAPERLESS_MEDIA_ROOT`](configuration.md#PAPERLESS_MEDIA_ROOT) to
`/data/media` `/data/media`.
7. Update timezone 7. Update timezone.
1. Set the environment variable [`PAPERLESS_TIME_ZONE`](configuration.md#PAPERLESS_TIME_ZONE) to the same 1. Set the environment variable [`PAPERLESS_TIME_ZONE`](configuration.md#PAPERLESS_TIME_ZONE) to the same
value as `TZ` value as `TZ`.
8. Modify the `image:` to point to 8. Modify `image:` to point to
`ghcr.io/paperless-ngx/paperless-ngx:latest` or a specific version `ghcr.io/paperless-ngx/paperless-ngx:latest` or a specific version
if preferred. if preferred.
9. Start the containers as before, using `docker compose`. 9. Start the containers as before, using `docker compose`.
## Moving data from SQLite to PostgreSQL or MySQL/MariaDB {#sqlite_to_psql} ## Running Paperless-ngx on less powerful devices {#less-powerful-devices data-toc-label="Less Powerful Devices"}
The best way to migrate between database types is to perform an [export](administration.md#exporter) and then Paperless runs on Raspberry Pi. Some tasks can be slow on lower-powered
[import](administration.md#importer) into a clean installation of Paperless-ngx. hardware, but a few settings can improve performance:
## Moving back to Paperless
Lets say you migrated to Paperless-ngx and used it for a while, but
decided that you don't like it and want to move back (If you do, send
me a mail about what part you didn't like!), you can totally do that
with a few simple steps.
Paperless-ngx modified the database schema slightly, however, these
changes can be reverted while keeping your current data, so that your
current data will be compatible with original Paperless. Thumbnails
were also changed from PNG to WEBP format and will need to be
re-generated.
Execute this:
```shell-session
$ cd /path/to/paperless
$ docker compose run --rm webserver migrate documents 0023
```
Or without docker:
```shell-session
$ cd /path/to/paperless/src
$ python3 manage.py migrate documents 0023
```
After regenerating thumbnails, you'll need to clear your cookies
(Paperless-ngx comes with updated dependencies that do cookie-processing
differently) and probably your cache as well.
# Considerations for less powerful devices {#less-powerful-devices}
Paperless runs on Raspberry Pi. However, some things are rather slow on
the Pi and configuring some options in paperless can help improve
performance immensely:
- Stick with SQLite to save some resources. See [troubleshooting](troubleshooting.md#log-reports-creating-paperlesstask-failed) - Stick with SQLite to save some resources. See [troubleshooting](troubleshooting.md#log-reports-creating-paperlesstask-failed)
if you encounter issues with SQLite locking. if you encounter issues with SQLite locking.
- If you do not need the filesystem-based consumer, consider disabling it - If you do not need the filesystem-based consumer, consider disabling it
entirely by setting [`PAPERLESS_CONSUMER_DISABLE`](configuration.md#PAPERLESS_CONSUMER_DISABLE) to `true`. entirely by setting [`PAPERLESS_CONSUMER_DISABLE`](configuration.md#PAPERLESS_CONSUMER_DISABLE) to `true`.
- Consider setting [`PAPERLESS_OCR_PAGES`](configuration.md#PAPERLESS_OCR_PAGES) to 1, so that paperless will - Consider setting [`PAPERLESS_OCR_PAGES`](configuration.md#PAPERLESS_OCR_PAGES) to 1, so that Paperless
only OCR the first page of your documents. In most cases, this page OCRs only the first page of your documents. In most cases, this page
contains enough information to be able to find it. contains enough information to be able to find it.
- [`PAPERLESS_TASK_WORKERS`](configuration.md#PAPERLESS_TASK_WORKERS) and [`PAPERLESS_THREADS_PER_WORKER`](configuration.md#PAPERLESS_THREADS_PER_WORKER) are - [`PAPERLESS_TASK_WORKERS`](configuration.md#PAPERLESS_TASK_WORKERS) and [`PAPERLESS_THREADS_PER_WORKER`](configuration.md#PAPERLESS_THREADS_PER_WORKER) are
configured to use all cores. The Raspberry Pi models 3 and up have 4 configured to use all cores. The Raspberry Pi models 3 and up have 4
cores, meaning that paperless will use 2 workers and 2 threads per cores, meaning that Paperless will use 2 workers and 2 threads per
worker. This may result in sluggish response times during worker. This may result in sluggish response times during
consumption, so you might want to lower these settings (example: 2 consumption, so you might want to lower these settings (example: 2
workers and 1 thread to always have some computing power left for workers and 1 thread to always have some computing power left for
other tasks). other tasks).
- Keep [`PAPERLESS_OCR_MODE`](configuration.md#PAPERLESS_OCR_MODE) at its default value `skip` and consider - Keep [`PAPERLESS_OCR_MODE`](configuration.md#PAPERLESS_OCR_MODE) at its default value `skip` and consider
OCR'ing your documents before feeding them into paperless. Some OCRing your documents before feeding them into Paperless. Some
scanners are able to do this! scanners are able to do this!
- Set [`PAPERLESS_OCR_SKIP_ARCHIVE_FILE`](configuration.md#PAPERLESS_OCR_SKIP_ARCHIVE_FILE) to `with_text` to skip archive - Set [`PAPERLESS_OCR_SKIP_ARCHIVE_FILE`](configuration.md#PAPERLESS_OCR_SKIP_ARCHIVE_FILE) to `with_text` to skip archive
file generation for already ocr'ed documents, or `always` to skip it file generation for already OCRed documents, or `always` to skip it
for all documents. for all documents.
- If you want to perform OCR on the device, consider using - If you want to perform OCR on the device, consider using
`PAPERLESS_OCR_CLEAN=none`. This will speed up OCR times and use `PAPERLESS_OCR_CLEAN=none`. This will speed up OCR times and use
less memory at the expense of slightly worse OCR results. less memory at the expense of slightly worse OCR results.
- If using docker, consider setting [`PAPERLESS_WEBSERVER_WORKERS`](configuration.md#PAPERLESS_WEBSERVER_WORKERS) to 1. This will save some memory. - If using Docker, consider setting [`PAPERLESS_WEBSERVER_WORKERS`](configuration.md#PAPERLESS_WEBSERVER_WORKERS) to 1. This will save some memory.
- Consider setting [`PAPERLESS_ENABLE_NLTK`](configuration.md#PAPERLESS_ENABLE_NLTK) to false, to disable the - Consider setting [`PAPERLESS_ENABLE_NLTK`](configuration.md#PAPERLESS_ENABLE_NLTK) to false, to disable the
more advanced language processing, which can take more memory and more advanced language processing, which can take more memory and
processing time. processing time.
@@ -727,17 +668,19 @@ For details, refer to [configuration](configuration.md).
Updating the Updating the
[automatic matching algorithm](advanced_usage.md#automatic-matching) takes quite a bit of time. However, the update mechanism [automatic matching algorithm](advanced_usage.md#automatic-matching) takes quite a bit of time. However, the update mechanism
checks if your data has changed before doing the heavy lifting. If you checks if your data has changed before doing the heavy lifting. If you
experience the algorithm taking too much cpu time, consider changing the experience the algorithm taking too much CPU time, consider changing the
schedule in the admin interface to daily. You can also manually invoke schedule in the admin interface to daily. You can also manually invoke
the task by changing the date and time of the next run to today/now. the task by changing the date and time of the next run to today/now.
The actual matching of the algorithm is fast and works on Raspberry Pi The actual matching of the algorithm is fast and works on Raspberry Pi
as well as on any other device. as well as on any other device.
# Using nginx as a reverse proxy {#nginx} ## Additional considerations
Please see [the wiki](https://github.com/paperless-ngx/paperless-ngx/wiki/Using-a-Reverse-Proxy-with-Paperless-ngx#nginx) for user-maintained documentation of using nginx with Paperless-ngx. **Using a reverse proxy with Paperless-ngx**
# Enhancing security {#security} Please see [the wiki](https://github.com/paperless-ngx/paperless-ngx/wiki/Using-a-Reverse-Proxy-with-Paperless-ngx#nginx) for user-maintained documentation on using nginx with Paperless-ngx.
Please see [the wiki](https://github.com/paperless-ngx/paperless-ngx/wiki/Using-Security-Tools-with-Paperless-ngx) for user-maintained documentation of how to configure security tools like Fail2ban with Paperless-ngx. **Enhancing security**
Please see [the wiki](https://github.com/paperless-ngx/paperless-ngx/wiki/Using-Security-Tools-with-Paperless-ngx) for user-maintained documentation on configuring security tools like Fail2ban with Paperless-ngx.
+14 -3
View File
@@ -1,4 +1,8 @@
# Usage Overview ---
title: Basic Usage
---
# Usage
Paperless-ngx is an application that manages your personal documents. With Paperless-ngx is an application that manages your personal documents. With
the (optional) help of a document scanner (see [the scanners wiki](https://github.com/paperless-ngx/paperless-ngx/wiki/Scanner-&-Software-Recommendations)), Paperless-ngx transforms your unwieldy the (optional) help of a document scanner (see [the scanners wiki](https://github.com/paperless-ngx/paperless-ngx/wiki/Scanner-&-Software-Recommendations)), Paperless-ngx transforms your unwieldy
@@ -344,6 +348,11 @@ permissions can be granted to limit access to certain parts of the UI (and corre
Superusers can access all parts of the front and backend application as well as any and all objects. Superuser status can only be granted by another superuser. Superusers can access all parts of the front and backend application as well as any and all objects. Superuser status can only be granted by another superuser.
!!! tip
Because superuser accounts can see all objects and documents, you may want to use a regular account for day-to-day use. Additional superuser accounts can
be created via [cli](administration.md#create-superuser) or granted superuser status from an existing superuser account.
#### Admin Status #### Admin Status
Admin status (Django 'staff status') grants access to viewing the paperless logs and the system status dialog Admin status (Django 'staff status') grants access to viewing the paperless logs and the system status dialog
@@ -372,7 +381,7 @@ still have "object-level" permissions.
| StoragePath | Add, edit, delete or view Storage Paths. | | StoragePath | Add, edit, delete or view Storage Paths. |
| Tag | Add, edit, delete or view Tags. | | Tag | Add, edit, delete or view Tags. |
| UISettings | Add, edit, delete or view the UI settings that are used by the web app.<br/>:warning: **Users that will access the web UI must be granted at least _View_ permissions.** | | UISettings | Add, edit, delete or view the UI settings that are used by the web app.<br/>:warning: **Users that will access the web UI must be granted at least _View_ permissions.** |
| User | Add, edit, delete or view Users. | | User | Add, edit, delete or view other user accounts via Settings > Users & Groups and `/api/users/`. These permissions are not needed for users to edit their own profile via "My Profile" or `/api/profile/`. |
| Workflow | Add, edit, delete or view Workflows.<br/>Note that Workflows are global; all users who can access workflows see the same set. Workflows have other permission implications — see [Workflow permissions](#workflow-permissions). | | Workflow | Add, edit, delete or view Workflows.<br/>Note that Workflows are global; all users who can access workflows see the same set. Workflows have other permission implications — see [Workflow permissions](#workflow-permissions). |
#### Detailed Explanation of Object Permissions {#object-permissions} #### Detailed Explanation of Object Permissions {#object-permissions}
@@ -383,6 +392,8 @@ still have "object-level" permissions.
| View | Confers the ability to view (not edit) a document, tag, etc.<br/>Users without 'view' (or higher) permissions will be shown _'Private'_ in place of the object name for example when viewing a document with a tag for which the user doesn't have permissions. | | View | Confers the ability to view (not edit) a document, tag, etc.<br/>Users without 'view' (or higher) permissions will be shown _'Private'_ in place of the object name for example when viewing a document with a tag for which the user doesn't have permissions. |
| Edit | Confers the ability to edit (and view) a document, tag, etc. | | Edit | Confers the ability to edit (and view) a document, tag, etc. |
For related metadata such as tags, correspondents, document types, and storage paths, object visibility and document assignment are intentionally distinct. A user may still retain or submit a known object ID when editing a document even if that related object is displayed as _Private_ or omitted from search and selection results. This allows documents to preserve existing assignments that the current user cannot necessarily inspect in detail.
### Password reset ### Password reset
In order to enable the password reset feature you will need to setup an SMTP backend, see In order to enable the password reset feature you will need to setup an SMTP backend, see
@@ -561,7 +572,7 @@ applied. You can use the following placeholders in the template with any trigger
- `{{added_day}}`: added day - `{{added_day}}`: added day
- `{{added_time}}`: added time in HH:MM format - `{{added_time}}`: added time in HH:MM format
- `{{original_filename}}`: original file name without extension - `{{original_filename}}`: original file name without extension
- `{{filename}}`: current file name without extension - `{{filename}}`: current file name without extension (for "added" workflows this may not be final yet, you can use `{{original_filename}}`)
- `{{doc_title}}`: current document title - `{{doc_title}}`: current document title
The following placeholders are only available for "added" or "updated" triggers The following placeholders are only available for "added" or "updated" triggers
-86
View File
@@ -1,86 +0,0 @@
site_name: Paperless-ngx
theme:
name: material
logo: assets/logo.svg
font:
text: Roboto
code: Roboto Mono
palette:
# Palette toggle for automatic mode
- media: "(prefers-color-scheme)"
toggle:
icon: material/brightness-auto
name: Switch to light mode
# Palette toggle for light mode
- media: "(prefers-color-scheme: light)"
scheme: default
toggle:
icon: material/brightness-7
name: Switch to dark mode
# Palette toggle for dark mode
- media: "(prefers-color-scheme: dark)"
scheme: slate
toggle:
icon: material/brightness-4
name: Switch to system preference
features:
- navigation.tabs
- navigation.top
- toc.integrate
- content.code.annotate
icon:
repo: fontawesome/brands/github
favicon: assets/favicon.png
repo_url: https://github.com/paperless-ngx/paperless-ngx
repo_name: paperless-ngx/paperless-ngx
edit_uri: blob/main/docs/
extra_css:
- assets/extra.css
markdown_extensions:
- attr_list
- md_in_html
- def_list
- admonition
- tables
- pymdownx.highlight:
anchor_linenums: true
- pymdownx.superfences
- pymdownx.inlinehilite
- pymdownx.snippets
- pymdownx.tilde
- footnotes
- pymdownx.superfences:
custom_fences:
- name: mermaid
class: mermaid
format: !!python/name:pymdownx.superfences.fence_code_format
- pymdownx.emoji:
emoji_index: !!python/name:material.extensions.emoji.twemoji
emoji_generator: !!python/name:material.extensions.emoji.to_svg
strict: true
nav:
- index.md
- setup.md
- 'Basic Usage': usage.md
- configuration.md
- administration.md
- advanced_usage.md
- 'REST API': api.md
- development.md
- 'FAQs': faq.md
- troubleshooting.md
- changelog.md
copyright: Copyright &copy; 2016 - 2023 Daniel Quinn, Jonas Winkler, and the Paperless-ngx team
extra:
social:
- icon: fontawesome/brands/github
link: https://github.com/paperless-ngx/paperless-ngx
- icon: fontawesome/brands/docker
link: https://hub.docker.com/r/paperlessngx/paperless-ngx
- icon: material/chat
link: https://matrix.to/#/#paperless:matrix.org
plugins:
- search
- glightbox:
skip_classes:
- no-lightbox
+3 -4
View File
@@ -1,6 +1,6 @@
[project] [project]
name = "paperless-ngx" name = "paperless-ngx"
version = "2.20.4" version = "2.20.14"
description = "A community-supported supercharged document management system: scan, index and archive all your physical documents" description = "A community-supported supercharged document management system: scan, index and archive all your physical documents"
readme = "README.md" readme = "README.md"
requires-python = ">=3.10" requires-python = ">=3.10"
@@ -96,8 +96,7 @@ dev = [
] ]
docs = [ docs = [
"mkdocs-glightbox~=0.5.1", "zensical>=0.0.21",
"mkdocs-material~=9.7.0",
] ]
testing = [ testing = [
@@ -238,7 +237,7 @@ lint.isort.force-single-line = true
[tool.codespell] [tool.codespell]
write-changes = true write-changes = true
ignore-words-list = "criterias,afterall,valeu,ureue,equest,ure,assertIn,Oktober" ignore-words-list = "criterias,afterall,valeu,ureue,equest,ure,assertIn,Oktober,commitish"
skip = "src-ui/src/locale/*,src-ui/pnpm-lock.yaml,src-ui/e2e/*,src/paperless_mail/tests/samples/*,src/documents/tests/samples/*,*.po,*.json" skip = "src-ui/src/locale/*,src-ui/pnpm-lock.yaml,src-ui/e2e/*,src/paperless_mail/tests/samples/*,src/documents/tests/samples/*,*.po,*.json"
[tool.pytest.ini_options] [tool.pytest.ini_options]
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "paperless-ngx-ui", "name": "paperless-ngx-ui",
"version": "2.20.4", "version": "2.20.14",
"scripts": { "scripts": {
"preinstall": "npx only-allow pnpm", "preinstall": "npx only-allow pnpm",
"ng": "ng", "ng": "ng",
@@ -19,13 +19,18 @@
<div class="col"> <div class="col">
<div class="card bg-light"> <div class="card bg-light">
<div class="card-body"> <div class="card-body">
<div class="card-title"> <div class="card-title d-flex align-items-center">
<h6> <h6 class="mb-0">
{{option.title}} {{option.title}}
<a class="btn btn-sm btn-link" title="Read the documentation about this setting" i18n-title [href]="getDocsUrl(option.config_key)" target="_blank" referrerpolicy="no-referrer">
<i-bs name="info-circle"></i-bs>
</a>
</h6> </h6>
<a class="btn btn-sm btn-link" title="Read the documentation about this setting" i18n-title [href]="getDocsUrl(option.config_key)" target="_blank" referrerpolicy="no-referrer">
<i-bs name="info-circle"></i-bs>
</a>
@if (isSet(option.key)) {
<button type="button" class="btn btn-sm btn-link text-danger ms-auto pe-0" title="Reset" i18n-title (click)="resetOption(option.key)">
<i-bs class="me-1" name="x"></i-bs><ng-container i18n>Reset</ng-container>
</button>
}
</div> </div>
<div class="mb-n3"> <div class="mb-n3">
@switch (option.type) { @switch (option.type) {
@@ -144,4 +144,18 @@ describe('ConfigComponent', () => {
component.uploadFile(new File([], 'test.png'), 'app_logo') component.uploadFile(new File([], 'test.png'), 'app_logo')
expect(initSpy).toHaveBeenCalled() expect(initSpy).toHaveBeenCalled()
}) })
it('should reset option to null', () => {
component.configForm.patchValue({ output_type: OutputTypeConfig.PDF_A })
expect(component.isSet('output_type')).toBeTruthy()
component.resetOption('output_type')
expect(component.configForm.get('output_type').value).toBeNull()
expect(component.isSet('output_type')).toBeFalsy()
component.configForm.patchValue({ app_title: 'Test Title' })
component.resetOption('app_title')
expect(component.configForm.get('app_title').value).toBeNull()
component.configForm.patchValue({ barcodes_enabled: true })
component.resetOption('barcodes_enabled')
expect(component.configForm.get('barcodes_enabled').value).toBeNull()
})
}) })
@@ -208,4 +208,12 @@ export class ConfigComponent
}, },
}) })
} }
public isSet(key: string): boolean {
return this.configForm.get(key).value != null
}
public resetOption(key: string) {
this.configForm.get(key).setValue(null)
}
} }
@@ -281,7 +281,7 @@ main {
.navbar .dropdown-menu { .navbar .dropdown-menu {
font-size: 0.875rem; // body size font-size: 0.875rem; // body size
a i-bs { a i-bs, button i-bs {
opacity: 0.6; opacity: 0.6;
} }
} }
@@ -26,6 +26,7 @@ import {
} from 'src/app/data/matching-model' } from 'src/app/data/matching-model'
import { Tag } from 'src/app/data/tag' import { Tag } from 'src/app/data/tag'
import { SETTINGS_KEYS } from 'src/app/data/ui-settings' import { SETTINGS_KEYS } from 'src/app/data/ui-settings'
import { PermissionsService } from 'src/app/services/permissions.service'
import { TagService } from 'src/app/services/rest/tag.service' import { TagService } from 'src/app/services/rest/tag.service'
import { UserService } from 'src/app/services/rest/user.service' import { UserService } from 'src/app/services/rest/user.service'
import { SettingsService } from 'src/app/services/settings.service' import { SettingsService } from 'src/app/services/settings.service'
@@ -87,6 +88,7 @@ describe('EditDialogComponent', () => {
let component: TestComponent let component: TestComponent
let fixture: ComponentFixture<TestComponent> let fixture: ComponentFixture<TestComponent>
let tagService: TagService let tagService: TagService
let permissionsService: PermissionsService
let settingsService: SettingsService let settingsService: SettingsService
let activeModal: NgbActiveModal let activeModal: NgbActiveModal
let httpTestingController: HttpTestingController let httpTestingController: HttpTestingController
@@ -118,8 +120,10 @@ describe('EditDialogComponent', () => {
}).compileComponents() }).compileComponents()
tagService = TestBed.inject(TagService) tagService = TestBed.inject(TagService)
permissionsService = TestBed.inject(PermissionsService)
settingsService = TestBed.inject(SettingsService) settingsService = TestBed.inject(SettingsService)
settingsService.currentUser = currentUser settingsService.currentUser = currentUser
permissionsService.initialize([], currentUser as any)
activeModal = TestBed.inject(NgbActiveModal) activeModal = TestBed.inject(NgbActiveModal)
httpTestingController = TestBed.inject(HttpTestingController) httpTestingController = TestBed.inject(HttpTestingController)
@@ -226,6 +230,25 @@ describe('EditDialogComponent', () => {
expect(updateSpy).toHaveBeenCalled() expect(updateSpy).toHaveBeenCalled()
}) })
it('should not submit owner or permissions for non-owner edits', () => {
component.object = tag
component.dialogMode = EditDialogMode.EDIT
component.ngOnInit()
component.objectForm.get('name').setValue('Updated tag')
component.save()
const req = httpTestingController.expectOne(
`${environment.apiBaseUrl}tags/${tag.id}/`
)
expect(req.request.method).toEqual('PUT')
expect(req.request.body.name).toEqual('Updated tag')
expect(req.request.body.owner).toEqual(tag.owner)
expect(req.request.body.set_permissions).toBeUndefined()
req.flush({})
})
it('should create an object on save in edit mode', () => { it('should create an object on save in edit mode', () => {
const createSpy = jest.spyOn(tagService, 'create') const createSpy = jest.spyOn(tagService, 'create')
component.dialogMode = EditDialogMode.CREATE component.dialogMode = EditDialogMode.CREATE
@@ -18,6 +18,7 @@ import { ObjectWithId } from 'src/app/data/object-with-id'
import { ObjectWithPermissions } from 'src/app/data/object-with-permissions' import { ObjectWithPermissions } from 'src/app/data/object-with-permissions'
import { SETTINGS_KEYS } from 'src/app/data/ui-settings' import { SETTINGS_KEYS } from 'src/app/data/ui-settings'
import { User } from 'src/app/data/user' import { User } from 'src/app/data/user'
import { PermissionsService } from 'src/app/services/permissions.service'
import { AbstractPaperlessService } from 'src/app/services/rest/abstract-paperless-service' import { AbstractPaperlessService } from 'src/app/services/rest/abstract-paperless-service'
import { UserService } from 'src/app/services/rest/user.service' import { UserService } from 'src/app/services/rest/user.service'
import { SettingsService } from 'src/app/services/settings.service' import { SettingsService } from 'src/app/services/settings.service'
@@ -42,6 +43,7 @@ export abstract class EditDialogComponent<
protected activeModal = inject(NgbActiveModal) protected activeModal = inject(NgbActiveModal)
protected userService = inject(UserService) protected userService = inject(UserService)
protected settingsService = inject(SettingsService) protected settingsService = inject(SettingsService)
protected permissionsService = inject(PermissionsService)
users: User[] users: User[]
@@ -69,10 +71,6 @@ export abstract class EditDialogComponent<
ngOnInit(): void { ngOnInit(): void {
if (this.object != null && this.dialogMode !== EditDialogMode.CREATE) { if (this.object != null && this.dialogMode !== EditDialogMode.CREATE) {
if ((this.object as ObjectWithPermissions).permissions) {
this.object['set_permissions'] = this.object['permissions']
}
this.object['permissions_form'] = { this.object['permissions_form'] = {
owner: (this.object as ObjectWithPermissions).owner, owner: (this.object as ObjectWithPermissions).owner,
set_permissions: (this.object as ObjectWithPermissions).permissions, set_permissions: (this.object as ObjectWithPermissions).permissions,
@@ -151,18 +149,28 @@ export abstract class EditDialogComponent<
return Object.assign({}, this.objectForm.value) return Object.assign({}, this.objectForm.value)
} }
protected shouldSubmitPermissions(): boolean {
return (
this.dialogMode === EditDialogMode.CREATE ||
this.permissionsService.currentUserOwnsObject(this.object)
)
}
save() { save() {
this.error = null this.error = null
const formValues = this.getFormValues() const formValues = this.getFormValues()
const permissionsObject: PermissionsFormObject = const permissionsObject: PermissionsFormObject =
this.objectForm.get('permissions_form')?.value this.objectForm.get('permissions_form')?.value
if (permissionsObject) { if (permissionsObject && this.shouldSubmitPermissions()) {
formValues.owner = permissionsObject.owner formValues.owner = permissionsObject.owner
formValues.set_permissions = permissionsObject.set_permissions formValues.set_permissions = permissionsObject.set_permissions
delete formValues.permissions_form
} }
delete formValues.permissions_form
var newObject = Object.assign(Object.assign({}, this.object), formValues) var newObject = Object.assign(Object.assign({}, this.object), formValues)
if (!this.shouldSubmitPermissions()) {
delete newObject['set_permissions']
}
var serverResponse: Observable<T> var serverResponse: Observable<T>
switch (this.dialogMode) { switch (this.dialogMode) {
case EditDialogMode.CREATE: case EditDialogMode.CREATE:
@@ -9,7 +9,6 @@ import { first } from 'rxjs'
import { EditDialogComponent } from 'src/app/components/common/edit-dialog/edit-dialog.component' import { EditDialogComponent } from 'src/app/components/common/edit-dialog/edit-dialog.component'
import { Group } from 'src/app/data/group' import { Group } from 'src/app/data/group'
import { User } from 'src/app/data/user' import { User } from 'src/app/data/user'
import { PermissionsService } from 'src/app/services/permissions.service'
import { GroupService } from 'src/app/services/rest/group.service' import { GroupService } from 'src/app/services/rest/group.service'
import { UserService } from 'src/app/services/rest/user.service' import { UserService } from 'src/app/services/rest/user.service'
import { SettingsService } from 'src/app/services/settings.service' import { SettingsService } from 'src/app/services/settings.service'
@@ -39,7 +38,6 @@ export class UserEditDialogComponent
implements OnInit implements OnInit
{ {
private toastService = inject(ToastService) private toastService = inject(ToastService)
private permissionsService = inject(PermissionsService)
private groupsService: GroupService private groupsService: GroupService
groups: Group[] groups: Group[]
@@ -252,7 +252,7 @@ describe('WorkflowEditDialogComponent', () => {
expect(component.object.actions.length).toEqual(2) expect(component.object.actions.length).toEqual(2)
}) })
it('should update order and remove ids from actions on drag n drop', () => { it('should update order on drag n drop', () => {
const action1 = workflow.actions[0] const action1 = workflow.actions[0]
const action2 = workflow.actions[1] const action2 = workflow.actions[1]
component.object = workflow component.object = workflow
@@ -261,8 +261,6 @@ describe('WorkflowEditDialogComponent', () => {
WorkflowAction[] WorkflowAction[]
>) >)
expect(component.object.actions).toEqual([action2, action1]) expect(component.object.actions).toEqual([action2, action1])
expect(action1.id).toBeNull()
expect(action2.id).toBeNull()
}) })
it('should not include auto matching in algorithms', () => { it('should not include auto matching in algorithms', () => {
@@ -1283,11 +1283,6 @@ export class WorkflowEditDialogComponent
const actionField = this.actionFields.at(event.previousIndex) const actionField = this.actionFields.at(event.previousIndex)
this.actionFields.removeAt(event.previousIndex) this.actionFields.removeAt(event.previousIndex)
this.actionFields.insert(event.currentIndex, actionField) this.actionFields.insert(event.currentIndex, actionField)
// removing id will effectively re-create the actions in this order
this.object.actions.forEach((a) => (a.id = null))
this.actionFields.controls.forEach((c) =>
c.get('id').setValue(null, { emitEvent: false })
)
} }
save(): void { save(): void {
@@ -631,6 +631,59 @@ describe('FilterableDropdownComponent & FilterableDropdownSelectionModel', () =>
]) ])
}) })
it('deselecting a parent clears selected descendants', () => {
const root: Tag = { id: 100, name: 'Root Tag' }
const child: Tag = { id: 101, name: 'Child Tag', parent: root.id }
const grandchild: Tag = {
id: 102,
name: 'Grandchild Tag',
parent: child.id,
}
const other: Tag = { id: 103, name: 'Other Tag' }
selectionModel.items = [root, child, grandchild, other]
selectionModel.set(root.id, ToggleableItemState.Selected, false)
selectionModel.set(child.id, ToggleableItemState.Selected, false)
selectionModel.set(grandchild.id, ToggleableItemState.Selected, false)
selectionModel.set(other.id, ToggleableItemState.Selected, false)
selectionModel.toggle(root.id, false)
expect(selectionModel.getSelectedItems()).toEqual([other])
})
it('un-excluding a parent clears excluded descendants', () => {
const root: Tag = { id: 110, name: 'Root Tag' }
const child: Tag = { id: 111, name: 'Child Tag', parent: root.id }
const other: Tag = { id: 112, name: 'Other Tag' }
selectionModel.items = [root, child, other]
selectionModel.set(root.id, ToggleableItemState.Excluded, false)
selectionModel.set(child.id, ToggleableItemState.Excluded, false)
selectionModel.set(other.id, ToggleableItemState.Excluded, false)
selectionModel.exclude(root.id, false)
expect(selectionModel.getExcludedItems()).toEqual([other])
})
it('excluding a selected parent clears selected descendants', () => {
const root: Tag = { id: 120, name: 'Root Tag' }
const child: Tag = { id: 121, name: 'Child Tag', parent: root.id }
const other: Tag = { id: 122, name: 'Other Tag' }
selectionModel.manyToOne = true
selectionModel.items = [root, child, other]
selectionModel.set(root.id, ToggleableItemState.Selected, false)
selectionModel.set(child.id, ToggleableItemState.Selected, false)
selectionModel.set(other.id, ToggleableItemState.Selected, false)
selectionModel.exclude(root.id, false)
expect(selectionModel.getExcludedItems()).toEqual([root])
expect(selectionModel.getSelectedItems()).toEqual([other])
})
it('resorts items immediately when document count sorting enabled', () => { it('resorts items immediately when document count sorting enabled', () => {
const apple: Tag = { id: 55, name: 'Apple' } const apple: Tag = { id: 55, name: 'Apple' }
const zebra: Tag = { id: 56, name: 'Zebra' } const zebra: Tag = { id: 56, name: 'Zebra' }
@@ -231,6 +231,7 @@ export class FilterableDropdownSelectionModel {
state == ToggleableItemState.Excluded state == ToggleableItemState.Excluded
) { ) {
this.temporarySelectionStates.delete(id) this.temporarySelectionStates.delete(id)
this.clearDescendantSelections(id)
} }
if (!id) { if (!id) {
@@ -257,6 +258,7 @@ export class FilterableDropdownSelectionModel {
if (this.manyToOne || this.singleSelect) { if (this.manyToOne || this.singleSelect) {
this.temporarySelectionStates.set(id, ToggleableItemState.Excluded) this.temporarySelectionStates.set(id, ToggleableItemState.Excluded)
this.clearDescendantSelections(id)
if (this.singleSelect) { if (this.singleSelect) {
for (let key of this.temporarySelectionStates.keys()) { for (let key of this.temporarySelectionStates.keys()) {
@@ -277,9 +279,15 @@ export class FilterableDropdownSelectionModel {
newState = ToggleableItemState.NotSelected newState = ToggleableItemState.NotSelected
} }
this.temporarySelectionStates.set(id, newState) this.temporarySelectionStates.set(id, newState)
if (newState == ToggleableItemState.Excluded) {
this.clearDescendantSelections(id)
}
} }
} else if (!id || state == ToggleableItemState.Excluded) { } else if (!id || state == ToggleableItemState.Excluded) {
this.temporarySelectionStates.delete(id) this.temporarySelectionStates.delete(id)
if (id) {
this.clearDescendantSelections(id)
}
} }
if (fireEvent) { if (fireEvent) {
@@ -291,6 +299,33 @@ export class FilterableDropdownSelectionModel {
return this.selectionStates.get(id) || ToggleableItemState.NotSelected return this.selectionStates.get(id) || ToggleableItemState.NotSelected
} }
private clearDescendantSelections(id: number) {
for (const descendantID of this.getDescendantIDs(id)) {
this.temporarySelectionStates.delete(descendantID)
}
}
private getDescendantIDs(id: number): number[] {
const descendants: number[] = []
const queue: number[] = [id]
while (queue.length) {
const parentID = queue.shift()
for (const item of this._items) {
if (
typeof item?.id === 'number' &&
typeof (item as any)['parent'] === 'number' &&
(item as any)['parent'] === parentID
) {
descendants.push(item.id)
queue.push(item.id)
}
}
}
return descendants
}
get logicalOperator(): LogicalOperator { get logicalOperator(): LogicalOperator {
return this.temporaryLogicalOperator return this.temporaryLogicalOperator
} }
@@ -28,7 +28,7 @@
</button> </button>
</ng-template> </ng-template>
<ng-template ng-option-tmp let-item="item" let-index="index" let-search="searchTerm"> <ng-template ng-option-tmp let-item="item" let-index="index" let-search="searchTerm">
<div class="tag-option-row d-flex align-items-center"> <div class="tag-option-row d-flex align-items-center" [class.w-auto]="!getTag(item.id)?.parent">
@if (item.id && tags) { @if (item.id && tags) {
@if (getTag(item.id)?.parent) { @if (getTag(item.id)?.parent) {
<i-bs name="list-nested" class="me-1"></i-bs> <i-bs name="list-nested" class="me-1"></i-bs>
@@ -22,8 +22,8 @@
} }
// Dropdown hierarchy reveal for ng-select options // Dropdown hierarchy reveal for ng-select options
::ng-deep .ng-dropdown-panel .ng-option { :host ::ng-deep .ng-dropdown-panel .ng-option {
overflow-x: scroll; overflow-x: auto !important;
.tag-option-row { .tag-option-row {
font-size: 1rem; font-size: 1rem;
@@ -41,12 +41,12 @@
} }
} }
::ng-deep .ng-dropdown-panel .ng-option:hover .hierarchy-reveal, :host ::ng-deep .ng-dropdown-panel .ng-option:hover .hierarchy-reveal,
::ng-deep .ng-dropdown-panel .ng-option.ng-option-marked .hierarchy-reveal { :host ::ng-deep .ng-dropdown-panel .ng-option.ng-option-marked .hierarchy-reveal {
max-width: 1000px; max-width: 1000px;
} }
::ng-deep .ng-dropdown-panel .ng-option:hover .hierarchy-indicator, ::ng-deep .ng-dropdown-panel .ng-option:hover .hierarchy-indicator,
::ng-deep .ng-dropdown-panel .ng-option.ng-option-marked .hierarchy-indicator { :host ::ng-deep .ng-dropdown-panel .ng-option.ng-option-marked .hierarchy-indicator {
background: transparent; background: transparent;
} }
@@ -205,6 +205,20 @@ describe('TagsComponent', () => {
expect(component.value).toEqual([2, 1]) expect(component.value).toEqual([2, 1])
}) })
it('should not duplicate parents when adding sibling nested tags', () => {
const root: Tag = { id: 1, name: 'root' }
const parent: Tag = { id: 2, name: 'parent', parent: 1 }
const leafA: Tag = { id: 3, name: 'leaf-a', parent: 2 }
const leafB: Tag = { id: 4, name: 'leaf-b', parent: 2 }
component.tags = [root, parent, leafA, leafB]
component.value = []
component.addTag(3)
component.addTag(4)
expect(component.value).toEqual([3, 2, 1, 4])
})
it('should return ancestors from root to parent using getParentChain', () => { it('should return ancestors from root to parent using getParentChain', () => {
const root: Tag = { id: 1, name: 'root' } const root: Tag = { id: 1, name: 'root' }
const mid: Tag = { id: 2, name: 'mid', parent: 1 } const mid: Tag = { id: 2, name: 'mid', parent: 1 }
@@ -153,11 +153,13 @@ export class TagsComponent implements OnInit, ControlValueAccessor {
} }
public onAdd(tag: Tag) { public onAdd(tag: Tag) {
if (tag.parent) { if (tag?.parent) {
// add all parents recursively // add all parents recursively
const parent = this.getTag(tag.parent) const parent = this.getTag(tag.parent)
this.value = [...this.value, parent.id] if (parent && !this.value.includes(parent.id)) {
this.onAdd(parent) this.value = [...this.value, parent.id]
this.onAdd(parent)
}
} }
} }
@@ -15,7 +15,7 @@
} }
@if (document && displayFields?.includes(DisplayField.TAGS)) { @if (document && displayFields?.includes(DisplayField.TAGS)) {
<div class="tags d-flex flex-column text-end position-absolute me-1 fs-6"> <div class="tags d-flex flex-column text-end position-absolute me-1 fs-6" [class.tags-no-wrap]="document.tags.length > 3">
@for (tagID of tagIDs; track tagID) { @for (tagID of tagIDs; track tagID) {
<pngx-tag [tagID]="tagID" (click)="clickTag.emit(tagID);$event.stopPropagation()" [clickable]="true" linkTitle="Toggle tag filter" i18n-linkTitle></pngx-tag> <pngx-tag [tagID]="tagID" (click)="clickTag.emit(tagID);$event.stopPropagation()" [clickable]="true" linkTitle="Toggle tag filter" i18n-linkTitle></pngx-tag>
} }
@@ -72,4 +72,14 @@ a {
max-width: 80%; max-width: 80%;
row-gap: .2rem; row-gap: .2rem;
line-height: 1; line-height: 1;
&.tags-no-wrap {
::ng-deep .badge {
display: inline-block;
max-width: 100%;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
}
} }
@@ -82,6 +82,16 @@ describe('DocumentCardSmallComponent', () => {
).toHaveLength(6) ).toHaveLength(6)
}) })
it('should clear hidden tag counter when tag count falls below the limit', () => {
expect(component.moreTags).toEqual(3)
component.document.tags = [1, 2, 3, 4, 5, 6]
fixture.detectChanges()
expect(component.moreTags).toBeNull()
expect(fixture.nativeElement.textContent).not.toContain('+ 3')
})
it('should try to close the preview on mouse leave', () => { it('should try to close the preview on mouse leave', () => {
component.popupPreview = { component.popupPreview = {
close: jest.fn(), close: jest.fn(),
@@ -126,6 +126,7 @@ export class DocumentCardSmallComponent
this.moreTags = this.document.tags.length - (limit - 1) this.moreTags = this.document.tags.length - (limit - 1)
return this.document.tags.slice(0, limit - 1) return this.document.tags.slice(0, limit - 1)
} else { } else {
this.moreTags = null
return this.document.tags return this.document.tags
} }
} }
@@ -62,9 +62,9 @@
@if (!loading) { @if (!loading) {
<div class="d-flex mb-2"> <div class="d-flex mb-2">
@if (collectionSize > 0) { @if (displayCollectionSize > 0) {
<div> <div>
<ng-container i18n>{collectionSize, plural, =1 {One {{typeName}}} other {{{collectionSize || 0}} total {{typeNamePlural}}}}</ng-container> <ng-container i18n>{displayCollectionSize, plural, =1 {One {{typeName}}} other {{{displayCollectionSize || 0}} total {{typeNamePlural}}}}</ng-container>
@if (selectedObjects.size > 0) { @if (selectedObjects.size > 0) {
&nbsp;({{selectedObjects.size}} selected) &nbsp;({{selectedObjects.size}} selected)
} }
@@ -229,6 +229,22 @@ describe('ManagementListComponent', () => {
expect(reloadSpy).toHaveBeenCalled() expect(reloadSpy).toHaveBeenCalled()
}) })
it('should use API count for pagination and all ids for displayed total', fakeAsync(() => {
jest.spyOn(tagService, 'listFiltered').mockReturnValueOnce(
of({
count: 1,
all: [1, 2, 3],
results: tags.slice(0, 1),
})
)
component.reloadData()
tick(100)
expect(component.collectionSize).toBe(1)
expect(component.displayCollectionSize).toBe(3)
}))
it('should support quick filter for objects', () => { it('should support quick filter for objects', () => {
const qfSpy = jest.spyOn(documentListViewService, 'quickFilter') const qfSpy = jest.spyOn(documentListViewService, 'quickFilter')
const filterButton = fixture.debugElement.queryAll(By.css('button'))[9] const filterButton = fixture.debugElement.queryAll(By.css('button'))[9]
@@ -23,6 +23,7 @@ import {
MatchingModel, MatchingModel,
} from 'src/app/data/matching-model' } from 'src/app/data/matching-model'
import { ObjectWithPermissions } from 'src/app/data/object-with-permissions' import { ObjectWithPermissions } from 'src/app/data/object-with-permissions'
import { Results } from 'src/app/data/results'
import { import {
SortableDirective, SortableDirective,
SortEvent, SortEvent,
@@ -88,6 +89,7 @@ export abstract class ManagementListComponent<T extends MatchingModel>
public page = 1 public page = 1
public collectionSize = 0 public collectionSize = 0
public displayCollectionSize = 0
public sortField: string public sortField: string
public sortReverse: boolean public sortReverse: boolean
@@ -141,6 +143,14 @@ export abstract class ManagementListComponent<T extends MatchingModel>
return data return data
} }
protected getCollectionSize(results: Results<T>): number {
return results.all?.length ?? results.count
}
protected getDisplayCollectionSize(results: Results<T>): number {
return this.getCollectionSize(results)
}
getDocumentCount(object: MatchingModel): number { getDocumentCount(object: MatchingModel): number {
return ( return (
object.document_count ?? object.document_count ??
@@ -171,7 +181,8 @@ export abstract class ManagementListComponent<T extends MatchingModel>
tap((c) => { tap((c) => {
this.unfilteredData = c.results this.unfilteredData = c.results
this.data = this.filterData(c.results) this.data = this.filterData(c.results)
this.collectionSize = c.count this.collectionSize = this.getCollectionSize(c)
this.displayCollectionSize = this.getDisplayCollectionSize(c)
}), }),
delay(100) delay(100)
) )
@@ -364,7 +375,7 @@ export abstract class ManagementListComponent<T extends MatchingModel>
backdrop: 'static', backdrop: 'static',
}) })
modal.componentInstance.title = $localize`Confirm delete` modal.componentInstance.title = $localize`Confirm delete`
modal.componentInstance.messageBold = $localize`This operation will permanently delete all objects.` modal.componentInstance.messageBold = $localize`This operation will permanently delete the selected ${this.typeNamePlural}.`
modal.componentInstance.message = $localize`This operation cannot be undone.` modal.componentInstance.message = $localize`This operation cannot be undone.`
modal.componentInstance.btnClass = 'btn-danger' modal.componentInstance.btnClass = 'btn-danger'
modal.componentInstance.btnCaption = $localize`Proceed` modal.componentInstance.btnCaption = $localize`Proceed`
@@ -51,6 +51,7 @@
@if (displayFields) { @if (displayFields) {
<pngx-input-drag-drop-select i18n-title title="Show" i18n-emptyText emptyText="Default" [items]="displayFields" formControlName="display_fields"></pngx-input-drag-drop-select> <pngx-input-drag-drop-select i18n-title title="Show" i18n-emptyText emptyText="Default" [items]="displayFields" formControlName="display_fields"></pngx-input-drag-drop-select>
} }
<span class="small text-muted fst-italic" i18n>Note: ordering is not preserved</span>
</div> </div>
</div> </div>
</li> </li>
@@ -7,6 +7,7 @@ import {
} from '@ng-bootstrap/ng-bootstrap' } from '@ng-bootstrap/ng-bootstrap'
import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons' import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons'
import { FILTER_HAS_TAGS_ALL } from 'src/app/data/filter-rule-type' import { FILTER_HAS_TAGS_ALL } from 'src/app/data/filter-rule-type'
import { Results } from 'src/app/data/results'
import { Tag } from 'src/app/data/tag' import { Tag } from 'src/app/data/tag'
import { IfPermissionsDirective } from 'src/app/directives/if-permissions.directive' import { IfPermissionsDirective } from 'src/app/directives/if-permissions.directive'
import { SortableDirective } from 'src/app/directives/sortable.directive' import { SortableDirective } from 'src/app/directives/sortable.directive'
@@ -77,6 +78,16 @@ export class TagListComponent extends ManagementListComponent<Tag> {
return data.filter((tag) => !tag.parent || !availableIds.has(tag.parent)) return data.filter((tag) => !tag.parent || !availableIds.has(tag.parent))
} }
protected override getCollectionSize(results: Results<Tag>): number {
// Tag list pages are requested with is_root=true (when unfiltered), so
// pagination must follow root count even though `all` includes descendants
return results.count
}
protected override getDisplayCollectionSize(results: Results<Tag>): number {
return super.getCollectionSize(results)
}
protected override getSelectableIDs(tags: Tag[]): number[] { protected override getSelectableIDs(tags: Tag[]): number[] {
const ids: number[] = [] const ids: number[] = []
for (const tag of tags.filter(Boolean)) { for (const tag of tags.filter(Boolean)) {
@@ -0,0 +1,154 @@
import { HttpErrorResponse, HttpRequest } from '@angular/common/http'
import { TestBed } from '@angular/core/testing'
import { throwError } from 'rxjs'
import * as navUtils from '../utils/navigation'
import { AuthExpiryInterceptor } from './auth-expiry.interceptor'
describe('AuthExpiryInterceptor', () => {
let interceptor: AuthExpiryInterceptor
let dateNowSpy: jest.SpiedFunction<typeof Date.now>
beforeEach(() => {
TestBed.configureTestingModule({
providers: [AuthExpiryInterceptor],
})
interceptor = TestBed.inject(AuthExpiryInterceptor)
dateNowSpy = jest.spyOn(Date, 'now').mockReturnValue(1000)
})
afterEach(() => {
jest.restoreAllMocks()
})
it('reloads when an API request returns 401', () => {
const reloadSpy = jest
.spyOn(navUtils, 'locationReload')
.mockImplementation(() => {})
interceptor
.intercept(new HttpRequest('GET', '/api/documents/'), {
handle: (_request) =>
throwError(
() =>
new HttpErrorResponse({
status: 401,
url: '/api/documents/',
})
),
})
.subscribe({
error: () => undefined,
})
expect(reloadSpy).toHaveBeenCalledTimes(1)
})
it('does not reload for non-401 errors', () => {
const reloadSpy = jest
.spyOn(navUtils, 'locationReload')
.mockImplementation(() => {})
interceptor
.intercept(new HttpRequest('GET', '/api/documents/'), {
handle: (_request) =>
throwError(
() =>
new HttpErrorResponse({
status: 500,
url: '/api/documents/',
})
),
})
.subscribe({
error: () => undefined,
})
expect(reloadSpy).not.toHaveBeenCalled()
})
it('does not reload for non-api 401 responses', () => {
const reloadSpy = jest
.spyOn(navUtils, 'locationReload')
.mockImplementation(() => {})
interceptor
.intercept(new HttpRequest('GET', '/accounts/profile/'), {
handle: (_request) =>
throwError(
() =>
new HttpErrorResponse({
status: 401,
url: '/accounts/profile/',
})
),
})
.subscribe({
error: () => undefined,
})
expect(reloadSpy).not.toHaveBeenCalled()
})
it('reloads only once even with multiple API 401 responses', () => {
const reloadSpy = jest
.spyOn(navUtils, 'locationReload')
.mockImplementation(() => {})
const request = new HttpRequest('GET', '/api/documents/')
const handler = {
handle: (_request) =>
throwError(
() =>
new HttpErrorResponse({
status: 401,
url: '/api/documents/',
})
),
}
interceptor.intercept(request, handler).subscribe({
error: () => undefined,
})
interceptor.intercept(request, handler).subscribe({
error: () => undefined,
})
expect(reloadSpy).toHaveBeenCalledTimes(1)
})
it('retries reload after cooldown for repeated API 401 responses', () => {
const reloadSpy = jest
.spyOn(navUtils, 'locationReload')
.mockImplementation(() => {})
dateNowSpy
.mockReturnValueOnce(1000)
.mockReturnValueOnce(2500)
.mockReturnValueOnce(3501)
const request = new HttpRequest('GET', '/api/documents/')
const handler = {
handle: (_request) =>
throwError(
() =>
new HttpErrorResponse({
status: 401,
url: '/api/documents/',
})
),
}
interceptor.intercept(request, handler).subscribe({
error: () => undefined,
})
interceptor.intercept(request, handler).subscribe({
error: () => undefined,
})
interceptor.intercept(request, handler).subscribe({
error: () => undefined,
})
expect(reloadSpy).toHaveBeenCalledTimes(2)
})
})
@@ -0,0 +1,38 @@
import {
HttpErrorResponse,
HttpEvent,
HttpHandler,
HttpInterceptor,
HttpRequest,
} from '@angular/common/http'
import { Injectable } from '@angular/core'
import { catchError, Observable, throwError } from 'rxjs'
import { locationReload } from '../utils/navigation'
@Injectable()
export class AuthExpiryInterceptor implements HttpInterceptor {
private lastReloadAttempt = Number.NEGATIVE_INFINITY
intercept(
request: HttpRequest<unknown>,
next: HttpHandler
): Observable<HttpEvent<unknown>> {
return next.handle(request).pipe(
catchError((error: unknown) => {
if (
error instanceof HttpErrorResponse &&
error.status === 401 &&
request.url.includes('/api/')
) {
const now = Date.now()
if (now - this.lastReloadAttempt >= 2000) {
this.lastReloadAttempt = now
locationReload()
}
}
return throwError(() => error)
})
)
}
}
+1 -1
View File
@@ -6,7 +6,7 @@ export const environment = {
apiVersion: '9', // match src/paperless/settings.py apiVersion: '9', // match src/paperless/settings.py
appTitle: 'Paperless-ngx', appTitle: 'Paperless-ngx',
tag: 'prod', tag: 'prod',
version: '2.20.4', version: '2.20.14',
webSocketHost: window.location.host, webSocketHost: window.location.host,
webSocketProtocol: window.location.protocol == 'https:' ? 'wss:' : 'ws:', webSocketProtocol: window.location.protocol == 'https:' ? 'wss:' : 'ws:',
webSocketBaseUrl: base_url.pathname + 'ws/', webSocketBaseUrl: base_url.pathname + 'ws/',
+6
View File
@@ -147,6 +147,7 @@ import { DirtyDocGuard } from './app/guards/dirty-doc.guard'
import { DirtySavedViewGuard } from './app/guards/dirty-saved-view.guard' import { DirtySavedViewGuard } from './app/guards/dirty-saved-view.guard'
import { PermissionsGuard } from './app/guards/permissions.guard' import { PermissionsGuard } from './app/guards/permissions.guard'
import { ApiVersionInterceptor } from './app/interceptors/api-version.interceptor' import { ApiVersionInterceptor } from './app/interceptors/api-version.interceptor'
import { AuthExpiryInterceptor } from './app/interceptors/auth-expiry.interceptor'
import { CsrfInterceptor } from './app/interceptors/csrf.interceptor' import { CsrfInterceptor } from './app/interceptors/csrf.interceptor'
import { DocumentTitlePipe } from './app/pipes/document-title.pipe' import { DocumentTitlePipe } from './app/pipes/document-title.pipe'
import { FilterPipe } from './app/pipes/filter.pipe' import { FilterPipe } from './app/pipes/filter.pipe'
@@ -390,6 +391,11 @@ bootstrapApplication(AppComponent, {
useClass: ApiVersionInterceptor, useClass: ApiVersionInterceptor,
multi: true, multi: true,
}, },
{
provide: HTTP_INTERCEPTORS,
useClass: AuthExpiryInterceptor,
multi: true,
},
FilterPipe, FilterPipe,
DocumentTitlePipe, DocumentTitlePipe,
{ provide: NgbDateAdapter, useClass: ISODateAdapter }, { provide: NgbDateAdapter, useClass: ISODateAdapter },
+9
View File
@@ -149,6 +149,15 @@ $form-check-radio-checked-bg-image-dark: url("data:image/svg+xml,<svg xmlns='htt
background-color: var(--pngx-body-color-accent); background-color: var(--pngx-body-color-accent);
} }
.list-group-item-action:not(.active):active {
--bs-list-group-action-active-color: var(--bs-body-color);
--bs-list-group-action-active-bg: var(--pngx-bg-darker);
}
.form-control:hover::file-selector-button {
background-color:var(--pngx-bg-dark) !important
}
.search-container { .search-container {
input, input:focus, i-bs[name="search"] , ::placeholder { input, input:focus, i-bs[name="search"] , ::placeholder {
color: var(--pngx-primary-text-contrast) !important; color: var(--pngx-primary-text-contrast) !important;
+28 -2
View File
@@ -19,6 +19,7 @@ from documents.classifier import load_classifier
from documents.data_models import ConsumableDocument from documents.data_models import ConsumableDocument
from documents.data_models import DocumentMetadataOverrides from documents.data_models import DocumentMetadataOverrides
from documents.file_handling import create_source_path_directory from documents.file_handling import create_source_path_directory
from documents.file_handling import generate_filename
from documents.file_handling import generate_unique_filename from documents.file_handling import generate_unique_filename
from documents.loggers import LoggingMixin from documents.loggers import LoggingMixin
from documents.models import Correspondent from documents.models import Correspondent
@@ -493,7 +494,19 @@ class ConsumerPlugin(
# After everything is in the database, copy the files into # After everything is in the database, copy the files into
# place. If this fails, we'll also rollback the transaction. # place. If this fails, we'll also rollback the transaction.
with FileLock(settings.MEDIA_LOCK): with FileLock(settings.MEDIA_LOCK):
document.filename = generate_unique_filename(document) generated_filename = generate_unique_filename(document)
if (
len(str(generated_filename))
> Document.MAX_STORED_FILENAME_LENGTH
):
self.log.warning(
"Generated source filename exceeds db path limit, falling back to default naming",
)
generated_filename = generate_filename(
document,
use_format=False,
)
document.filename = generated_filename
create_source_path_directory(document.source_path) create_source_path_directory(document.source_path)
self._write( self._write(
@@ -511,10 +524,23 @@ class ConsumerPlugin(
) )
if archive_path and Path(archive_path).is_file(): if archive_path and Path(archive_path).is_file():
document.archive_filename = generate_unique_filename( generated_archive_filename = generate_unique_filename(
document, document,
archive_filename=True, archive_filename=True,
) )
if (
len(str(generated_archive_filename))
> Document.MAX_STORED_FILENAME_LENGTH
):
self.log.warning(
"Generated archive filename exceeds db path limit, falling back to default naming",
)
generated_archive_filename = generate_filename(
document,
archive_filename=True,
use_format=False,
)
document.archive_filename = generated_archive_filename
create_source_path_directory(document.archive_path) create_source_path_directory(document.archive_path)
self._write( self._write(
document.storage_type, document.storage_type,
+1 -1
View File
@@ -115,7 +115,7 @@ class DocumentMetadataOverrides:
).values_list("id", flat=True), ).values_list("id", flat=True),
) )
overrides.custom_fields = { overrides.custom_fields = {
custom_field.id: custom_field.value custom_field.field.id: custom_field.value
for custom_field in doc.custom_fields.all() for custom_field in doc.custom_fields.all()
} }
+11 -7
View File
@@ -128,17 +128,21 @@ def generate_filename(
counter=0, counter=0,
append_gpg=True, append_gpg=True,
archive_filename=False, archive_filename=False,
use_format=True,
) -> Path: ) -> Path:
base_path: Path | None = None base_path: Path | None = None
# Determine the source of the format string # Determine the source of the format string
if doc.storage_path is not None: if use_format:
filename_format = doc.storage_path.path if doc.storage_path is not None:
elif settings.FILENAME_FORMAT is not None: filename_format = doc.storage_path.path
# Maybe convert old to new style elif settings.FILENAME_FORMAT is not None:
filename_format = convert_format_str_to_template_format( # Maybe convert old to new style
settings.FILENAME_FORMAT, filename_format = convert_format_str_to_template_format(
) settings.FILENAME_FORMAT,
)
else:
filename_format = None
else: else:
filename_format = None filename_format = None
+9 -2
View File
@@ -470,7 +470,14 @@ class DelayedFullTextQuery(DelayedQuery):
try: try:
corrected = self.searcher.correct_query(q, q_str) corrected = self.searcher.correct_query(q, q_str)
if corrected.string != q_str: if corrected.string != q_str:
suggested_correction = corrected.string corrected_results = self.searcher.search(
corrected.query,
limit=1,
filter=MappedDocIdSet(self.filter_queryset, self.searcher.ixreader),
scored=False,
)
if len(corrected_results) > 0:
suggested_correction = corrected.string
except Exception as e: except Exception as e:
logger.info( logger.info(
"Error while correcting query %s: %s", "Error while correcting query %s: %s",
@@ -602,7 +609,7 @@ def rewrite_natural_date_keywords(query_string: str) -> str:
case "this year": case "this year":
start = datetime(local_now.year, 1, 1, 0, 0, 0, tzinfo=tz) start = datetime(local_now.year, 1, 1, 0, 0, 0, tzinfo=tz)
end = datetime.combine(today, time.max, tzinfo=tz) end = datetime(local_now.year, 12, 31, 23, 59, 59, tzinfo=tz)
case "previous week": case "previous week":
days_since_monday = local_now.weekday() days_since_monday = local_now.weekday()
@@ -0,0 +1,28 @@
# Generated by Django 5.2.7 on 2026-01-14 16:53
from django.db import migrations
from django.db import models
from django.db.models import F
def populate_action_order(apps, schema_editor):
WorkflowAction = apps.get_model("documents", "WorkflowAction")
WorkflowAction.objects.all().update(order=F("id"))
class Migration(migrations.Migration):
dependencies = [
("documents", "1074_workflowrun_deleted_at_workflowrun_restored_at_and_more"),
]
operations = [
migrations.AddField(
model_name="workflowaction",
name="order",
field=models.PositiveIntegerField(default=0, verbose_name="order"),
),
migrations.RunPython(
populate_action_order,
reverse_code=migrations.RunPython.noop,
),
]
+6 -3
View File
@@ -160,6 +160,7 @@ class Document(SoftDeleteModel, ModelWithOwner):
(STORAGE_TYPE_UNENCRYPTED, _("Unencrypted")), (STORAGE_TYPE_UNENCRYPTED, _("Unencrypted")),
(STORAGE_TYPE_GPG, _("Encrypted with GNU Privacy Guard")), (STORAGE_TYPE_GPG, _("Encrypted with GNU Privacy Guard")),
) )
MAX_STORED_FILENAME_LENGTH: Final[int] = 1024
correspondent = models.ForeignKey( correspondent = models.ForeignKey(
Correspondent, Correspondent,
@@ -267,7 +268,7 @@ class Document(SoftDeleteModel, ModelWithOwner):
filename = models.FilePathField( filename = models.FilePathField(
_("filename"), _("filename"),
max_length=1024, max_length=MAX_STORED_FILENAME_LENGTH,
editable=False, editable=False,
default=None, default=None,
unique=True, unique=True,
@@ -277,7 +278,7 @@ class Document(SoftDeleteModel, ModelWithOwner):
archive_filename = models.FilePathField( archive_filename = models.FilePathField(
_("archive filename"), _("archive filename"),
max_length=1024, max_length=MAX_STORED_FILENAME_LENGTH,
editable=False, editable=False,
default=None, default=None,
unique=True, unique=True,
@@ -287,7 +288,7 @@ class Document(SoftDeleteModel, ModelWithOwner):
original_filename = models.CharField( original_filename = models.CharField(
_("original filename"), _("original filename"),
max_length=1024, max_length=MAX_STORED_FILENAME_LENGTH,
editable=False, editable=False,
default=None, default=None,
unique=False, unique=False,
@@ -1294,6 +1295,8 @@ class WorkflowAction(models.Model):
default=WorkflowActionType.ASSIGNMENT, default=WorkflowActionType.ASSIGNMENT,
) )
order = models.PositiveIntegerField(_("order"), default=0)
assign_title = models.TextField( assign_title = models.TextField(
_("assign title"), _("assign title"),
null=True, null=True,
+89 -9
View File
@@ -2,10 +2,17 @@ from django.contrib.auth.models import Group
from django.contrib.auth.models import Permission from django.contrib.auth.models import Permission
from django.contrib.auth.models import User from django.contrib.auth.models import User
from django.contrib.contenttypes.models import ContentType from django.contrib.contenttypes.models import ContentType
from django.db.models import Count
from django.db.models import IntegerField
from django.db.models import OuterRef
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 Subquery
from django.db.models.functions import Cast
from django.db.models.functions import Coalesce
from guardian.core import ObjectPermissionChecker from guardian.core import ObjectPermissionChecker
from guardian.models import GroupObjectPermission from guardian.models import GroupObjectPermission
from guardian.models import UserObjectPermission
from guardian.shortcuts import assign_perm from guardian.shortcuts import assign_perm
from guardian.shortcuts import get_objects_for_user from guardian.shortcuts import get_objects_for_user
from guardian.shortcuts import get_users_with_perms from guardian.shortcuts import get_users_with_perms
@@ -129,23 +136,96 @@ def set_permissions_for_object(permissions: dict, object, *, merge: bool = False
) )
def _permitted_document_ids(user):
"""
Return a queryset of document IDs the user may view, limited to non-deleted
documents. This intentionally avoids ``get_objects_for_user`` to keep the
subquery small and index-friendly.
"""
base_docs = Document.objects.filter(deleted_at__isnull=True).only("id", "owner")
if user is None or not getattr(user, "is_authenticated", False):
# Just Anonymous user e.g. for drf-spectacular
return base_docs.filter(owner__isnull=True).values_list("id", flat=True)
if getattr(user, "is_superuser", False):
return base_docs.values_list("id", flat=True)
document_ct = ContentType.objects.get_for_model(Document)
perm_filter = {
"permission__codename": "view_document",
"permission__content_type": document_ct,
}
user_perm_docs = (
UserObjectPermission.objects.filter(user=user, **perm_filter)
.annotate(object_pk_int=Cast("object_pk", IntegerField()))
.values_list("object_pk_int", flat=True)
)
group_perm_docs = (
GroupObjectPermission.objects.filter(group__user=user, **perm_filter)
.annotate(object_pk_int=Cast("object_pk", IntegerField()))
.values_list("object_pk_int", flat=True)
)
permitted_documents = user_perm_docs.union(group_perm_docs)
return base_docs.filter(
Q(owner=user) | Q(owner__isnull=True) | Q(id__in=permitted_documents),
).values_list("id", flat=True)
def get_document_count_filter_for_user(user): def get_document_count_filter_for_user(user):
""" """
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.
The filter is expressed as an ``id__in`` against a small subquery of permitted
document IDs to keep the generated SQL simple and avoid large OR clauses.
""" """
if user is None or not getattr(user, "is_authenticated", False):
return Q(documents__deleted_at__isnull=True, documents__owner__isnull=True)
if getattr(user, "is_superuser", False): if getattr(user, "is_superuser", False):
# Superuser: no permission filtering needed
return Q(documents__deleted_at__isnull=True) return Q(documents__deleted_at__isnull=True)
return Q(
documents__deleted_at__isnull=True, permitted_ids = _permitted_document_ids(user)
documents__id__in=get_objects_for_user_owner_aware( return Q(documents__id__in=permitted_ids)
user,
"documents.view_document",
Document, def annotate_document_count_for_related_queryset(
).values_list("id", flat=True), queryset,
through_model,
related_object_field: str,
target_field: str = "document_id",
user=None,
):
"""
Annotate a queryset with permissions-aware document counts using a subquery
against a relation table.
Args:
queryset: base queryset to annotate (must contain pk)
through_model: model representing the relation (e.g., Document.tags.through
or CustomFieldInstance)
source_field: field on the relation pointing back to queryset pk
target_field: field on the relation pointing to Document id
user: the user for whom to filter permitted document ids
"""
permitted_ids = _permitted_document_ids(user)
counts = (
through_model.objects.filter(
**{
related_object_field: OuterRef("pk"),
f"{target_field}__in": permitted_ids,
},
)
.values(related_object_field)
.annotate(c=Count(target_field))
.values("c")
) )
return queryset.annotate(document_count=Coalesce(Subquery(counts[:1]), 0))
def get_objects_for_user_owner_aware(user, perms, Model) -> QuerySet: def get_objects_for_user_owner_aware(user, perms, Model) -> QuerySet:
+121 -28
View File
@@ -6,6 +6,7 @@ import re
from datetime import datetime from datetime import datetime
from decimal import Decimal from decimal import Decimal
from typing import TYPE_CHECKING from typing import TYPE_CHECKING
from typing import Any
from typing import Literal from typing import Literal
import magic import magic
@@ -40,6 +41,7 @@ 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
from rest_framework import fields from rest_framework import fields
from rest_framework import serializers from rest_framework import serializers
from rest_framework.exceptions import PermissionDenied
from rest_framework.fields import SerializerMethodField from rest_framework.fields import SerializerMethodField
from rest_framework.filters import OrderingFilter from rest_framework.filters import OrderingFilter
@@ -72,6 +74,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 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
@@ -436,6 +440,19 @@ class OwnedObjectSerializer(
return instance return instance
def update(self, instance, validated_data): def update(self, instance, validated_data):
user = getattr(self, "user", None)
is_superuser = user.is_superuser if user is not None else False
is_owner = instance.owner == user if user is not None else False
is_unowned = instance.owner is None
if (
("owner" in validated_data and validated_data["owner"] != instance.owner)
or "set_permissions" in validated_data
) and not (is_superuser or is_owner or is_unowned):
raise PermissionDenied(
_("Insufficient permissions."),
)
if "set_permissions" in validated_data: if "set_permissions" in validated_data:
self._set_permissions(validated_data["set_permissions"], instance) self._set_permissions(validated_data["set_permissions"], instance)
self.validate_unique_together(validated_data, instance) self.validate_unique_together(validated_data, instance)
@@ -580,30 +597,34 @@ class TagSerializer(MatchingModelSerializer, OwnedObjectSerializer):
), ),
) )
def get_children(self, obj): def get_children(self, obj):
filter_q = self.context.get("document_count_filter") children_map = self.context.get("children_map")
request = self.context.get("request") if children_map is not None:
if filter_q is None: children = children_map.get(obj.pk, [])
user = getattr(request, "user", None) if request else None else:
filter_q = get_document_count_filter_for_user(user) filter_q = self.context.get("document_count_filter")
self.context["document_count_filter"] = filter_q request = self.context.get("request")
if filter_q is None:
user = getattr(request, "user", None) if request else None
filter_q = get_document_count_filter_for_user(user)
self.context["document_count_filter"] = filter_q
children_queryset = ( children = (
obj.get_children_queryset() obj.get_children_queryset()
.select_related("owner") .select_related("owner")
.annotate(document_count=Count("documents", filter=filter_q)) .annotate(document_count=Count("documents", filter=filter_q))
) )
view = self.context.get("view") view = self.context.get("view")
ordering = ( ordering = (
OrderingFilter().get_ordering(request, children_queryset, view) OrderingFilter().get_ordering(request, children, view)
if request and view if request and view
else None else None
) )
ordering = ordering or (Lower("name"),) ordering = ordering or (Lower("name"),)
children_queryset = children_queryset.order_by(*ordering) children = children.order_by(*ordering)
serializer = TagSerializer( serializer = TagSerializer(
children_queryset, children,
many=True, many=True,
user=self.user, user=self.user,
full_perms=self.full_perms, full_perms=self.full_perms,
@@ -695,6 +716,9 @@ class StoragePathField(serializers.PrimaryKeyRelatedField):
class CustomFieldSerializer(serializers.ModelSerializer): class CustomFieldSerializer(serializers.ModelSerializer):
def __init__(self, *args, **kwargs): def __init__(self, *args, **kwargs):
# Ignore args passed by permissions mixin
kwargs.pop("user", None)
kwargs.pop("full_perms", None)
context = kwargs.get("context") context = kwargs.get("context")
self.api_version = int( self.api_version = int(
context.get("request").version context.get("request").version
@@ -829,6 +853,25 @@ class ReadWriteSerializerMethodField(serializers.SerializerMethodField):
return {self.field_name: data} return {self.field_name: data}
def validate_documentlink_targets(user, doc_ids):
if Document.objects.filter(id__in=doc_ids).count() != len(doc_ids):
raise serializers.ValidationError(
"Some documents in value don't exist or were specified twice.",
)
if user is None:
return
target_documents = Document.objects.filter(id__in=doc_ids).select_related("owner")
if not all(
has_perms_owner_aware(user, "change_document", document)
for document in target_documents
):
raise PermissionDenied(
_("Insufficient permissions."),
)
class CustomFieldInstanceSerializer(serializers.ModelSerializer): class CustomFieldInstanceSerializer(serializers.ModelSerializer):
field = serializers.PrimaryKeyRelatedField(queryset=CustomField.objects.all()) field = serializers.PrimaryKeyRelatedField(queryset=CustomField.objects.all())
value = ReadWriteSerializerMethodField(allow_null=True) value = ReadWriteSerializerMethodField(allow_null=True)
@@ -919,12 +962,13 @@ class CustomFieldInstanceSerializer(serializers.ModelSerializer):
"Value must be a list", "Value must be a list",
) )
doc_ids = data["value"] doc_ids = data["value"]
if Document.objects.filter(id__in=doc_ids).count() != len( request = self.context.get("request")
data["value"], validate_documentlink_targets(
): getattr(request, "user", None) if request is not None else None,
raise serializers.ValidationError( doc_ids,
"Some documents in value don't exist or were specified twice.", )
) elif field.data_type == CustomField.FieldDataType.DATE:
data["value"] = serializers.DateField().to_internal_value(data["value"])
return data return data
@@ -1474,6 +1518,19 @@ class BulkEditSerializer(
f"Some custom fields in {name} don't exist or were specified twice.", f"Some custom fields in {name} don't exist or were specified twice.",
) )
if isinstance(custom_fields, dict):
custom_field_map = CustomField.objects.in_bulk(ids)
for raw_field_id, value in custom_fields.items():
field = custom_field_map.get(int(raw_field_id))
if (
field is not None
and field.data_type == CustomField.FieldDataType.DOCUMENTLINK
and value is not None
):
if not isinstance(value, list):
raise serializers.ValidationError("Value must be a list")
validate_documentlink_targets(self.user, value)
def validate_method(self, method): def validate_method(self, method):
if method == "set_correspondent": if method == "set_correspondent":
return bulk_edit.set_correspondent return bulk_edit.set_correspondent
@@ -2156,6 +2213,17 @@ class ShareLinkSerializer(OwnedObjectSerializer):
validated_data["slug"] = get_random_string(50) validated_data["slug"] = get_random_string(50)
return super().create(validated_data) return super().create(validated_data)
def validate_document(self, document):
if self.user is not None and has_perms_owner_aware(
self.user,
"view_document",
document,
):
return document
raise PermissionDenied(
_("Insufficient permissions."),
)
class BulkEditObjectsSerializer(SerializerWithPerms, SetPermissionsMixin): class BulkEditObjectsSerializer(SerializerWithPerms, SetPermissionsMixin):
objects = serializers.ListField( objects = serializers.ListField(
@@ -2562,7 +2630,8 @@ class WorkflowSerializer(serializers.ModelSerializer):
set_triggers.append(trigger_instance) set_triggers.append(trigger_instance)
if actions is not None and actions is not serializers.empty: if actions is not None and actions is not serializers.empty:
for action in actions: for index, action in enumerate(actions):
action["order"] = index
assign_tags = action.pop("assign_tags", None) assign_tags = action.pop("assign_tags", None)
assign_view_users = action.pop("assign_view_users", None) assign_view_users = action.pop("assign_view_users", None)
assign_view_groups = action.pop("assign_view_groups", None) assign_view_groups = action.pop("assign_view_groups", None)
@@ -2689,6 +2758,16 @@ class WorkflowSerializer(serializers.ModelSerializer):
return instance return instance
def to_representation(self, instance):
data = super().to_representation(instance)
actions = instance.actions.order_by("order", "pk")
data["actions"] = WorkflowActionSerializer(
actions,
many=True,
context=self.context,
).data
return data
class TrashSerializer(SerializerWithPerms): class TrashSerializer(SerializerWithPerms):
documents = serializers.ListField( documents = serializers.ListField(
@@ -2721,8 +2800,22 @@ class StoragePathTestSerializer(SerializerWithPerms):
) )
document = serializers.PrimaryKeyRelatedField( document = serializers.PrimaryKeyRelatedField(
queryset=Document.objects.all(), queryset=Document.objects.none(),
required=True, required=True,
label="Document", label="Document",
write_only=True, write_only=True,
) )
def __init__(self, *args: Any, **kwargs: Any) -> None:
super().__init__(*args, **kwargs)
request = self.context.get("request")
user = getattr(request, "user", None) if request else None
if user is not None and user.is_authenticated:
document_field = self.fields.get("document")
if not isinstance(document_field, serializers.PrimaryKeyRelatedField):
return
document_field.queryset = get_objects_for_user_owner_aware(
user,
"documents.view_document",
Document,
)
+89 -22
View File
@@ -1,5 +1,6 @@
from __future__ import annotations from __future__ import annotations
import hashlib
import logging import logging
import shutil import shutil
from pathlib import Path from pathlib import Path
@@ -391,6 +392,14 @@ class CannotMoveFilesException(Exception):
pass pass
def _path_matches_checksum(path: Path, checksum: str | None) -> bool:
if checksum is None or not path.is_file():
return False
with path.open("rb") as f:
return hashlib.md5(f.read()).hexdigest() == checksum
def _filename_template_uses_custom_fields(doc: Document) -> bool: def _filename_template_uses_custom_fields(doc: Document) -> bool:
template = None template = None
if doc.storage_path is not None: if doc.storage_path is not None:
@@ -460,8 +469,24 @@ def update_filename_and_move_files(
old_filename = instance.filename old_filename = instance.filename
old_source_path = instance.source_path old_source_path = instance.source_path
move_original = False
original_already_moved = False
old_archive_filename = instance.archive_filename
old_archive_path = instance.archive_path
move_archive = False
archive_already_moved = False
candidate_filename = generate_filename(instance) candidate_filename = generate_filename(instance)
if len(str(candidate_filename)) > Document.MAX_STORED_FILENAME_LENGTH:
msg = (
f"Document {instance!s}: Generated filename exceeds db path "
f"limit ({len(str(candidate_filename))} > "
f"{Document.MAX_STORED_FILENAME_LENGTH}): {candidate_filename!s}"
)
logger.warning(msg)
raise CannotMoveFilesException(msg)
candidate_source_path = ( candidate_source_path = (
settings.ORIGINALS_DIR / candidate_filename settings.ORIGINALS_DIR / candidate_filename
).resolve() ).resolve()
@@ -471,20 +496,34 @@ def update_filename_and_move_files(
candidate_source_path.exists() candidate_source_path.exists()
and candidate_source_path != old_source_path and candidate_source_path != old_source_path
): ):
# Only fall back to unique search when there is an actual conflict if not old_source_path.is_file() and _path_matches_checksum(
new_filename = generate_unique_filename(instance) candidate_source_path,
instance.checksum,
):
new_filename = candidate_filename
original_already_moved = True
else:
# Only fall back to unique search when there is an actual conflict
new_filename = generate_unique_filename(instance)
else: else:
new_filename = candidate_filename new_filename = candidate_filename
# Need to convert to string to be able to save it to the db # Need to convert to string to be able to save it to the db
instance.filename = str(new_filename) instance.filename = str(new_filename)
move_original = old_filename != instance.filename move_original = (
old_filename != instance.filename and not original_already_moved
old_archive_filename = instance.archive_filename )
old_archive_path = instance.archive_path
if instance.has_archive_version: if instance.has_archive_version:
archive_candidate = generate_filename(instance, archive_filename=True) archive_candidate = generate_filename(instance, archive_filename=True)
if len(str(archive_candidate)) > Document.MAX_STORED_FILENAME_LENGTH:
msg = (
f"Document {instance!s}: Generated archive filename exceeds "
f"db path limit ({len(str(archive_candidate))} > "
f"{Document.MAX_STORED_FILENAME_LENGTH}): {archive_candidate!s}"
)
logger.warning(msg)
raise CannotMoveFilesException(msg)
archive_candidate_path = ( archive_candidate_path = (
settings.ARCHIVE_DIR / archive_candidate settings.ARCHIVE_DIR / archive_candidate
).resolve() ).resolve()
@@ -494,24 +533,38 @@ def update_filename_and_move_files(
archive_candidate_path.exists() archive_candidate_path.exists()
and archive_candidate_path != old_archive_path and archive_candidate_path != old_archive_path
): ):
new_archive_filename = generate_unique_filename( if not old_archive_path.is_file() and _path_matches_checksum(
instance, archive_candidate_path,
archive_filename=True, instance.archive_checksum,
) ):
new_archive_filename = archive_candidate
archive_already_moved = True
else:
new_archive_filename = generate_unique_filename(
instance,
archive_filename=True,
)
else: else:
new_archive_filename = archive_candidate new_archive_filename = archive_candidate
instance.archive_filename = str(new_archive_filename) instance.archive_filename = str(new_archive_filename)
move_archive = old_archive_filename != instance.archive_filename move_archive = (
old_archive_filename != instance.archive_filename
and not archive_already_moved
)
else: else:
move_archive = False move_archive = False
if not move_original and not move_archive: if not move_original and not move_archive:
# Just update modified. Also, don't save() here to prevent infinite recursion. updates = {"modified": timezone.now()}
Document.objects.filter(pk=instance.pk).update( if old_filename != instance.filename:
modified=timezone.now(), updates["filename"] = instance.filename
) if old_archive_filename != instance.archive_filename:
updates["archive_filename"] = instance.archive_filename
# Don't save() here to prevent infinite recursion.
Document.objects.filter(pk=instance.pk).update(**updates)
return return
if move_original: if move_original:
@@ -765,11 +818,10 @@ def run_workflows(
# Refresh this so the matching data is fresh and instance fields are re-freshed # Refresh this so the matching data is fresh and instance fields are re-freshed
# Otherwise, this instance might be behind and overwrite the work another process did # Otherwise, this instance might be behind and overwrite the work another process did
document.refresh_from_db() document.refresh_from_db()
doc_tag_ids = list(document.tags.values_list("pk", flat=True))
if matching.document_matches_workflow(document, workflow, trigger_type): if matching.document_matches_workflow(document, workflow, trigger_type):
action: WorkflowAction action: WorkflowAction
for action in workflow.actions.all(): for action in workflow.actions.order_by("order", "pk"):
message = f"Applying {action} from {workflow}" message = f"Applying {action} from {workflow}"
if not use_overrides: if not use_overrides:
logger.info(message, extra={"group": logging_group}) logger.info(message, extra={"group": logging_group})
@@ -783,14 +835,13 @@ def run_workflows(
apply_assignment_to_document( apply_assignment_to_document(
action, action,
document, document,
doc_tag_ids,
logging_group, logging_group,
) )
elif action.type == WorkflowAction.WorkflowActionType.REMOVAL: elif action.type == WorkflowAction.WorkflowActionType.REMOVAL:
if use_overrides and overrides: if use_overrides and overrides:
apply_removal_to_overrides(action, overrides) apply_removal_to_overrides(action, overrides)
else: else:
apply_removal_to_document(action, document, doc_tag_ids) apply_removal_to_document(action, document)
elif action.type == WorkflowAction.WorkflowActionType.EMAIL: elif action.type == WorkflowAction.WorkflowActionType.EMAIL:
context = build_workflow_action_context(document, overrides) context = build_workflow_action_context(document, overrides)
execute_email_action( execute_email_action(
@@ -814,9 +865,25 @@ def run_workflows(
if not use_overrides: if not use_overrides:
# limit title to 128 characters # limit title to 128 characters
document.title = document.title[:128] document.title = document.title[:128]
# save first before setting tags # Save only the fields that workflow actions can set directly.
document.save() # Deliberately excludes filename and archive_filename — those are
document.tags.set(doc_tag_ids) # managed exclusively by update_filename_and_move_files via the
# post_save signal. Writing stale in-memory values here would revert
# a concurrent update_filename_and_move_files DB write, leaving the
# DB pointing at the old path while the file is already at the new
# one (see: https://github.com/paperless-ngx/paperless-ngx/issues/12386).
# modified has auto_now=True but is not auto-added when update_fields
# is specified, so it must be listed explicitly.
document.save(
update_fields=[
"title",
"correspondent",
"document_type",
"storage_path",
"owner",
"modified",
],
)
WorkflowRun.objects.create( WorkflowRun.objects.create(
workflow=workflow, workflow=workflow,
+71 -1
View File
@@ -79,6 +79,23 @@ class PlaceholderString(str):
NO_VALUE_PLACEHOLDER = PlaceholderString("-none-") NO_VALUE_PLACEHOLDER = PlaceholderString("-none-")
class MatchingModelContext:
"""
Safe template context for related objects.
Keeps legacy behavior where including the object ina template yields the related object's
name as a string, while still exposing limited attributes.
"""
def __init__(self, *, id: int, name: str, path: str | None = None):
self.id = id
self.name = name
self.path = path
def __str__(self) -> str:
return self.name
_template_environment.undefined = _LogStrictUndefined _template_environment.undefined = _LogStrictUndefined
_template_environment.filters["get_cf_value"] = get_cf_value _template_environment.filters["get_cf_value"] = get_cf_value
@@ -193,6 +210,59 @@ def get_basic_metadata_context(
} }
def get_safe_document_context(
document: Document,
tags: Iterable[Tag],
) -> dict[str, object]:
"""
Build a document context object to avoid supplying entire model instance.
"""
return {
"id": document.pk,
"pk": document.pk,
"title": document.title,
"content": document.content,
"page_count": document.page_count,
"created": document.created,
"added": document.added,
"modified": document.modified,
"archive_serial_number": document.archive_serial_number,
"mime_type": document.mime_type,
"checksum": document.checksum,
"archive_checksum": document.archive_checksum,
"filename": document.filename,
"archive_filename": document.archive_filename,
"original_filename": document.original_filename,
"owner": {"username": document.owner.username, "id": document.owner.id}
if document.owner
else None,
"tags": [{"name": tag.name, "id": tag.id} for tag in tags],
"correspondent": (
MatchingModelContext(
name=document.correspondent.name,
id=document.correspondent.id,
)
if document.correspondent
else None
),
"document_type": (
MatchingModelContext(
name=document.document_type.name,
id=document.document_type.id,
)
if document.document_type
else None
),
"storage_path": MatchingModelContext(
name=document.storage_path.name,
path=document.storage_path.path,
id=document.storage_path.id,
)
if document.storage_path
else None,
}
def get_tags_context(tags: Iterable[Tag]) -> dict[str, str | list[str]]: def get_tags_context(tags: Iterable[Tag]) -> dict[str, str | list[str]]:
""" """
Given an Iterable of tags, constructs some context from them for usage Given an Iterable of tags, constructs some context from them for usage
@@ -303,7 +373,7 @@ def validate_filepath_template_and_render(
# Build the context dictionary # Build the context dictionary
context = ( context = (
{"document": document} {"document": get_safe_document_context(document, tags=tags_list)}
| get_basic_metadata_context(document, no_value_default=NO_VALUE_PLACEHOLDER) | get_basic_metadata_context(document, no_value_default=NO_VALUE_PLACEHOLDER)
| get_creation_date_context(document) | get_creation_date_context(document)
| get_added_date_context(document) | get_added_date_context(document)
+60
View File
@@ -262,6 +262,50 @@ class TestBulkEditAPI(DirectoriesMixin, APITestCase):
self.assertEqual(kwargs["add_custom_fields"], [self.cf1.id]) self.assertEqual(kwargs["add_custom_fields"], [self.cf1.id])
self.assertEqual(kwargs["remove_custom_fields"], [self.cf2.id]) self.assertEqual(kwargs["remove_custom_fields"], [self.cf2.id])
@mock.patch("documents.serialisers.bulk_edit.modify_custom_fields")
def test_api_modify_custom_fields_documentlink_forbidden_for_unpermitted_target(
self,
m,
):
self.setup_mock(m, "modify_custom_fields")
user = User.objects.create_user(username="doc-owner")
user.user_permissions.add(Permission.objects.get(codename="change_document"))
other_user = User.objects.create_user(username="other-user")
source_doc = Document.objects.create(
checksum="source",
title="Source",
owner=user,
)
target_doc = Document.objects.create(
checksum="target",
title="Target",
owner=other_user,
)
doclink_field = CustomField.objects.create(
name="doclink",
data_type=CustomField.FieldDataType.DOCUMENTLINK,
)
self.client.force_authenticate(user=user)
response = self.client.post(
"/api/documents/bulk_edit/",
json.dumps(
{
"documents": [source_doc.id],
"method": "modify_custom_fields",
"parameters": {
"add_custom_fields": {doclink_field.id: [target_doc.id]},
"remove_custom_fields": [],
},
},
),
content_type="application/json",
)
self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN)
m.assert_not_called()
@mock.patch("documents.serialisers.bulk_edit.modify_custom_fields") @mock.patch("documents.serialisers.bulk_edit.modify_custom_fields")
def test_api_modify_custom_fields_with_values(self, m): def test_api_modify_custom_fields_with_values(self, m):
self.setup_mock(m, "modify_custom_fields") self.setup_mock(m, "modify_custom_fields")
@@ -773,6 +817,22 @@ class TestBulkEditAPI(DirectoriesMixin, APITestCase):
], ],
) )
def test_api_selection_data_requires_view_permission(self):
self.doc2.owner = self.user
self.doc2.save()
user1 = User.objects.create(username="user1")
self.client.force_authenticate(user=user1)
response = self.client.post(
"/api/documents/selection_data/",
json.dumps({"documents": [self.doc2.id]}),
content_type="application/json",
)
self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN)
self.assertEqual(response.content, b"Insufficient permissions")
@mock.patch("documents.serialisers.bulk_edit.set_permissions") @mock.patch("documents.serialisers.bulk_edit.set_permissions")
def test_set_permissions(self, m): def test_set_permissions(self, m):
self.setup_mock(m, "set_permissions") self.setup_mock(m, "set_permissions")
@@ -6,6 +6,7 @@ from unittest.mock import ANY
from django.contrib.auth.models import Permission from django.contrib.auth.models import Permission
from django.contrib.auth.models import User from django.contrib.auth.models import User
from django.test import override_settings from django.test import override_settings
from guardian.shortcuts import assign_perm
from rest_framework import status from rest_framework import status
from rest_framework.test import APITestCase from rest_framework.test import APITestCase
@@ -1247,6 +1248,100 @@ class TestCustomFieldsAPI(DirectoriesMixin, APITestCase):
self.assertEqual(resp.status_code, status.HTTP_200_OK) self.assertEqual(resp.status_code, status.HTTP_200_OK)
self.assertEqual(doc5.custom_fields.first().value, [1]) self.assertEqual(doc5.custom_fields.first().value, [1])
def test_documentlink_patch_requires_change_permission_on_target_documents(self):
source_owner = User.objects.create_user(username="source-owner")
source_owner.user_permissions.add(
Permission.objects.get(codename="change_document"),
)
other_user = User.objects.create_user(username="other-user")
source_doc = Document.objects.create(
title="Source",
checksum="source",
mime_type="application/pdf",
owner=source_owner,
)
target_doc = Document.objects.create(
title="Target",
checksum="target",
mime_type="application/pdf",
owner=other_user,
)
custom_field_doclink = CustomField.objects.create(
name="Test Custom Field Doc Link",
data_type=CustomField.FieldDataType.DOCUMENTLINK,
)
self.client.force_authenticate(user=source_owner)
resp = self.client.patch(
f"/api/documents/{source_doc.id}/",
data={
"custom_fields": [
{
"field": custom_field_doclink.id,
"value": [target_doc.id],
},
],
},
format="json",
)
self.assertEqual(resp.status_code, status.HTTP_403_FORBIDDEN)
self.assertEqual(
CustomFieldInstance.objects.filter(field=custom_field_doclink).count(),
0,
)
def test_documentlink_patch_allowed_with_change_permission_on_target_documents(
self,
):
source_owner = User.objects.create_user(username="source-owner")
source_owner.user_permissions.add(
Permission.objects.get(codename="change_document"),
)
other_user = User.objects.create_user(username="other-user")
source_doc = Document.objects.create(
title="Source",
checksum="source",
mime_type="application/pdf",
owner=source_owner,
)
target_doc = Document.objects.create(
title="Target",
checksum="target",
mime_type="application/pdf",
owner=other_user,
)
custom_field_doclink = CustomField.objects.create(
name="Test Custom Field Doc Link",
data_type=CustomField.FieldDataType.DOCUMENTLINK,
)
assign_perm("change_document", source_owner, target_doc)
self.client.force_authenticate(user=source_owner)
resp = self.client.patch(
f"/api/documents/{source_doc.id}/",
data={
"custom_fields": [
{
"field": custom_field_doclink.id,
"value": [target_doc.id],
},
],
},
format="json",
)
self.assertEqual(resp.status_code, status.HTTP_200_OK)
target_doc.refresh_from_db()
self.assertEqual(
target_doc.custom_fields.get(field=custom_field_doclink).value,
[source_doc.id],
)
def test_custom_field_filters(self): def test_custom_field_filters(self):
custom_field_string = CustomField.objects.create( custom_field_string = CustomField.objects.create(
name="Test Custom Field String", name="Test Custom Field String",
@@ -1330,3 +1425,41 @@ class TestCustomFieldsAPI(DirectoriesMixin, APITestCase):
self.assertEqual(response.status_code, status.HTTP_200_OK) self.assertEqual(response.status_code, status.HTTP_200_OK)
results = response.data["results"] results = response.data["results"]
self.assertEqual(results[0]["document_count"], 0) self.assertEqual(results[0]["document_count"], 0)
def test_patch_document_invalid_date_custom_field_returns_validation_error(self):
"""
GIVEN:
- A date custom field
- A document
WHEN:
- Patching the document with a date string in the wrong format
THEN:
- HTTP 400 is returned instead of an internal server error
- No custom field instance is created
"""
cf_date = CustomField.objects.create(
name="datefield",
data_type=CustomField.FieldDataType.DATE,
)
doc = Document.objects.create(
title="Doc",
checksum="123",
mime_type="application/pdf",
)
response = self.client.patch(
f"/api/documents/{doc.pk}/",
{
"custom_fields": [
{
"field": cf_date.pk,
"value": "10.03.2026",
},
],
},
format="json",
)
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
self.assertIn("custom_fields", response.data)
self.assertEqual(CustomFieldInstance.objects.count(), 0)
+148
View File
@@ -1087,6 +1087,43 @@ class TestDocumentApi(DirectoriesMixin, DocumentConsumeDelayMixin, APITestCase):
self.assertEqual(len(response.data["all"]), 50) self.assertEqual(len(response.data["all"]), 50)
self.assertCountEqual(response.data["all"], [d.id for d in docs]) self.assertCountEqual(response.data["all"], [d.id for d in docs])
def test_default_ordering_uses_id_as_tiebreaker(self):
"""
GIVEN:
- Documents sharing the same created date
WHEN:
- API request for documents without an explicit ordering
THEN:
- Results are correctly ordered by created > id
"""
older_doc = Document.objects.create(
checksum="older",
content="older",
created=date(2024, 1, 1),
)
first_same_date_doc = Document.objects.create(
checksum="same-date-1",
content="same-date-1",
created=date(2024, 1, 2),
)
second_same_date_doc = Document.objects.create(
checksum="same-date-2",
content="same-date-2",
created=date(2024, 1, 2),
)
response = self.client.get("/api/documents/")
self.assertEqual(response.status_code, status.HTTP_200_OK)
self.assertEqual(
[result["id"] for result in response.data["results"]],
[
second_same_date_doc.id,
first_same_date_doc.id,
older_doc.id,
],
)
def test_statistics(self): def test_statistics(self):
doc1 = Document.objects.create( doc1 = Document.objects.create(
title="none1", title="none1",
@@ -1216,6 +1253,17 @@ class TestDocumentApi(DirectoriesMixin, DocumentConsumeDelayMixin, APITestCase):
self.assertEqual(response.status_code, status.HTTP_405_METHOD_NOT_ALLOWED) self.assertEqual(response.status_code, status.HTTP_405_METHOD_NOT_ALLOWED)
def test_upload_insufficient_permissions(self):
self.client.force_authenticate(user=User.objects.create_user("testuser2"))
with (Path(__file__).parent / "samples" / "simple.pdf").open("rb") as f:
response = self.client.post(
"/api/documents/post_document/",
{"document": f},
)
self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN)
def test_upload_empty_metadata(self): def test_upload_empty_metadata(self):
self.consume_file_mock.return_value = celery.result.AsyncResult( self.consume_file_mock.return_value = celery.result.AsyncResult(
id=str(uuid.uuid4()), id=str(uuid.uuid4()),
@@ -2894,6 +2942,106 @@ class TestDocumentApi(DirectoriesMixin, DocumentConsumeDelayMixin, APITestCase):
) )
self.assertEqual(resp.status_code, status.HTTP_200_OK) self.assertEqual(resp.status_code, status.HTTP_200_OK)
def test_create_share_link_requires_view_permission_for_document(self):
"""
GIVEN:
- A user with add_sharelink but without view permission on a document
WHEN:
- API request is made to create a share link for that document
THEN:
- Share link creation is denied until view permission is granted
"""
user1 = User.objects.create_user(username="test1")
user1.user_permissions.add(*Permission.objects.filter(codename="add_sharelink"))
user1.save()
user2 = User.objects.create_user(username="test2")
user2.save()
doc = Document.objects.create(
title="test",
mime_type="application/pdf",
content="this is a document which will be protected",
owner=user2,
)
self.client.force_authenticate(user1)
create_resp = self.client.post(
"/api/share_links/",
data={
"document": doc.pk,
"file_version": "original",
},
format="json",
)
self.assertEqual(create_resp.status_code, status.HTTP_403_FORBIDDEN)
assign_perm("view_document", user1, doc)
create_resp = self.client.post(
"/api/share_links/",
data={
"document": doc.pk,
"file_version": "original",
},
format="json",
)
self.assertEqual(create_resp.status_code, status.HTTP_201_CREATED)
self.assertEqual(create_resp.data["document"], doc.pk)
def test_share_link_update_methods_not_allowed(self):
"""
GIVEN:
- An existing share link
WHEN:
- PUT and PATCH requests are made to its detail endpoint
THEN:
- The API rejects them with 405 and the link is unchanged
"""
doc = Document.objects.create(
title="test",
mime_type="application/pdf",
content="share link content",
)
expiration = timezone.now() + timedelta(days=7)
create_resp = self.client.post(
"/api/share_links/",
data={
"document": doc.pk,
"expiration": expiration.isoformat(),
"file_version": ShareLink.FileVersion.ORIGINAL,
},
format="json",
)
self.assertEqual(create_resp.status_code, status.HTTP_201_CREATED)
share_link_id = create_resp.data["id"]
patch_resp = self.client.patch(
f"/api/share_links/{share_link_id}/",
data={
"expiration": None,
"file_version": ShareLink.FileVersion.ARCHIVE,
},
format="json",
)
self.assertEqual(patch_resp.status_code, status.HTTP_405_METHOD_NOT_ALLOWED)
put_resp = self.client.put(
f"/api/share_links/{share_link_id}/",
data={
"document": doc.pk,
"expiration": None,
"file_version": ShareLink.FileVersion.ARCHIVE,
},
format="json",
)
self.assertEqual(put_resp.status_code, status.HTTP_405_METHOD_NOT_ALLOWED)
share_link = ShareLink.objects.get(pk=share_link_id)
self.assertEqual(share_link.file_version, ShareLink.FileVersion.ORIGINAL)
self.assertIsNotNone(share_link.expiration)
def test_next_asn(self): def test_next_asn(self):
""" """
GIVEN: GIVEN:
+289
View File
@@ -5,10 +5,13 @@ from unittest import mock
from django.contrib.auth.models import Permission from django.contrib.auth.models import Permission
from django.contrib.auth.models import User from django.contrib.auth.models import User
from django.test import override_settings from django.test import override_settings
from guardian.shortcuts import assign_perm
from rest_framework import status from rest_framework import status
from rest_framework.test import APITestCase from rest_framework.test import APITestCase
from documents.models import Correspondent from documents.models import Correspondent
from documents.models import CustomField
from documents.models import CustomFieldInstance
from documents.models import Document from documents.models import Document
from documents.models import DocumentType from documents.models import DocumentType
from documents.models import StoragePath from documents.models import StoragePath
@@ -398,6 +401,292 @@ class TestApiStoragePaths(DirectoriesMixin, APITestCase):
self.assertEqual(response.status_code, status.HTTP_200_OK) self.assertEqual(response.status_code, status.HTTP_200_OK)
self.assertEqual(response.data, "folder/Something") self.assertEqual(response.data, "folder/Something")
def test_test_storage_path_requires_document_view_permission(self) -> None:
owner = User.objects.create_user(username="owner")
unprivileged = User.objects.create_user(username="unprivileged")
document = Document.objects.create(
mime_type="application/pdf",
owner=owner,
title="Sensitive",
checksum="123",
)
self.client.force_authenticate(user=unprivileged)
response = self.client.post(
f"{self.ENDPOINT}test/",
json.dumps(
{
"document": document.id,
"path": "path/{{ title }}",
},
),
content_type="application/json",
)
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
self.assertIn("document", response.data)
def test_test_storage_path_allows_shared_document_view_permission(self) -> None:
owner = User.objects.create_user(username="owner")
viewer = User.objects.create_user(username="viewer")
document = Document.objects.create(
mime_type="application/pdf",
owner=owner,
title="Shared",
checksum="123",
)
assign_perm("view_document", viewer, document)
self.client.force_authenticate(user=viewer)
response = self.client.post(
f"{self.ENDPOINT}test/",
json.dumps(
{
"document": document.id,
"path": "path/{{ title }}",
},
),
content_type="application/json",
)
self.assertEqual(response.status_code, status.HTTP_200_OK)
self.assertEqual(response.data, "path/Shared")
def test_test_storage_path_exposes_basic_document_context_but_not_sensitive_owner_data(
self,
) -> None:
owner = User.objects.create_user(
username="owner",
password="password",
email="owner@example.com",
)
document = Document.objects.create(
mime_type="application/pdf",
owner=owner,
title="Document",
content="Top secret content",
page_count=2,
checksum="123",
)
self.client.force_authenticate(user=owner)
response = self.client.post(
f"{self.ENDPOINT}test/",
json.dumps(
{
"document": document.id,
"path": "{{ document.owner.username }}",
},
),
content_type="application/json",
)
self.assertEqual(response.status_code, status.HTTP_200_OK)
self.assertEqual(response.data, "owner")
for expression, expected in (
("{{ document.content }}", "Top secret content"),
("{{ document.id }}", str(document.id)),
("{{ document.page_count }}", "2"),
):
response = self.client.post(
f"{self.ENDPOINT}test/",
json.dumps(
{
"document": document.id,
"path": expression,
},
),
content_type="application/json",
)
self.assertEqual(response.status_code, status.HTTP_200_OK)
self.assertEqual(response.data, expected)
for expression in (
"{{ document.owner.password }}",
"{{ document.owner.email }}",
):
response = self.client.post(
f"{self.ENDPOINT}test/",
json.dumps(
{
"document": document.id,
"path": expression,
},
),
content_type="application/json",
)
self.assertEqual(response.status_code, status.HTTP_200_OK)
self.assertIsNone(response.data)
def test_test_storage_path_includes_related_objects_for_visible_document(
self,
) -> None:
owner = User.objects.create_user(username="owner")
viewer = User.objects.create_user(username="viewer")
private_correspondent = Correspondent.objects.create(
name="Private Correspondent",
owner=owner,
)
document = Document.objects.create(
mime_type="application/pdf",
owner=owner,
correspondent=private_correspondent,
title="Document",
checksum="123",
)
assign_perm("view_document", viewer, document)
self.client.force_authenticate(user=viewer)
response = self.client.post(
f"{self.ENDPOINT}test/",
json.dumps(
{
"document": document.id,
"path": "{{ correspondent }}",
},
),
content_type="application/json",
)
self.assertEqual(response.status_code, status.HTTP_200_OK)
self.assertEqual(response.data, "Private Correspondent")
response = self.client.post(
f"{self.ENDPOINT}test/",
json.dumps(
{
"document": document.id,
"path": (
"{{ document.correspondent.name if document.correspondent else 'none' }}"
),
},
),
content_type="application/json",
)
self.assertEqual(response.status_code, status.HTTP_200_OK)
self.assertEqual(response.data, "Private Correspondent")
def test_test_storage_path_superuser_can_view_private_related_objects(self) -> None:
owner = User.objects.create_user(username="owner")
private_correspondent = Correspondent.objects.create(
name="Private Correspondent",
owner=owner,
)
document = Document.objects.create(
mime_type="application/pdf",
owner=owner,
correspondent=private_correspondent,
title="Document",
checksum="123",
)
response = self.client.post(
f"{self.ENDPOINT}test/",
json.dumps(
{
"document": document.id,
"path": (
"{{ document.correspondent.name if document.correspondent else 'none' }}"
),
},
),
content_type="application/json",
)
self.assertEqual(response.status_code, status.HTTP_200_OK)
self.assertEqual(response.data, "Private Correspondent")
def test_test_storage_path_includes_doc_type_storage_path_and_tags(
self,
) -> None:
owner = User.objects.create_user(username="owner")
viewer = User.objects.create_user(username="viewer")
private_document_type = DocumentType.objects.create(
name="Private Type",
owner=owner,
)
private_storage_path = StoragePath.objects.create(
name="Private Storage Path",
path="private/path",
owner=owner,
)
private_tag = Tag.objects.create(
name="Private Tag",
owner=owner,
)
document = Document.objects.create(
mime_type="application/pdf",
owner=owner,
document_type=private_document_type,
storage_path=private_storage_path,
title="Document",
checksum="123",
)
document.tags.add(private_tag)
assign_perm("view_document", viewer, document)
self.client.force_authenticate(user=viewer)
response = self.client.post(
f"{self.ENDPOINT}test/",
json.dumps(
{
"document": document.id,
"path": (
"{{ document.document_type.name if document.document_type else 'none' }}/"
"{{ document.storage_path.path if document.storage_path else 'none' }}/"
"{{ document.tags[0].name if document.tags else 'none' }}"
),
},
),
content_type="application/json",
)
self.assertEqual(response.status_code, status.HTTP_200_OK)
self.assertEqual(response.data, "Private Type/private/path/Private Tag")
response = self.client.post(
f"{self.ENDPOINT}test/",
json.dumps(
{
"document": document.id,
"path": "{{ document_type }}/{{ tag_list if tag_list else 'none' }}",
},
),
content_type="application/json",
)
self.assertEqual(response.status_code, status.HTTP_200_OK)
self.assertEqual(response.data, "Private Type/Private Tag")
def test_test_storage_path_includes_custom_fields_for_visible_document(
self,
) -> None:
owner = User.objects.create_user(username="owner")
viewer = User.objects.create_user(username="viewer")
document = Document.objects.create(
mime_type="application/pdf",
owner=owner,
title="Document",
checksum="123",
)
custom_field = CustomField.objects.create(
name="Secret Number",
data_type=CustomField.FieldDataType.INT,
)
CustomFieldInstance.objects.create(
document=document,
field=custom_field,
value_int=42,
)
assign_perm("view_document", viewer, document)
self.client.force_authenticate(user=viewer)
response = self.client.post(
f"{self.ENDPOINT}test/",
json.dumps(
{
"document": document.id,
"path": "{{ custom_fields | get_cf_value('Secret Number', 'none') }}",
},
),
content_type="application/json",
)
self.assertEqual(response.status_code, status.HTTP_200_OK)
self.assertEqual(response.data, "42")
class TestBulkEditObjects(APITestCase): class TestBulkEditObjects(APITestCase):
# See test_api_permissions.py for bulk tests on permissions # See test_api_permissions.py for bulk tests on permissions
+125
View File
@@ -441,6 +441,59 @@ class TestApiAuth(DirectoriesMixin, APITestCase):
self.assertTrue(checker.has_perm("change_document", doc)) self.assertTrue(checker.has_perm("change_document", doc))
self.assertIn("change_document", get_perms(group1, doc)) self.assertIn("change_document", get_perms(group1, doc))
def test_document_permissions_change_requires_owner(self):
owner = User.objects.create_user(username="owner")
editor = User.objects.create_user(username="editor")
editor.user_permissions.add(
*Permission.objects.all(),
)
doc = Document.objects.create(
title="Ownered doc",
content="sensitive",
checksum="abc123",
mime_type="application/pdf",
owner=owner,
)
assign_perm("view_document", editor, doc)
assign_perm("change_document", editor, doc)
self.client.force_authenticate(editor)
response = self.client.patch(
f"/api/documents/{doc.pk}/",
json.dumps(
{
"set_permissions": {
"view": {
"users": [editor.id],
"groups": [],
},
"change": {
"users": None,
"groups": None,
},
},
},
),
content_type="application/json",
)
self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN)
self.client.force_authenticate(editor)
response = self.client.patch(
f"/api/documents/{doc.pk}/",
json.dumps(
{
"owner": editor.id,
},
),
content_type="application/json",
)
self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN)
def test_dynamic_permissions_fields(self): def test_dynamic_permissions_fields(self):
user1 = User.objects.create_user(username="user1") user1 = User.objects.create_user(username="user1")
user1.user_permissions.add(*Permission.objects.filter(codename="view_document")) user1.user_permissions.add(*Permission.objects.filter(codename="view_document"))
@@ -835,6 +888,19 @@ class TestApiUser(DirectoriesMixin, APITestCase):
self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN)
response = self.client.post(
f"{self.ENDPOINT}",
json.dumps(
{
"username": "user4",
"is_superuser": "true",
},
),
content_type="application/json",
)
self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN)
self.client.force_authenticate(user2) self.client.force_authenticate(user2)
response = self.client.patch( response = self.client.patch(
@@ -867,6 +933,65 @@ class TestApiUser(DirectoriesMixin, APITestCase):
returned_user1 = User.objects.get(pk=user1.pk) returned_user1 = User.objects.get(pk=user1.pk)
self.assertEqual(returned_user1.is_superuser, False) self.assertEqual(returned_user1.is_superuser, False)
def test_only_superusers_can_create_or_alter_staff_status(self):
"""
GIVEN:
- Existing user account
WHEN:
- API request is made to add a user account with staff status
- API request is made to change staff status
THEN:
- Only superusers can change staff status
"""
user1 = User.objects.create_user(username="user1")
user1.user_permissions.add(*Permission.objects.all())
user2 = User.objects.create_superuser(username="user2")
self.client.force_authenticate(user1)
response = self.client.patch(
f"{self.ENDPOINT}{user1.pk}/",
json.dumps(
{
"is_staff": "true",
},
),
content_type="application/json",
)
self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN)
response = self.client.post(
f"{self.ENDPOINT}",
json.dumps(
{
"username": "user3",
"is_staff": 1,
},
),
content_type="application/json",
)
self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN)
self.client.force_authenticate(user2)
response = self.client.patch(
f"{self.ENDPOINT}{user1.pk}/",
json.dumps(
{
"is_staff": True,
},
),
content_type="application/json",
)
self.assertEqual(response.status_code, status.HTTP_200_OK)
returned_user1 = User.objects.get(pk=user1.pk)
self.assertEqual(returned_user1.is_staff, True)
class TestApiGroup(DirectoriesMixin, APITestCase): class TestApiGroup(DirectoriesMixin, APITestCase):
ENDPOINT = "/api/groups/" ENDPOINT = "/api/groups/"
+163
View File
@@ -702,6 +702,40 @@ class TestDocumentSearchApi(DirectoriesMixin, APITestCase):
self.assertEqual(correction, None) self.assertEqual(correction, None)
def test_search_spelling_suggestion_suppressed_for_private_terms(self):
owner = User.objects.create_user("owner")
attacker = User.objects.create_user("attacker")
attacker.user_permissions.add(
Permission.objects.get(codename="view_document"),
)
with AsyncWriter(index.open_index()) as writer:
for i in range(55):
private_doc = Document.objects.create(
checksum=f"p{i}",
pk=100 + i,
title=f"Private Document {i + 1}",
content=f"treasury document {i + 1}",
owner=owner,
)
visible_doc = Document.objects.create(
checksum=f"v{i}",
pk=200 + i,
title=f"Visible Document {i + 1}",
content=f"public ledger {i + 1}",
owner=attacker,
)
index.update_document(writer, private_doc)
index.update_document(writer, visible_doc)
self.client.force_authenticate(user=attacker)
response = self.client.get("/api/documents/?query=treasurx")
self.assertEqual(response.status_code, status.HTTP_200_OK)
self.assertEqual(response.data["count"], 0)
self.assertIsNone(response.data["corrected_query"])
@mock.patch( @mock.patch(
"whoosh.searching.Searcher.correct_query", "whoosh.searching.Searcher.correct_query",
side_effect=Exception("Test error"), side_effect=Exception("Test error"),
@@ -772,6 +806,58 @@ class TestDocumentSearchApi(DirectoriesMixin, APITestCase):
self.assertEqual(results[0]["id"], d3.id) self.assertEqual(results[0]["id"], d3.id)
self.assertEqual(results[1]["id"], d1.id) self.assertEqual(results[1]["id"], d1.id)
def test_search_more_like_requires_view_permission_on_seed_document(self):
"""
GIVEN:
- A user can search documents they own
- Another user's private document exists with similar content
WHEN:
- The user requests more-like-this for the private seed document
THEN:
- The request is rejected
"""
owner = User.objects.create_user("owner")
attacker = User.objects.create_user("attacker")
attacker.user_permissions.add(
Permission.objects.get(codename="view_document"),
)
private_seed = Document.objects.create(
title="private bank statement",
content="quarterly treasury bank statement wire transfer",
checksum="seed",
owner=owner,
pk=10,
)
visible_doc = Document.objects.create(
title="attacker-visible match",
content="quarterly treasury bank statement wire transfer summary",
checksum="visible",
owner=attacker,
pk=11,
)
other_doc = Document.objects.create(
title="unrelated",
content="completely different topic",
checksum="other",
owner=attacker,
pk=12,
)
with AsyncWriter(index.open_index()) as writer:
index.update_document(writer, private_seed)
index.update_document(writer, visible_doc)
index.update_document(writer, other_doc)
self.client.force_authenticate(user=attacker)
response = self.client.get(
f"/api/documents/?more_like_id={private_seed.id}",
)
self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN)
self.assertEqual(response.content, b"Insufficient permissions.")
def test_search_filtering(self): def test_search_filtering(self):
t = Tag.objects.create(name="tag") t = Tag.objects.create(name="tag")
t2 = Tag.objects.create(name="tag2") t2 = Tag.objects.create(name="tag2")
@@ -1357,6 +1443,83 @@ class TestDocumentSearchApi(DirectoriesMixin, APITestCase):
self.assertEqual(results["custom_fields"][0]["id"], custom_field1.id) self.assertEqual(results["custom_fields"][0]["id"], custom_field1.id)
self.assertEqual(results["workflows"][0]["id"], workflow1.id) self.assertEqual(results["workflows"][0]["id"], workflow1.id)
def test_global_search_filters_owned_mail_objects(self):
user1 = User.objects.create_user("mail-search-user")
user2 = User.objects.create_user("other-mail-search-user")
user1.user_permissions.add(
Permission.objects.get(codename="view_mailaccount"),
Permission.objects.get(codename="view_mailrule"),
)
own_account = MailAccount.objects.create(
name="bank owned account",
username="owner@example.com",
password="secret",
imap_server="imap.owner.example.com",
imap_port=993,
imap_security=MailAccount.ImapSecurity.SSL,
character_set="UTF-8",
owner=user1,
)
other_account = MailAccount.objects.create(
name="bank other account",
username="other@example.com",
password="secret",
imap_server="imap.other.example.com",
imap_port=993,
imap_security=MailAccount.ImapSecurity.SSL,
character_set="UTF-8",
owner=user2,
)
unowned_account = MailAccount.objects.create(
name="bank shared account",
username="shared@example.com",
password="secret",
imap_server="imap.shared.example.com",
imap_port=993,
imap_security=MailAccount.ImapSecurity.SSL,
character_set="UTF-8",
)
own_rule = MailRule.objects.create(
name="bank owned rule",
account=own_account,
action=MailRule.MailAction.MOVE,
owner=user1,
)
other_rule = MailRule.objects.create(
name="bank other rule",
account=other_account,
action=MailRule.MailAction.MOVE,
owner=user2,
)
unowned_rule = MailRule.objects.create(
name="bank shared rule",
account=unowned_account,
action=MailRule.MailAction.MOVE,
)
self.client.force_authenticate(user1)
response = self.client.get("/api/search/?query=bank")
self.assertEqual(response.status_code, status.HTTP_200_OK)
self.assertCountEqual(
[account["id"] for account in response.data["mail_accounts"]],
[own_account.id, unowned_account.id],
)
self.assertCountEqual(
[rule["id"] for rule in response.data["mail_rules"]],
[own_rule.id, unowned_rule.id],
)
self.assertNotIn(
other_account.id,
[account["id"] for account in response.data["mail_accounts"]],
)
self.assertNotIn(
other_rule.id,
[rule["id"] for rule in response.data["mail_rules"]],
)
def test_global_search_bad_request(self): def test_global_search_bad_request(self):
""" """
WHEN: WHEN:
+7
View File
@@ -57,11 +57,18 @@ class TestSystemStatus(APITestCase):
""" """
response = self.client.get(self.ENDPOINT) response = self.client.get(self.ENDPOINT)
self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED) self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED)
self.assertEqual(response["WWW-Authenticate"], "Token")
normal_user = User.objects.create_user(username="normal_user") normal_user = User.objects.create_user(username="normal_user")
self.client.force_login(normal_user) self.client.force_login(normal_user)
response = self.client.get(self.ENDPOINT) response = self.client.get(self.ENDPOINT)
self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN)
def test_system_status_with_bad_basic_auth_challenges(self) -> None:
self.client.credentials(HTTP_AUTHORIZATION="Basic invalid")
response = self.client.get(self.ENDPOINT)
self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED)
self.assertEqual(response["WWW-Authenticate"], 'Basic realm="api"')
def test_system_status_container_detection(self): def test_system_status_container_detection(self):
""" """
GIVEN: GIVEN:
+27
View File
@@ -633,6 +633,33 @@ class TestConsumer(
self._assert_first_last_send_progress() self._assert_first_last_send_progress()
@mock.patch("documents.consumer.generate_unique_filename")
def testFilenameHandlingFallsBackWhenGeneratedPathExceedsDbLimit(self, m):
m.side_effect = lambda doc, archive_filename=False: Path(
("a" * 1100 + ".pdf") if not archive_filename else ("b" * 1100 + ".pdf"),
)
with self.get_consumer(
self.get_test_file(),
DocumentMetadataOverrides(title="new docs"),
) as consumer:
consumer.run()
document = Document.objects.first()
self.assertIsNotNone(document)
assert document is not None
self.assertEqual(document.filename, f"{document.pk:07d}.pdf")
self.assertLessEqual(len(document.filename), 1024)
self.assertLessEqual(
len(document.archive_filename),
1024,
)
self.assertIsFile(document.source_path)
self.assertIsFile(document.archive_path)
self._assert_first_last_send_progress()
@override_settings(FILENAME_FORMAT="{correspondent}/{title}") @override_settings(FILENAME_FORMAT="{correspondent}/{title}")
@mock.patch("documents.signals.handlers.generate_unique_filename") @mock.patch("documents.signals.handlers.generate_unique_filename")
def testFilenameHandlingUnstableFormat(self, m): def testFilenameHandlingUnstableFormat(self, m):
+100 -3
View File
@@ -1,4 +1,5 @@
import datetime import datetime
import hashlib
import logging import logging
import tempfile import tempfile
from pathlib import Path from pathlib import Path
@@ -166,6 +167,52 @@ class TestFileHandling(DirectoriesMixin, FileSystemAssertsMixin, TestCase):
) )
self.assertEqual(document.filename, "none/none.pdf") self.assertEqual(document.filename, "none/none.pdf")
@override_settings(FILENAME_FORMAT=None)
def test_stale_save_recovers_already_moved_files(self) -> None:
old_storage_path = StoragePath.objects.create(
name="old-path",
path="old/{{title}}",
)
new_storage_path = StoragePath.objects.create(
name="new-path",
path="new/{{title}}",
)
original_bytes = b"original"
archive_bytes = b"archive"
doc = Document.objects.create(
title="document",
mime_type="application/pdf",
checksum=hashlib.md5(original_bytes).hexdigest(),
archive_checksum=hashlib.md5(archive_bytes).hexdigest(),
filename="old/document.pdf",
archive_filename="old/document.pdf",
storage_path=old_storage_path,
)
create_source_path_directory(doc.source_path)
doc.source_path.write_bytes(original_bytes)
create_source_path_directory(doc.archive_path)
doc.archive_path.write_bytes(archive_bytes)
stale_doc = Document.objects.get(pk=doc.pk)
fresh_doc = Document.objects.get(pk=doc.pk)
fresh_doc.storage_path = new_storage_path
fresh_doc.save()
doc.refresh_from_db()
self.assertEqual(doc.filename, "new/document.pdf")
self.assertEqual(doc.archive_filename, "new/document.pdf")
stale_doc.storage_path = new_storage_path
stale_doc.save()
doc.refresh_from_db()
self.assertEqual(doc.filename, "new/document.pdf")
self.assertEqual(doc.archive_filename, "new/document.pdf")
self.assertIsFile(doc.source_path)
self.assertIsFile(doc.archive_path)
self.assertIsNotFile(settings.ORIGINALS_DIR / "old" / "document.pdf")
self.assertIsNotFile(settings.ARCHIVE_DIR / "old" / "document.pdf")
@override_settings(FILENAME_FORMAT="{correspondent}/{correspondent}") @override_settings(FILENAME_FORMAT="{correspondent}/{correspondent}")
def test_document_delete(self): def test_document_delete(self):
document = Document() document = Document()
@@ -1341,6 +1388,41 @@ class TestFilenameGeneration(DirectoriesMixin, TestCase):
Path("somepath/asn-201-400/asn-3xx/Does Matter.pdf"), Path("somepath/asn-201-400/asn-3xx/Does Matter.pdf"),
) )
def test_template_related_context_keeps_legacy_string_coercion(self):
"""
GIVEN:
- A storage path template that uses related objects directly as strings
WHEN:
- Filepath for a document with this format is called
THEN:
- Related objects coerce to their names (legacy behavior)
- Explicit attribute access remains available for new templates
"""
sp = StoragePath.objects.create(
name="PARTNER",
path=(
"{{ document.storage_path|lower }} / "
"{{ document.correspondent|lower|replace('mi:', 'mieter/') }} / "
"{{ document_type|lower }} / "
"{{ title|lower }}"
),
)
doc = Document.objects.create(
title="scan_017562",
created=datetime.date(2025, 7, 2),
added=timezone.make_aware(datetime.datetime(2026, 3, 3, 11, 53, 16)),
mime_type="application/pdf",
checksum="test-checksum",
storage_path=sp,
correspondent=Correspondent.objects.create(name="mi:kochkach"),
document_type=DocumentType.objects.create(name="Mietvertrag"),
)
self.assertEqual(
generate_filename(doc),
Path("partner/mieter/kochkach/mietvertrag/scan_017562.pdf"),
)
@override_settings( @override_settings(
FILENAME_FORMAT="{{creation_date}}/{{ title_name_str }}", FILENAME_FORMAT="{{creation_date}}/{{ title_name_str }}",
) )
@@ -1382,11 +1464,11 @@ class TestFilenameGeneration(DirectoriesMixin, TestCase):
def test_template_with_security(self): def test_template_with_security(self):
""" """
GIVEN: GIVEN:
- Filename format with one or more undefined variables - Filename format with an unavailable document attribute
WHEN: WHEN:
- Filepath for a document with this format is called - Filepath for a document with this format is called
THEN: THEN:
- The first undefined variable is logged - The missing attribute is logged
- The default format is used - The default format is used
""" """
doc_a = Document.objects.create( doc_a = Document.objects.create(
@@ -1408,7 +1490,7 @@ class TestFilenameGeneration(DirectoriesMixin, TestCase):
self.assertEqual(len(capture.output), 1) self.assertEqual(len(capture.output), 1)
self.assertEqual( self.assertEqual(
capture.output[0], capture.output[0],
"WARNING:paperless.templating:Template attempted restricted operation: <bound method Model.save of <Document: 2020-06-25 Does Matter>> is not safely callable", "ERROR:paperless.templating:Template variable error: 'dict object' has no attribute 'save'",
) )
def test_template_with_custom_fields(self): def test_template_with_custom_fields(self):
@@ -1699,6 +1781,21 @@ class TestCustomFieldFilenameUpdates(
self.assertTrue(Path(self.doc.source_path).is_file()) self.assertTrue(Path(self.doc.source_path).is_file())
self.assertLessEqual(m.call_count, 1) self.assertLessEqual(m.call_count, 1)
@override_settings(FILENAME_FORMAT=None)
def test_overlong_storage_path_keeps_existing_filename(self):
initial_filename = generate_filename(self.doc)
Document.objects.filter(pk=self.doc.pk).update(filename=str(initial_filename))
self.doc.refresh_from_db()
Path(self.doc.source_path).parent.mkdir(parents=True, exist_ok=True)
Path(self.doc.source_path).touch()
self.doc.storage_path = StoragePath.objects.create(path="a" * 1100)
self.doc.save()
self.doc.refresh_from_db()
self.assertEqual(Path(self.doc.filename), initial_filename)
self.assertTrue(Path(self.doc.source_path).is_file())
class TestPathDateLocalization: class TestPathDateLocalization:
""" """
+1 -1
View File
@@ -180,7 +180,7 @@ class TestRewriteNaturalDateKeywords(SimpleTestCase):
( (
"added:this year", "added:this year",
datetime(2025, 7, 15, 12, 0, 0, tzinfo=timezone.utc), datetime(2025, 7, 15, 12, 0, 0, tzinfo=timezone.utc),
("added:[20250101", "TO 20250715"), ("added:[20250101", "TO 20251231"),
), ),
( (
"added:previous year", "added:previous year",
+10
View File
@@ -147,6 +147,16 @@ class TestTagHierarchy(APITestCase):
assert serializer.data # triggers serialization assert serializer.data # triggers serialization
assert "document_count_filter" in context assert "document_count_filter" in context
def test_tag_list_can_order_by_document_count_with_children(self) -> None:
self.document.tags.add(self.child)
response = self.client.get(
"/api/tags/",
{"ordering": "document_count"},
)
assert response.status_code == 200
def test_cannot_set_parent_to_self(self): def test_cannot_set_parent_to_self(self):
tag = Tag.objects.create(name="Selfie") tag = Tag.objects.create(name="Selfie")
resp = self.client.patch( resp = self.client.patch(
+234
View File
@@ -25,6 +25,7 @@ from rest_framework.test import APIClient
from rest_framework.test import APITestCase from rest_framework.test import APITestCase
from documents.file_handling import create_source_path_directory from documents.file_handling import create_source_path_directory
from documents.file_handling import generate_filename
from documents.file_handling import generate_unique_filename from documents.file_handling import generate_unique_filename
from documents.signals.handlers import run_workflows from documents.signals.handlers import run_workflows
from documents.workflows.webhooks import send_webhook from documents.workflows.webhooks import send_webhook
@@ -898,6 +899,121 @@ class TestWorkflows(
expected_str = f"Document matched {trigger} from {w}" expected_str = f"Document matched {trigger} from {w}"
self.assertIn(expected_str, cm.output[0]) self.assertIn(expected_str, cm.output[0])
def test_workflow_assign_custom_field_keeps_storage_filename_in_sync(self) -> None:
"""
GIVEN:
- Existing document with a storage path template that depends on a custom field
- Existing workflow triggered on document update assigning that custom field
WHEN:
- Workflow runs for the document
THEN:
- The database filename remains aligned with the moved file on disk
"""
storage_path = StoragePath.objects.create(
name="workflow-custom-field-path",
path="{{ custom_fields|get_cf_value('Custom Field 1', 'none') }}/{{ title }}",
)
doc = Document.objects.create(
title="workflow custom field sync",
mime_type="application/pdf",
checksum="workflow-custom-field-sync",
storage_path=storage_path,
original_filename="workflow-custom-field-sync.pdf",
)
CustomFieldInstance.objects.create(
document=doc,
field=self.cf1,
value_text="initial",
)
generated = generate_unique_filename(doc)
destination = (settings.ORIGINALS_DIR / generated).resolve()
create_source_path_directory(destination)
shutil.copy(self.SAMPLE_DIR / "simple.pdf", destination)
Document.objects.filter(pk=doc.pk).update(filename=generated.as_posix())
doc.refresh_from_db()
trigger = WorkflowTrigger.objects.create(
type=WorkflowTrigger.WorkflowTriggerType.DOCUMENT_UPDATED,
)
action = WorkflowAction.objects.create(
type=WorkflowAction.WorkflowActionType.ASSIGNMENT,
assign_custom_fields_values={self.cf1.pk: "cars"},
)
action.assign_custom_fields.add(self.cf1.pk)
workflow = Workflow.objects.create(
name="Workflow custom field filename sync",
order=0,
)
workflow.triggers.add(trigger)
workflow.actions.add(action)
workflow.save()
run_workflows(WorkflowTrigger.WorkflowTriggerType.DOCUMENT_UPDATED, doc)
doc.refresh_from_db()
expected_filename = generate_filename(doc)
self.assertEqual(Path(doc.filename), expected_filename)
self.assertTrue(doc.source_path.is_file())
def test_workflow_document_updated_does_not_overwrite_filename(self) -> None:
"""
GIVEN:
- A document whose filename has been updated in the DB by a concurrent
bulk_update_documents task (simulating update_filename_and_move_files
completing and writing the new filename to the DB)
- A stale in-memory document instance still holding the old filename
- An active DOCUMENT_UPDATED workflow
WHEN:
- run_workflows is called with the stale in-memory instance
(as would happen in the second concurrent bulk_update_documents task)
THEN:
- The DB filename is NOT overwritten with the stale in-memory value
(regression test for GH #12386 — the race window between
refresh_from_db and document.save in run_workflows)
"""
trigger = WorkflowTrigger.objects.create(
type=WorkflowTrigger.WorkflowTriggerType.DOCUMENT_UPDATED,
)
action = WorkflowAction.objects.create(
type=WorkflowAction.WorkflowActionType.ASSIGNMENT,
assign_title="Updated by workflow",
)
workflow = Workflow.objects.create(name="Race condition test workflow", order=0)
workflow.triggers.add(trigger)
workflow.actions.add(action)
workflow.save()
doc = Document.objects.create(
title="race condition test",
mime_type="application/pdf",
checksum="racecondition123",
original_filename="old.pdf",
filename="old/path/old.pdf",
)
# Simulate BUD-1 completing update_filename_and_move_files:
# the DB now holds the new filename while BUD-2's in-memory instance is stale.
new_filename = "new/path/new.pdf"
Document.global_objects.filter(pk=doc.pk).update(filename=new_filename)
# The stale instance still has filename="old/path/old.pdf" in memory.
# Mock refresh_from_db so the stale value persists through run_workflows,
# replicating the race window between refresh and save.
# Mock update_filename_and_move_files to prevent file-not-found errors
# since we are only testing DB state here.
with (
mock.patch(
"documents.signals.handlers.update_filename_and_move_files",
),
mock.patch.object(Document, "refresh_from_db"),
):
run_workflows(WorkflowTrigger.WorkflowTriggerType.DOCUMENT_UPDATED, doc)
# The DB filename must not have been reverted to the stale old value.
doc.refresh_from_db()
self.assertEqual(doc.filename, new_filename)
def test_document_added_workflow(self): def test_document_added_workflow(self):
trigger = WorkflowTrigger.objects.create( trigger = WorkflowTrigger.objects.create(
type=WorkflowTrigger.WorkflowTriggerType.DOCUMENT_ADDED, type=WorkflowTrigger.WorkflowTriggerType.DOCUMENT_ADDED,
@@ -3396,6 +3512,124 @@ class TestWorkflows(
as_json=False, as_json=False,
) )
@mock.patch("documents.signals.handlers.execute_webhook_action")
def test_workflow_webhook_action_does_not_overwrite_concurrent_tags(
self,
mock_execute_webhook_action,
):
"""
GIVEN:
- A document updated workflow with only a webhook action
- A tag update that happens after run_workflows
WHEN:
- The workflow runs
THEN:
- The concurrent tag update is preserved
"""
trigger = WorkflowTrigger.objects.create(
type=WorkflowTrigger.WorkflowTriggerType.DOCUMENT_UPDATED,
)
webhook_action = WorkflowActionWebhook.objects.create(
use_params=False,
body="Test message: {{doc_url}}",
url="http://paperless-ngx.com",
include_document=False,
)
action = WorkflowAction.objects.create(
type=WorkflowAction.WorkflowActionType.WEBHOOK,
webhook=webhook_action,
)
w = Workflow.objects.create(
name="Webhook workflow",
order=0,
)
w.triggers.add(trigger)
w.actions.add(action)
w.save()
inbox_tag = Tag.objects.create(name="inbox")
error_tag = Tag.objects.create(name="error")
doc = Document.objects.create(
title="sample test",
correspondent=self.c,
original_filename="sample.pdf",
)
doc.tags.add(inbox_tag)
def add_error_tag(*args, **kwargs):
Document.objects.get(pk=doc.pk).tags.add(error_tag)
mock_execute_webhook_action.side_effect = add_error_tag
run_workflows(WorkflowTrigger.WorkflowTriggerType.DOCUMENT_UPDATED, doc)
doc.refresh_from_db()
self.assertCountEqual(doc.tags.all(), [inbox_tag, error_tag])
@mock.patch("documents.signals.handlers.execute_webhook_action")
def test_workflow_tag_actions_do_not_overwrite_concurrent_tags(
self,
mock_execute_webhook_action,
):
"""
GIVEN:
- A document updated workflow that clears tags and assigns an inbox tag
- A later tag update that happens before the workflow finishes
WHEN:
- The workflow runs
THEN:
- The later tag update is preserved
"""
trigger = WorkflowTrigger.objects.create(
type=WorkflowTrigger.WorkflowTriggerType.DOCUMENT_UPDATED,
)
removal_action = WorkflowAction.objects.create(
type=WorkflowAction.WorkflowActionType.REMOVAL,
remove_all_tags=True,
)
assign_action = WorkflowAction.objects.create(
assign_owner=self.user2,
)
assign_action.assign_tags.add(self.t1)
webhook_action = WorkflowActionWebhook.objects.create(
use_params=False,
body="Test message: {{doc_url}}",
url="http://paperless-ngx.com",
include_document=False,
)
notify_action = WorkflowAction.objects.create(
type=WorkflowAction.WorkflowActionType.WEBHOOK,
webhook=webhook_action,
)
w = Workflow.objects.create(
name="Workflow tag race",
order=0,
)
w.triggers.add(trigger)
w.actions.add(removal_action)
w.actions.add(assign_action)
w.actions.add(notify_action)
w.save()
doc = Document.objects.create(
title="sample test",
correspondent=self.c,
original_filename="sample.pdf",
owner=self.user3,
)
doc.tags.add(self.t2, self.t3)
def add_error_tag(*args, **kwargs):
Document.objects.get(pk=doc.pk).tags.add(self.t2)
mock_execute_webhook_action.side_effect = add_error_tag
run_workflows(WorkflowTrigger.WorkflowTriggerType.DOCUMENT_UPDATED, doc)
doc.refresh_from_db()
self.assertEqual(doc.owner, self.user2)
self.assertCountEqual(doc.tags.all(), [self.t1, self.t2])
@override_settings( @override_settings(
PAPERLESS_URL="http://localhost:8000", PAPERLESS_URL="http://localhost:8000",
) )
+131 -44
View File
@@ -32,7 +32,6 @@ from django.db.models import Count
from django.db.models import IntegerField from django.db.models import IntegerField
from django.db.models import Max from django.db.models import Max
from django.db.models import Model from django.db.models import Model
from django.db.models import Q
from django.db.models import Sum from django.db.models import Sum
from django.db.models import When from django.db.models import When
from django.db.models.functions import Length from django.db.models.functions import Length
@@ -50,6 +49,7 @@ from django.utils import timezone
from django.utils.decorators import method_decorator from django.utils.decorators import method_decorator
from django.utils.timezone import make_aware from django.utils.timezone import make_aware
from django.utils.translation import get_language from django.utils.translation import get_language
from django.utils.translation import gettext_lazy as _
from django.views import View from django.views import View
from django.views.decorators.cache import cache_control from django.views.decorators.cache import cache_control
from django.views.decorators.http import condition from django.views.decorators.http import condition
@@ -71,10 +71,12 @@ from rest_framework import parsers
from rest_framework import serializers from rest_framework import serializers
from rest_framework.decorators import action from rest_framework.decorators import action
from rest_framework.exceptions import NotFound from rest_framework.exceptions import NotFound
from rest_framework.exceptions import PermissionDenied
from rest_framework.exceptions import ValidationError from rest_framework.exceptions import ValidationError
from rest_framework.filters import OrderingFilter from rest_framework.filters import OrderingFilter
from rest_framework.filters import SearchFilter from rest_framework.filters import SearchFilter
from rest_framework.generics import GenericAPIView from rest_framework.generics import GenericAPIView
from rest_framework.mixins import CreateModelMixin
from rest_framework.mixins import DestroyModelMixin from rest_framework.mixins import DestroyModelMixin
from rest_framework.mixins import ListModelMixin from rest_framework.mixins import ListModelMixin
from rest_framework.mixins import RetrieveModelMixin from rest_framework.mixins import RetrieveModelMixin
@@ -128,6 +130,7 @@ from documents.matching import match_storage_paths
from documents.matching import match_tags from documents.matching import match_tags
from documents.models import Correspondent from documents.models import Correspondent
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 DocumentType from documents.models import DocumentType
from documents.models import Note from documents.models import Note
@@ -147,6 +150,7 @@ from documents.permissions import PaperlessAdminPermissions
from documents.permissions import PaperlessNotePermissions from documents.permissions import PaperlessNotePermissions
from documents.permissions import PaperlessObjectPermissions from documents.permissions import PaperlessObjectPermissions
from documents.permissions import ViewDocumentsPermissions from documents.permissions import ViewDocumentsPermissions
from documents.permissions import annotate_document_count_for_related_queryset
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_objects_for_user_owner_aware from documents.permissions import get_objects_for_user_owner_aware
from documents.permissions import has_perms_owner_aware from documents.permissions import has_perms_owner_aware
@@ -370,22 +374,37 @@ class PermissionsAwareDocumentCountMixin(BulkPermissionMixin, PassUserMixin):
Mixin to add document count to queryset, permissions-aware if needed Mixin to add document count to queryset, permissions-aware if needed
""" """
# Default is simple relation path, override for through-table/count specialization.
document_count_through = None
document_count_source_field = None
def get_document_count_filter(self): def get_document_count_filter(self):
request = getattr(self, "request", None) request = getattr(self, "request", None)
user = getattr(request, "user", None) if request else None user = getattr(request, "user", None) if request else None
return get_document_count_filter_for_user(user) return get_document_count_filter_for_user(user)
def get_queryset(self): def get_queryset(self):
base_qs = super().get_queryset()
# Use optimized through-table counting when configured.
if self.document_count_through:
user = getattr(getattr(self, "request", None), "user", None)
return annotate_document_count_for_related_queryset(
base_qs,
through_model=self.document_count_through,
related_object_field=self.document_count_source_field,
user=user,
)
# Fallback: simple Count on relation with permission filter.
filter = self.get_document_count_filter() filter = self.get_document_count_filter()
return ( return base_qs.annotate(
super() document_count=Count("documents", filter=filter),
.get_queryset()
.annotate(document_count=Count("documents", filter=filter))
) )
@extend_schema_view(**generate_object_with_permissions_schema(CorrespondentSerializer)) @extend_schema_view(**generate_object_with_permissions_schema(CorrespondentSerializer))
class CorrespondentViewSet(ModelViewSet, PermissionsAwareDocumentCountMixin): class CorrespondentViewSet(PermissionsAwareDocumentCountMixin, ModelViewSet):
model = Correspondent model = Correspondent
queryset = Correspondent.objects.select_related("owner").order_by(Lower("name")) queryset = Correspondent.objects.select_related("owner").order_by(Lower("name"))
@@ -422,8 +441,10 @@ class CorrespondentViewSet(ModelViewSet, PermissionsAwareDocumentCountMixin):
@extend_schema_view(**generate_object_with_permissions_schema(TagSerializer)) @extend_schema_view(**generate_object_with_permissions_schema(TagSerializer))
class TagViewSet(ModelViewSet, PermissionsAwareDocumentCountMixin): class TagViewSet(PermissionsAwareDocumentCountMixin, ModelViewSet):
model = Tag model = Tag
document_count_through = Document.tags.through
document_count_source_field = "tag_id"
queryset = Tag.objects.select_related("owner").order_by( queryset = Tag.objects.select_related("owner").order_by(
Lower("name"), Lower("name"),
@@ -448,8 +469,51 @@ class TagViewSet(ModelViewSet, PermissionsAwareDocumentCountMixin):
def get_serializer_context(self): def get_serializer_context(self):
context = super().get_serializer_context() context = super().get_serializer_context()
context["document_count_filter"] = self.get_document_count_filter() context["document_count_filter"] = self.get_document_count_filter()
if hasattr(self, "_children_map"):
context["children_map"] = self._children_map
return context return context
def list(self, request, *args, **kwargs):
"""
Build a children map once to avoid per-parent queries in the serializer.
"""
queryset = self.filter_queryset(self.get_queryset())
ordering = OrderingFilter().get_ordering(request, queryset, self) or (
Lower("name"),
)
queryset = queryset.order_by(*ordering)
all_tags = list(queryset)
descendant_pks = {pk for tag in all_tags for pk in tag.get_descendants_pks()}
if descendant_pks:
user = getattr(getattr(self, "request", None), "user", None)
children_source = list(
annotate_document_count_for_related_queryset(
Tag.objects.filter(
pk__in=descendant_pks | {t.pk for t in all_tags},
).select_related("owner"),
through_model=self.document_count_through,
related_object_field=self.document_count_source_field,
user=user,
).order_by(*ordering),
)
else:
children_source = all_tags
children_map = {}
for tag in children_source:
children_map.setdefault(tag.tn_parent_id, []).append(tag)
self._children_map = children_map
page = self.paginate_queryset(queryset)
serializer = self.get_serializer(page, many=True)
response = self.get_paginated_response(serializer.data)
if descendant_pks:
# Include children in the "all" field, if needed
response.data["all"] = [tag.pk for tag in children_source]
return response
def perform_update(self, serializer): def perform_update(self, serializer):
old_parent = self.get_object().get_parent() old_parent = self.get_object().get_parent()
tag = serializer.save() tag = serializer.save()
@@ -459,7 +523,7 @@ class TagViewSet(ModelViewSet, PermissionsAwareDocumentCountMixin):
@extend_schema_view(**generate_object_with_permissions_schema(DocumentTypeSerializer)) @extend_schema_view(**generate_object_with_permissions_schema(DocumentTypeSerializer))
class DocumentTypeViewSet(ModelViewSet, PermissionsAwareDocumentCountMixin): class DocumentTypeViewSet(PermissionsAwareDocumentCountMixin, ModelViewSet):
model = DocumentType model = DocumentType
queryset = DocumentType.objects.select_related("owner").order_by(Lower("name")) queryset = DocumentType.objects.select_related("owner").order_by(Lower("name"))
@@ -722,7 +786,7 @@ class DocumentViewSet(
def get_queryset(self): def get_queryset(self):
return ( return (
Document.objects.distinct() Document.objects.distinct()
.order_by("-created") .order_by("-created", "-id")
.annotate(num_notes=Count("notes")) .annotate(num_notes=Count("notes"))
.select_related("correspondent", "storage_path", "document_type", "owner") .select_related("correspondent", "storage_path", "document_type", "owner")
.prefetch_related("tags", "custom_fields", "notes") .prefetch_related("tags", "custom_fields", "notes")
@@ -977,6 +1041,7 @@ class DocumentViewSet(
methods=["get", "post", "delete"], methods=["get", "post", "delete"],
detail=True, detail=True,
permission_classes=[PaperlessNotePermissions], permission_classes=[PaperlessNotePermissions],
pagination_class=None,
filter_backends=[], filter_backends=[],
) )
def notes(self, request, pk=None): def notes(self, request, pk=None):
@@ -1060,7 +1125,7 @@ class DocumentViewSet(
): ):
return HttpResponseForbidden("Insufficient permissions to delete notes") return HttpResponseForbidden("Insufficient permissions to delete notes")
note = Note.objects.get(id=int(request.GET.get("id"))) note = Note.objects.get(id=int(request.GET.get("id")), document=doc)
if settings.AUDIT_LOG_ENABLED: if settings.AUDIT_LOG_ENABLED:
LogEntry.objects.log_create( LogEntry.objects.log_create(
instance=doc, instance=doc,
@@ -1307,11 +1372,28 @@ class UnifiedSearchViewSet(DocumentViewSet):
filtered_queryset = super().filter_queryset(queryset) filtered_queryset = super().filter_queryset(queryset)
if self._is_search_request(): if self._is_search_request():
from documents import index
if "query" in self.request.query_params: if "query" in self.request.query_params:
from documents import index
query_class = index.DelayedFullTextQuery query_class = index.DelayedFullTextQuery
elif "more_like_id" in self.request.query_params: elif "more_like_id" in self.request.query_params:
try:
more_like_doc_id = int(self.request.query_params["more_like_id"])
more_like_doc = Document.objects.select_related("owner").get(
pk=more_like_doc_id,
)
except (TypeError, ValueError, Document.DoesNotExist):
raise PermissionDenied(_("Invalid more_like_id"))
if not has_perms_owner_aware(
self.request.user,
"view_document",
more_like_doc,
):
raise PermissionDenied(_("Insufficient permissions."))
from documents import index
query_class = index.DelayedMoreLikeThisQuery query_class = index.DelayedMoreLikeThisQuery
else: else:
raise ValueError raise ValueError
@@ -1347,6 +1429,8 @@ class UnifiedSearchViewSet(DocumentViewSet):
return response return response
except NotFound: except NotFound:
raise raise
except PermissionDenied as e:
return HttpResponseForbidden(str(e.detail))
except Exception as e: except Exception as e:
logger.warning(f"An error occurred listing search results: {e!s}") logger.warning(f"An error occurred listing search results: {e!s}")
return HttpResponseBadRequest( return HttpResponseBadRequest(
@@ -1664,6 +1748,8 @@ class PostDocumentView(GenericAPIView):
parser_classes = (parsers.MultiPartParser,) parser_classes = (parsers.MultiPartParser,)
def post(self, request, *args, **kwargs): def post(self, request, *args, **kwargs):
if not request.user.has_perm("documents.add_document"):
return HttpResponseForbidden("Insufficient permissions")
serializer = self.get_serializer(data=request.data) serializer = self.get_serializer(data=request.data)
serializer.is_valid(raise_exception=True) serializer.is_valid(raise_exception=True)
@@ -1787,6 +1873,13 @@ class SelectionDataView(GenericAPIView):
serializer.is_valid(raise_exception=True) serializer.is_valid(raise_exception=True)
ids = serializer.validated_data.get("documents") ids = serializer.validated_data.get("documents")
permitted_documents = get_objects_for_user_owner_aware(
request.user,
"documents.view_document",
Document,
)
if permitted_documents.filter(pk__in=ids).count() != len(ids):
return HttpResponseForbidden("Insufficient permissions")
correspondents = Correspondent.objects.annotate( correspondents = Correspondent.objects.annotate(
document_count=Count( document_count=Count(
@@ -2041,13 +2134,21 @@ class GlobalSearchView(PassUserMixin):
) )
groups = groups[:OBJECT_LIMIT] groups = groups[:OBJECT_LIMIT]
mail_rules = ( mail_rules = (
MailRule.objects.filter(name__icontains=query) get_objects_for_user_owner_aware(
request.user,
"view_mailrule",
MailRule,
).filter(name__icontains=query)
if request.user.has_perm("paperless_mail.view_mailrule") if request.user.has_perm("paperless_mail.view_mailrule")
else [] else []
) )
mail_rules = mail_rules[:OBJECT_LIMIT] mail_rules = mail_rules[:OBJECT_LIMIT]
mail_accounts = ( mail_accounts = (
MailAccount.objects.filter(name__icontains=query) get_objects_for_user_owner_aware(
request.user,
"view_mailaccount",
MailAccount,
).filter(name__icontains=query)
if request.user.has_perm("paperless_mail.view_mailaccount") if request.user.has_perm("paperless_mail.view_mailaccount")
else [] else []
) )
@@ -2303,7 +2404,7 @@ class BulkDownloadView(GenericAPIView):
@extend_schema_view(**generate_object_with_permissions_schema(StoragePathSerializer)) @extend_schema_view(**generate_object_with_permissions_schema(StoragePathSerializer))
class StoragePathViewSet(ModelViewSet, PermissionsAwareDocumentCountMixin): class StoragePathViewSet(PermissionsAwareDocumentCountMixin, ModelViewSet):
model = StoragePath model = StoragePath
queryset = StoragePath.objects.select_related("owner").order_by( queryset = StoragePath.objects.select_related("owner").order_by(
@@ -2348,7 +2449,10 @@ class StoragePathViewSet(ModelViewSet, PermissionsAwareDocumentCountMixin):
""" """
Test storage path against a document Test storage path against a document
""" """
serializer = StoragePathTestSerializer(data=request.data) serializer = StoragePathTestSerializer(
data=request.data,
context={"request": request},
)
serializer.is_valid(raise_exception=True) serializer.is_valid(raise_exception=True)
document = serializer.validated_data.get("document") document = serializer.validated_data.get("document")
@@ -2599,7 +2703,14 @@ class TasksViewSet(ReadOnlyModelViewSet):
) )
class ShareLinkViewSet(ModelViewSet, PassUserMixin): class ShareLinkViewSet(
PassUserMixin,
CreateModelMixin,
RetrieveModelMixin,
DestroyModelMixin,
ListModelMixin,
GenericViewSet,
):
model = ShareLink model = ShareLink
queryset = ShareLink.objects.all() queryset = ShareLink.objects.all()
@@ -2820,7 +2931,7 @@ class WorkflowViewSet(ModelViewSet):
) )
class CustomFieldViewSet(ModelViewSet): class CustomFieldViewSet(PermissionsAwareDocumentCountMixin, ModelViewSet):
permission_classes = (IsAuthenticated, PaperlessObjectPermissions) permission_classes = (IsAuthenticated, PaperlessObjectPermissions)
serializer_class = CustomFieldSerializer serializer_class = CustomFieldSerializer
@@ -2832,35 +2943,11 @@ class CustomFieldViewSet(ModelViewSet):
filterset_class = CustomFieldFilterSet filterset_class = CustomFieldFilterSet
model = CustomField model = CustomField
document_count_through = CustomFieldInstance
document_count_source_field = "field_id"
queryset = CustomField.objects.all().order_by("-created") queryset = CustomField.objects.all().order_by("-created")
def get_queryset(self):
filter = (
Q(fields__document__deleted_at__isnull=True)
if self.request.user is None or self.request.user.is_superuser
else (
Q(
fields__document__deleted_at__isnull=True,
fields__document__id__in=get_objects_for_user_owner_aware(
self.request.user,
"documents.view_document",
Document,
).values_list("id", flat=True),
)
)
)
return (
super()
.get_queryset()
.annotate(
document_count=Count(
"fields",
filter=filter,
),
)
)
@extend_schema_view( @extend_schema_view(
get=extend_schema( get=extend_schema(
+4 -10
View File
@@ -16,7 +16,6 @@ logger = logging.getLogger("paperless.workflows.mutations")
def apply_assignment_to_document( def apply_assignment_to_document(
action: WorkflowAction, action: WorkflowAction,
document: Document, document: Document,
doc_tag_ids: list[int],
logging_group, logging_group,
): ):
""" """
@@ -25,12 +24,7 @@ def apply_assignment_to_document(
action: WorkflowAction, annotated with 'has_assign_*' boolean fields action: WorkflowAction, annotated with 'has_assign_*' boolean fields
""" """
if action.has_assign_tags: if action.has_assign_tags:
tag_ids_to_add: set[int] = set() document.add_nested_tags(action.assign_tags.all())
for tag in action.assign_tags.all():
tag_ids_to_add.add(tag.pk)
tag_ids_to_add.update(int(pk) for pk in tag.get_ancestors_pks())
doc_tag_ids[:] = list(set(doc_tag_ids) | tag_ids_to_add)
if action.assign_correspondent: if action.assign_correspondent:
document.correspondent = action.assign_correspondent document.correspondent = action.assign_correspondent
@@ -197,7 +191,6 @@ def apply_assignment_to_overrides(
def apply_removal_to_document( def apply_removal_to_document(
action: WorkflowAction, action: WorkflowAction,
document: Document, document: Document,
doc_tag_ids: list[int],
): ):
""" """
Apply removal actions to a Document instance. Apply removal actions to a Document instance.
@@ -206,14 +199,15 @@ def apply_removal_to_document(
""" """
if action.remove_all_tags: if action.remove_all_tags:
doc_tag_ids.clear() document.tags.clear()
else: else:
tag_ids_to_remove: set[int] = set() tag_ids_to_remove: set[int] = set()
for tag in action.remove_tags.all(): for tag in action.remove_tags.all():
tag_ids_to_remove.add(tag.pk) tag_ids_to_remove.add(tag.pk)
tag_ids_to_remove.update(int(pk) for pk in tag.get_descendants_pks()) tag_ids_to_remove.update(int(pk) for pk in tag.get_descendants_pks())
doc_tag_ids[:] = [t for t in doc_tag_ids if t not in tag_ids_to_remove] if tag_ids_to_remove:
document.tags.remove(*tag_ids_to_remove)
if action.remove_all_correspondents or ( if action.remove_all_correspondents or (
document.correspondent document.correspondent
+16 -4
View File
@@ -20,9 +20,6 @@ def get_workflows_for_trigger(
wrap it in a list; otherwise fetch enabled workflows for the trigger with wrap it in a list; otherwise fetch enabled workflows for the trigger with
the prefetches used by the runner. the prefetches used by the runner.
""" """
if workflow_to_run is not None:
return [workflow_to_run]
annotated_actions = ( annotated_actions = (
WorkflowAction.objects.select_related( WorkflowAction.objects.select_related(
"assign_correspondent", "assign_correspondent",
@@ -105,10 +102,25 @@ def get_workflows_for_trigger(
) )
) )
action_prefetch = Prefetch(
"actions",
queryset=annotated_actions.order_by("order", "pk"),
)
if workflow_to_run is not None:
return (
Workflow.objects.filter(pk=workflow_to_run.pk)
.prefetch_related(
action_prefetch,
"triggers",
)
.distinct()
)
return ( return (
Workflow.objects.filter(enabled=True, triggers__type=trigger_type) Workflow.objects.filter(enabled=True, triggers__type=trigger_type)
.prefetch_related( .prefetch_related(
Prefetch("actions", queryset=annotated_actions), action_prefetch,
"triggers", "triggers",
) )
.order_by("order") .order_by("order")
+8
View File
@@ -83,3 +83,11 @@ class PaperlessBasicAuthentication(authentication.BasicAuthentication):
raise exceptions.AuthenticationFailed("MFA required") raise exceptions.AuthenticationFailed("MFA required")
return user_tuple return user_tuple
def authenticate_header(self, request):
auth_header = request.META.get("HTTP_AUTHORIZATION", "")
if auth_header.lower().startswith("basic "):
return super().authenticate_header(request)
# Still 401 for anonymous API access
return authentication.TokenAuthentication.keyword
+1 -1
View File
@@ -1,6 +1,6 @@
from typing import Final from typing import Final
__version__: Final[tuple[int, int, int]] = (2, 20, 4) __version__: Final[tuple[int, int, int]] = (2, 20, 14)
# Version string like X.Y.Z # Version string like X.Y.Z
__full_version_str__: Final[str] = ".".join(map(str, __version__)) __full_version_str__: Final[str] = ".".join(map(str, __version__))
# Version string like X.Y # Version string like X.Y
+47 -6
View File
@@ -25,6 +25,8 @@ from drf_spectacular.utils import extend_schema_view
from rest_framework.authtoken.models import Token from rest_framework.authtoken.models import Token
from rest_framework.authtoken.views import ObtainAuthToken from rest_framework.authtoken.views import ObtainAuthToken
from rest_framework.decorators import action from rest_framework.decorators import action
from rest_framework.exceptions import ValidationError
from rest_framework.fields import BooleanField
from rest_framework.filters import OrderingFilter from rest_framework.filters import OrderingFilter
from rest_framework.generics import GenericAPIView from rest_framework.generics import GenericAPIView
from rest_framework.pagination import PageNumberPagination from rest_framework.pagination import PageNumberPagination
@@ -103,6 +105,7 @@ class FaviconView(View):
class UserViewSet(ModelViewSet): class UserViewSet(ModelViewSet):
_BOOL_NOT_PROVIDED = object()
model = User model = User
queryset = User.objects.exclude( queryset = User.objects.exclude(
@@ -116,27 +119,65 @@ class UserViewSet(ModelViewSet):
filterset_class = UserFilterSet filterset_class = UserFilterSet
ordering_fields = ("username",) ordering_fields = ("username",)
@staticmethod
def _parse_requested_bool(data, key: str):
if key not in data:
return UserViewSet._BOOL_NOT_PROVIDED
try:
return BooleanField().to_internal_value(data.get(key))
except ValidationError:
# Let serializer validation report invalid values as 400 responses
return UserViewSet._BOOL_NOT_PROVIDED
def create(self, request, *args, **kwargs): def create(self, request, *args, **kwargs):
if not request.user.is_superuser and request.data.get("is_superuser") is True: requested_is_superuser = self._parse_requested_bool(
return HttpResponseForbidden( request.data,
"Superuser status can only be granted by a superuser", "is_superuser",
) )
requested_is_staff = self._parse_requested_bool(request.data, "is_staff")
if not request.user.is_superuser:
if requested_is_superuser is True:
return HttpResponseForbidden(
"Superuser status can only be granted by a superuser",
)
if requested_is_staff is True:
return HttpResponseForbidden(
"Staff status can only be granted by a superuser",
)
return super().create(request, *args, **kwargs) return super().create(request, *args, **kwargs)
def update(self, request, *args, **kwargs): def update(self, request, *args, **kwargs):
user_to_update: User = self.get_object() user_to_update: User = self.get_object()
if not request.user.is_superuser and user_to_update.is_superuser: if not request.user.is_superuser and user_to_update.is_superuser:
return HttpResponseForbidden( return HttpResponseForbidden(
"Superusers can only be modified by other superusers", "Superusers can only be modified by other superusers",
) )
requested_is_superuser = self._parse_requested_bool(
request.data,
"is_superuser",
)
requested_is_staff = self._parse_requested_bool(request.data, "is_staff")
if ( if (
not request.user.is_superuser not request.user.is_superuser
and request.data.get("is_superuser") is not None and requested_is_superuser is not self._BOOL_NOT_PROVIDED
and request.data.get("is_superuser") != user_to_update.is_superuser and requested_is_superuser != user_to_update.is_superuser
): ):
return HttpResponseForbidden( return HttpResponseForbidden(
"Superuser status can only be changed by a superuser", "Superuser status can only be changed by a superuser",
) )
if (
not request.user.is_superuser
and requested_is_staff is not self._BOOL_NOT_PROVIDED
and requested_is_staff != user_to_update.is_staff
):
return HttpResponseForbidden(
"Staff status can only be changed by a superuser",
)
return super().update(request, *args, **kwargs) return super().update(request, *args, **kwargs)
@extend_schema( @extend_schema(
+1
View File
@@ -472,6 +472,7 @@ class MailAccountHandler(LoggingMixin):
name=name, name=name,
defaults={ defaults={
"match": name, "match": name,
"matching_algorithm": Correspondent.MATCH_LITERAL,
}, },
)[0] )[0]
except DatabaseError as e: except DatabaseError as e:
+28 -1
View File
@@ -1,5 +1,9 @@
from django.utils.translation import gettext as _
from rest_framework import serializers from rest_framework import serializers
from rest_framework.exceptions import PermissionDenied
from documents.permissions import get_objects_for_user_owner_aware
from documents.permissions import has_perms_owner_aware
from documents.serialisers import CorrespondentField from documents.serialisers import CorrespondentField
from documents.serialisers import DocumentTypeField from documents.serialisers import DocumentTypeField
from documents.serialisers import OwnedObjectSerializer from documents.serialisers import OwnedObjectSerializer
@@ -56,7 +60,18 @@ class MailAccountSerializer(OwnedObjectSerializer):
class AccountField(serializers.PrimaryKeyRelatedField): class AccountField(serializers.PrimaryKeyRelatedField):
def get_queryset(self): def get_queryset(self):
return MailAccount.objects.all().order_by("-id") user = getattr(self.context.get("request"), "user", None)
if user is None:
user = getattr(self.root, "user", None)
if user is None:
return MailAccount.objects.none()
return get_objects_for_user_owner_aware(
user,
"change_mailaccount",
MailAccount,
).order_by("-id")
class MailRuleSerializer(OwnedObjectSerializer): class MailRuleSerializer(OwnedObjectSerializer):
@@ -127,6 +142,18 @@ class MailRuleSerializer(OwnedObjectSerializer):
return attrs return attrs
def validate_account(self, account):
if self.user is not None and has_perms_owner_aware(
self.user,
"change_mailaccount",
account,
):
return account
raise PermissionDenied(
_("Insufficient permissions."),
)
def validate_maximum_age(self, value): def validate_maximum_age(self, value):
if value > 36500: # ~100 years if value > 36500: # ~100 years
raise serializers.ValidationError("Maximum mail age is unreasonably large.") raise serializers.ValidationError("Maximum mail age is unreasonably large.")
+144
View File
@@ -272,6 +272,24 @@ class TestAPIMailAccounts(DirectoriesMixin, APITestCase):
self.assertEqual(response.status_code, status.HTTP_200_OK) self.assertEqual(response.status_code, status.HTTP_200_OK)
self.assertEqual(response.data["success"], True) self.assertEqual(response.data["success"], True)
def test_mail_account_test_existing_nonexistent_id_forbidden(self):
response = self.client.post(
f"{self.ENDPOINT}test/",
json.dumps(
{
"id": 999999,
"imap_server": "server.example.com",
"imap_port": 443,
"imap_security": MailAccount.ImapSecurity.SSL,
"username": "admin",
"password": "******",
},
),
content_type="application/json",
)
self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN)
self.assertEqual(response.content.decode(), "Insufficient permissions")
def test_get_mail_accounts_owner_aware(self): def test_get_mail_accounts_owner_aware(self):
""" """
GIVEN: GIVEN:
@@ -614,6 +632,132 @@ class TestAPIMailRules(DirectoriesMixin, APITestCase):
self.assertEqual(returned_rule1.name, "Updated Name 1") self.assertEqual(returned_rule1.name, "Updated Name 1")
self.assertEqual(returned_rule1.action, MailRule.MailAction.DELETE) self.assertEqual(returned_rule1.action, MailRule.MailAction.DELETE)
def test_create_mail_rule_scopes_accounts(self):
other_user = User.objects.create_user(username="mail-owner")
foreign_account = MailAccount.objects.create(
name="ForeignEmail",
username="username1",
password="password1",
imap_server="server.example.com",
imap_port=443,
imap_security=MailAccount.ImapSecurity.SSL,
character_set="UTF-8",
owner=other_user,
)
response = self.client.post(
self.ENDPOINT,
data={
"name": "Rule1",
"account": foreign_account.pk,
"folder": "INBOX",
"filter_from": "from@example.com",
"maximum_age": 30,
"action": MailRule.MailAction.MARK_READ,
"assign_title_from": MailRule.TitleSource.FROM_SUBJECT,
"assign_correspondent_from": MailRule.CorrespondentSource.FROM_NOTHING,
"order": 0,
"attachment_type": MailRule.AttachmentProcessing.ATTACHMENTS_ONLY,
},
)
missing_response = self.client.post(
self.ENDPOINT,
data={
"name": "Rule1",
"account": foreign_account.pk + 1000,
"folder": "INBOX",
"filter_from": "from@example.com",
"maximum_age": 30,
"action": MailRule.MailAction.MARK_READ,
"assign_title_from": MailRule.TitleSource.FROM_SUBJECT,
"assign_correspondent_from": MailRule.CorrespondentSource.FROM_NOTHING,
"order": 0,
"attachment_type": MailRule.AttachmentProcessing.ATTACHMENTS_ONLY,
},
)
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
self.assertEqual(missing_response.status_code, status.HTTP_400_BAD_REQUEST)
self.assertEqual(response.data["account"][0].code, "does_not_exist")
self.assertEqual(missing_response.data["account"][0].code, "does_not_exist")
self.assertEqual(MailRule.objects.count(), 0)
def test_create_mail_rule_allowed_for_granted_account_change_permission(self):
other_user = User.objects.create_user(username="mail-owner")
foreign_account = MailAccount.objects.create(
name="ForeignEmail",
username="username1",
password="password1",
imap_server="server.example.com",
imap_port=443,
imap_security=MailAccount.ImapSecurity.SSL,
character_set="UTF-8",
owner=other_user,
)
assign_perm("change_mailaccount", self.user, foreign_account)
response = self.client.post(
self.ENDPOINT,
data={
"name": "Rule1",
"account": foreign_account.pk,
"folder": "INBOX",
"filter_from": "from@example.com",
"maximum_age": 30,
"action": MailRule.MailAction.MARK_READ,
"assign_title_from": MailRule.TitleSource.FROM_SUBJECT,
"assign_correspondent_from": MailRule.CorrespondentSource.FROM_NOTHING,
"order": 0,
"attachment_type": MailRule.AttachmentProcessing.ATTACHMENTS_ONLY,
},
)
self.assertEqual(response.status_code, status.HTTP_201_CREATED)
self.assertEqual(MailRule.objects.get().account, foreign_account)
def test_update_mail_rule_forbidden_for_unpermitted_account(self):
own_account = MailAccount.objects.create(
name="Email1",
username="username1",
password="password1",
imap_server="server.example.com",
imap_port=443,
imap_security=MailAccount.ImapSecurity.SSL,
character_set="UTF-8",
)
other_user = User.objects.create_user(username="mail-owner")
foreign_account = MailAccount.objects.create(
name="ForeignEmail",
username="username2",
password="password2",
imap_server="server.example.com",
imap_port=443,
imap_security=MailAccount.ImapSecurity.SSL,
character_set="UTF-8",
owner=other_user,
)
rule1 = MailRule.objects.create(
name="Rule1",
account=own_account,
folder="INBOX",
filter_from="from@example.com",
maximum_age=30,
action=MailRule.MailAction.MARK_READ,
assign_title_from=MailRule.TitleSource.FROM_SUBJECT,
assign_correspondent_from=MailRule.CorrespondentSource.FROM_NOTHING,
order=0,
attachment_type=MailRule.AttachmentProcessing.ATTACHMENTS_ONLY,
)
response = self.client.patch(
f"{self.ENDPOINT}{rule1.pk}/",
data={"account": foreign_account.pk},
)
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
rule1.refresh_from_db()
self.assertEqual(rule1.account, own_account)
def test_get_mail_rules_owner_aware(self): def test_get_mail_rules_owner_aware(self):
""" """
GIVEN: GIVEN:
+54 -1
View File
@@ -9,6 +9,7 @@ from datetime import timedelta
from unittest import mock from unittest import mock
import pytest import pytest
from django.contrib.auth.models import Permission
from django.contrib.auth.models import User from django.contrib.auth.models import User
from django.core.management import call_command from django.core.management import call_command
from django.db import DatabaseError from django.db import DatabaseError
@@ -447,7 +448,7 @@ class TestMail(
c = handler._get_correspondent(message, rule) c = handler._get_correspondent(message, rule)
self.assertIsNotNone(c) self.assertIsNotNone(c)
self.assertEqual(c.name, "someone@somewhere.com") self.assertEqual(c.name, "someone@somewhere.com")
self.assertEqual(c.matching_algorithm, MatchingModel.MATCH_ANY) self.assertEqual(c.matching_algorithm, MatchingModel.MATCH_LITERAL)
self.assertEqual(c.match, "someone@somewhere.com") self.assertEqual(c.match, "someone@somewhere.com")
c = handler._get_correspondent(message2, rule) c = handler._get_correspondent(message2, rule)
self.assertIsNotNone(c) self.assertIsNotNone(c)
@@ -1699,6 +1700,10 @@ class TestMailAccountTestView(APITestCase):
username="testuser", username="testuser",
password="testpassword", password="testpassword",
) )
self.user.user_permissions.add(
*Permission.objects.filter(codename__in=["add_mailaccount"]),
)
self.user.save()
self.client.force_authenticate(user=self.user) self.client.force_authenticate(user=self.user)
self.url = "/api/mail_accounts/test/" self.url = "/api/mail_accounts/test/"
@@ -1815,6 +1820,54 @@ class TestMailAccountTestView(APITestCase):
expected_str = "Unable to refresh oauth token" expected_str = "Unable to refresh oauth token"
self.assertIn(expected_str, error_str) self.assertIn(expected_str, error_str)
def test_mail_account_test_view_existing_forbidden_for_other_owner(self):
other_user = User.objects.create_user(
username="otheruser",
password="testpassword",
)
existing_account = MailAccount.objects.create(
name="Owned account",
imap_server="imap.example.com",
imap_port=993,
imap_security=MailAccount.ImapSecurity.SSL,
username="admin",
password="secret",
owner=other_user,
)
data = {
"id": existing_account.id,
"imap_server": "imap.example.com",
"imap_port": 993,
"imap_security": MailAccount.ImapSecurity.SSL,
"username": "admin",
"password": "****",
"is_token": False,
}
response = self.client.post(self.url, data, format="json")
self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN)
self.assertEqual(response.content.decode(), "Insufficient permissions")
def test_mail_account_test_view_requires_add_permission_without_account_id(self):
self.user.user_permissions.remove(
*Permission.objects.filter(codename__in=["add_mailaccount"]),
)
self.user.save()
data = {
"imap_server": "imap.example.com",
"imap_port": 993,
"imap_security": MailAccount.ImapSecurity.SSL,
"username": "admin",
"password": "secret",
"is_token": False,
}
response = self.client.post(self.url, data, format="json")
self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN)
self.assertEqual(response.content.decode(), "Insufficient permissions")
class TestMailAccountProcess(APITestCase): class TestMailAccountProcess(APITestCase):
def setUp(self): def setUp(self):
+27 -4
View File
@@ -86,13 +86,34 @@ class MailAccountViewSet(ModelViewSet, PassUserMixin):
request.data["name"] = datetime.datetime.now().isoformat() request.data["name"] = datetime.datetime.now().isoformat()
serializer = self.get_serializer(data=request.data) serializer = self.get_serializer(data=request.data)
serializer.is_valid(raise_exception=True) serializer.is_valid(raise_exception=True)
existing_account = None
account_id = request.data.get("id")
# account exists, use the password from there instead of *** and refresh_token / expiration # testing a new connection requires add permission
if account_id is None and not request.user.has_perms(
["paperless_mail.add_mailaccount"],
):
return HttpResponseForbidden("Insufficient permissions")
# testing an existing account requires change permission on that account
if account_id is not None:
try:
existing_account = MailAccount.objects.get(pk=account_id)
except (TypeError, ValueError, MailAccount.DoesNotExist):
return HttpResponseForbidden("Insufficient permissions")
if not has_perms_owner_aware(
request.user,
"change_mailaccount",
existing_account,
):
return HttpResponseForbidden("Insufficient permissions")
# account exists, use the password from there instead of ***
if ( if (
len(serializer.validated_data.get("password").replace("*", "")) == 0 len(serializer.validated_data.get("password").replace("*", "")) == 0
and request.data["id"] is not None and existing_account is not None
): ):
existing_account = MailAccount.objects.get(pk=request.data["id"])
serializer.validated_data["password"] = existing_account.password serializer.validated_data["password"] = existing_account.password
serializer.validated_data["account_type"] = existing_account.account_type serializer.validated_data["account_type"] = existing_account.account_type
serializer.validated_data["refresh_token"] = existing_account.refresh_token serializer.validated_data["refresh_token"] = existing_account.refresh_token
@@ -106,7 +127,8 @@ class MailAccountViewSet(ModelViewSet, PassUserMixin):
) as M: ) as M:
try: try:
if ( if (
account.is_token existing_account is not None
and account.is_token
and account.expiration is not None and account.expiration is not None
and account.expiration < timezone.now() and account.expiration < timezone.now()
): ):
@@ -248,6 +270,7 @@ class OauthCallbackView(GenericAPIView):
imap_server=imap_server, imap_server=imap_server,
refresh_token=refresh_token, refresh_token=refresh_token,
expiration=timezone.now() + timedelta(seconds=expires_in), expiration=timezone.now() + timedelta(seconds=expires_in),
owner=request.user,
defaults=defaults, defaults=defaults,
) )
return HttpResponseRedirect( return HttpResponseRedirect(
Loaded 100 of 102 files, more files were not shown because too many files have changed in this diff. Show more