Compare commits

..
Author SHA1 Message Date
dependabot[bot] 8b6f59b287 Chore(deps): Bump the actions group with 15 updates
Bumps the actions group with 15 updates:

| Package | From | To |
| --- | --- | --- |
| [dorny/paths-filter](https://github.com/dorny/paths-filter) | `4.0.2` | `4.0.3` |
| [astral-sh/setup-uv](https://github.com/astral-sh/setup-uv) | `9.0.0` | `10.0.1` |
| [docker/setup-buildx-action](https://github.com/docker/setup-buildx-action) | `4.2.0` | `4.3.0` |
| [docker/login-action](https://github.com/docker/login-action) | `4.5.1` | `4.6.0` |
| [pnpm/action-setup](https://github.com/pnpm/action-setup) | `6.0.9` | `6.0.10` |
| [j178/prek-action](https://github.com/j178/prek-action) | `2.0.6` | `3.0.0` |
| [lewagon/wait-on-check-action](https://github.com/lewagon/wait-on-check-action) | `1.9.0` | `1.9.1` |
| [release-drafter/release-drafter](https://github.com/release-drafter/release-drafter) | `7.6.0` | `7.7.0` |
| [shogo82148/actions-upload-release-asset](https://github.com/shogo82148/actions-upload-release-asset) | `1.10.2` | `1.10.4` |
| [zizmorcore/zizmor-action](https://github.com/zizmorcore/zizmor-action) | `0.6.1` | `0.6.2` |
| [github/codeql-action/upload-sarif](https://github.com/github/codeql-action) | `4.37.3` | `4.37.8` |
| [github/codeql-action/init](https://github.com/github/codeql-action) | `4.37.3` | `4.37.8` |
| [github/codeql-action/analyze](https://github.com/github/codeql-action) | `4.37.3` | `4.37.8` |
| [crowdin/github-action](https://github.com/crowdin/github-action) | `2.17.0` | `2.17.1` |
| [actions/stale](https://github.com/actions/stale) | `10.4.0` | `11.0.0` |


Updates `dorny/paths-filter` from 4.0.2 to 4.0.3
- [Release notes](https://github.com/dorny/paths-filter/releases)
- [Changelog](https://github.com/dorny/paths-filter/blob/master/CHANGELOG.md)
- [Commits](https://github.com/dorny/paths-filter/compare/7b450fff21473bca461d4b92ce414b9d0420d706...ceb8a2b8f2d89434be7ff52d3de7ec3738c5cc9d)

Updates `astral-sh/setup-uv` from 9.0.0 to 10.0.1
- [Release notes](https://github.com/astral-sh/setup-uv/releases)
- [Commits](https://github.com/astral-sh/setup-uv/compare/c771a70e6277c0a99b617c7a806ffedaca235ff9...20cfd1bf945f4377ade1205e4dbc17946fc9a30d)

Updates `docker/setup-buildx-action` from 4.2.0 to 4.3.0
- [Release notes](https://github.com/docker/setup-buildx-action/releases)
- [Commits](https://github.com/docker/setup-buildx-action/compare/bb05f3f5519dd87d3ba754cc423b652a5edd6d2c...37fe631027851001ddb9b187196cc803df7f5f0e)

Updates `docker/login-action` from 4.5.1 to 4.6.0
- [Release notes](https://github.com/docker/login-action/releases)
- [Commits](https://github.com/docker/login-action/compare/abd2ef45e78c5afb21d64d4ca52ee8550d9572c7...dbcb813823bdd20940b903addbd779551569679f)

Updates `pnpm/action-setup` from 6.0.9 to 6.0.10
- [Release notes](https://github.com/pnpm/action-setup/releases)
- [Commits](https://github.com/pnpm/action-setup/compare/0ebf47130e4866e96fce0953f49152a61190b271...0977fd99725f1db4007ccb2928dbb4e90d06cc86)

Updates `j178/prek-action` from 2.0.6 to 3.0.0
- [Release notes](https://github.com/j178/prek-action/releases)
- [Commits](https://github.com/j178/prek-action/compare/5337cb91e0fa35a7ff31b9ca345126d8bbbcdf16...4e14d07f9231acabce116ccfca13b13dd9755ece)

Updates `lewagon/wait-on-check-action` from 1.9.0 to 1.9.1
- [Release notes](https://github.com/lewagon/wait-on-check-action/releases)
- [Changelog](https://github.com/lewagon/wait-on-check-action/blob/master/CHANGELOG.md)
- [Commits](https://github.com/lewagon/wait-on-check-action/compare/2271c86c146b96545b4e871b855e10ffa6f50773...369769072fe522a3a8a85c03c96af1e5242a1994)

Updates `release-drafter/release-drafter` from 7.6.0 to 7.7.0
- [Release notes](https://github.com/release-drafter/release-drafter/releases)
- [Commits](https://github.com/release-drafter/release-drafter/compare/eada3c96a64734dd381cfbda23511034e328ddb0...34d80673e067bdc0c24568d3af899c216adcfaa9)

Updates `shogo82148/actions-upload-release-asset` from 1.10.2 to 1.10.4
- [Release notes](https://github.com/shogo82148/actions-upload-release-asset/releases)
- [Commits](https://github.com/shogo82148/actions-upload-release-asset/compare/394b3c11c3cfc038b5396ad265c074065cf875c3...aaba0f56bdbc1071f4af234d5cb16055e8a400de)

Updates `zizmorcore/zizmor-action` from 0.6.1 to 0.6.2
- [Release notes](https://github.com/zizmorcore/zizmor-action/releases)
- [Commits](https://github.com/zizmorcore/zizmor-action/compare/6fc4b006235f201fdab3722e17240ab420d580e5...3dc1ecc9bcb9e94e9b2c709687979e1298497054)

Updates `github/codeql-action/upload-sarif` from 4.37.3 to 4.37.8
- [Release notes](https://github.com/github/codeql-action/releases)
- [Changelog](https://github.com/github/codeql-action/blob/main/CHANGELOG.md)
- [Commits](https://github.com/github/codeql-action/compare/e4fba868fa4b1b91e1fdab776edc8cfbe6e9fb81...db488ddef3bf6cb639b32c2e9a7c0a7ea8271d28)

Updates `github/codeql-action/init` from 4.37.3 to 4.37.8
- [Release notes](https://github.com/github/codeql-action/releases)
- [Changelog](https://github.com/github/codeql-action/blob/main/CHANGELOG.md)
- [Commits](https://github.com/github/codeql-action/compare/e4fba868fa4b1b91e1fdab776edc8cfbe6e9fb81...db488ddef3bf6cb639b32c2e9a7c0a7ea8271d28)

Updates `github/codeql-action/analyze` from 4.37.3 to 4.37.8
- [Release notes](https://github.com/github/codeql-action/releases)
- [Changelog](https://github.com/github/codeql-action/blob/main/CHANGELOG.md)
- [Commits](https://github.com/github/codeql-action/compare/e4fba868fa4b1b91e1fdab776edc8cfbe6e9fb81...db488ddef3bf6cb639b32c2e9a7c0a7ea8271d28)

Updates `crowdin/github-action` from 2.17.0 to 2.17.1
- [Release notes](https://github.com/crowdin/github-action/releases)
- [Commits](https://github.com/crowdin/github-action/compare/c7af9bc98b01694653031fef2a0dc6c7888ce9bc...8f01d54f70f1713ee3f09d82c2bbb2daeac28689)

Updates `actions/stale` from 10.4.0 to 11.0.0
- [Release notes](https://github.com/actions/stale/releases)
- [Changelog](https://github.com/actions/stale/blob/main/CHANGELOG.md)
- [Commits](https://github.com/actions/stale/compare/1e223db275d687790206a7acac4d1a11bd6fe629...4391f3da665fdf50b6810c1a66712fb9ba21aa93)

---
updated-dependencies:
- dependency-name: dorny/paths-filter
  dependency-version: 4.0.3
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: actions
- dependency-name: astral-sh/setup-uv
  dependency-version: 10.0.1
  dependency-type: direct:production
  update-type: version-update:semver-major
  dependency-group: actions
- dependency-name: docker/setup-buildx-action
  dependency-version: 4.3.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: actions
- dependency-name: docker/login-action
  dependency-version: 4.6.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: actions
- dependency-name: pnpm/action-setup
  dependency-version: 6.0.10
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: actions
- dependency-name: j178/prek-action
  dependency-version: 3.0.0
  dependency-type: direct:production
  update-type: version-update:semver-major
  dependency-group: actions
- dependency-name: lewagon/wait-on-check-action
  dependency-version: 1.9.1
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: actions
- dependency-name: release-drafter/release-drafter
  dependency-version: 7.7.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: actions
- dependency-name: shogo82148/actions-upload-release-asset
  dependency-version: 1.10.4
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: actions
- dependency-name: zizmorcore/zizmor-action
  dependency-version: 0.6.2
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: actions
- dependency-name: github/codeql-action/upload-sarif
  dependency-version: 4.37.8
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: actions
- dependency-name: github/codeql-action/init
  dependency-version: 4.37.8
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: actions
- dependency-name: github/codeql-action/analyze
  dependency-version: 4.37.8
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: actions
- dependency-name: crowdin/github-action
  dependency-version: 2.17.1
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: actions
- dependency-name: actions/stale
  dependency-version: 11.0.0
  dependency-type: direct:production
  update-type: version-update:semver-major
  dependency-group: actions
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-09-01 20:21:28 +00:00
shamoon 5c5b1ee6b5 Fix: fix slim sidebar saved view dragging appearance (#13906) 2026-09-01 13:02:57 -07:00
GitHub Actions 08f2f4bfe2 Auto translate strings 2026-09-01 19:54:47 +00:00
Trenton H f993462973 Security: Minor additional hardening (#13898)
* Security: bump jinja2 floor to 3.1.6 (CVE-2025-27516)

* Security: anchor the /share/ URL pattern

* Security: handle missing file on public share view without 500

* Security: scope correspondent last_correspondence to permitted documents

* Security: disable PUT/PATCH on share link bundles
2026-09-01 19:53:28 +00:00
shamoon ae70b8d60f Chore: consolidate pickle hmac signing (#13899) 2026-09-01 12:41:45 -07:00
shamoon 38db6b51db Fix: use signal-backed queries input in CF dropdown to reflect changes immediately under zoneless (#13901) 2026-09-01 11:52:53 -07:00
GitHub Actions 31e9f4272c Auto translate strings 2026-09-01 16:56:33 +00:00
shamoon b8659c1af3 Fix: use root doc metadata for filename generation (#13893) 2026-09-01 09:55:04 -07:00
shamoon 741115b36b Fix: some css cleanup (#13891) 2026-09-01 09:17:27 -07:00
github-actions[bot] 1211db5cbb Documentation: Add v3.1.2 changelog (#13890) 2026-09-01 08:33:32 -07:00
shamoon ca98dffbd2 Bump version to 3.1.2 2026-09-01 08:09:13 -07:00
github-actions[bot]andCrowdin Bot 4db1451e41 New Crowdin translations by GitHub Action (#13889)
Co-authored-by: Crowdin Bot <support+bot@crowdin.com>
2026-09-01 08:06:06 -07:00
shamoon 624b7911e5 Merge commit from fork 2026-09-01 07:56:38 -07:00
GitHub Actions 5a1a5333ad Auto translate strings 2026-09-01 14:48:13 +00:00
shamoon bfe8213b78 Fix: re-use permitted_object_ids 2026-09-01 07:45:47 -07:00
shamoon 85e192f935 Fix: remove bg from docs list select label 2026-08-31 14:12:56 -07:00
shamoon 4fcd4961bb Development: change front-end e2e testing to a live instance (#13884) 2026-08-31 12:55:19 -07:00
shamoon 5d9401ac4a Chore: update screenshots for v3+ (#13883) 2026-08-31 12:28:18 -07:00
shamoon 6935defe7c Fix: fix dark mode select disabled color, ensure disabled cursor on display mode dropdown (#13881) 2026-08-31 09:23:03 -07:00
shamoon 440049978b Fix: add disable to the drag-drop list component (#13880) 2026-08-31 09:09:10 -07:00
Trenton H 06e9c1c02b Chore: Isolate the search index directory in trash-restore tests, they were using a persistent index (#13876) 2026-08-31 14:43:14 +00:00
156 changed files with 2497 additions and 41855 deletions
+3 -3
View File
@@ -63,7 +63,7 @@ jobs:
- name: Detect changes - name: Detect changes
id: filter id: filter
if: steps.force.outputs.run_all != 'true' if: steps.force.outputs.run_all != 'true'
uses: dorny/paths-filter@7b450fff21473bca461d4b92ce414b9d0420d706 # v4.0.2 uses: dorny/paths-filter@ceb8a2b8f2d89434be7ff52d3de7ec3738c5cc9d # v4.0.3
with: with:
base: ${{ steps.range.outputs.base }} base: ${{ steps.range.outputs.base }}
ref: ${{ steps.range.outputs.ref }} ref: ${{ steps.range.outputs.ref }}
@@ -100,7 +100,7 @@ jobs:
with: with:
python-version: "${{ matrix.python-version }}" python-version: "${{ matrix.python-version }}"
- name: Install uv - name: Install uv
uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0 uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1
with: with:
version: ${{ env.DEFAULT_UV_VERSION }} version: ${{ env.DEFAULT_UV_VERSION }}
enable-cache: true enable-cache: true
@@ -178,7 +178,7 @@ jobs:
with: with:
python-version: "${{ env.DEFAULT_PYTHON }}" python-version: "${{ env.DEFAULT_PYTHON }}"
- name: Install uv - name: Install uv
uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0 uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1
with: with:
version: ${{ env.DEFAULT_UV_VERSION }} version: ${{ env.DEFAULT_UV_VERSION }}
enable-cache: true enable-cache: true
+6 -6
View File
@@ -106,9 +106,9 @@ jobs:
echo "repository=${repo_name}" echo "repository=${repo_name}"
echo "name=${repo_name}" >> $GITHUB_OUTPUT echo "name=${repo_name}" >> $GITHUB_OUTPUT
- name: Set up Docker Buildx - name: Set up Docker Buildx
uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0 uses: docker/setup-buildx-action@37fe631027851001ddb9b187196cc803df7f5f0e # v4.3.0
- name: Login to GitHub Container Registry - name: Login to GitHub Container Registry
uses: docker/login-action@abd2ef45e78c5afb21d64d4ca52ee8550d9572c7 # v4.5.1 uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
with: with:
registry: ${{ env.REGISTRY }} registry: ${{ env.REGISTRY }}
username: ${{ github.actor }} username: ${{ github.actor }}
@@ -182,22 +182,22 @@ jobs:
echo "Downloaded digests:" echo "Downloaded digests:"
ls -la /tmp/digests/ ls -la /tmp/digests/
- name: Set up Docker Buildx - name: Set up Docker Buildx
uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0 uses: docker/setup-buildx-action@37fe631027851001ddb9b187196cc803df7f5f0e # v4.3.0
- name: Login to GitHub Container Registry - name: Login to GitHub Container Registry
uses: docker/login-action@abd2ef45e78c5afb21d64d4ca52ee8550d9572c7 # v4.5.1 uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
with: with:
registry: ${{ env.REGISTRY }} registry: ${{ env.REGISTRY }}
username: ${{ github.actor }} username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }} password: ${{ secrets.GITHUB_TOKEN }}
- name: Login to Docker Hub - name: Login to Docker Hub
if: needs.build-arch.outputs.push-external == 'true' if: needs.build-arch.outputs.push-external == 'true'
uses: docker/login-action@abd2ef45e78c5afb21d64d4ca52ee8550d9572c7 # v4.5.1 uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
with: with:
username: ${{ secrets.DOCKERHUB_USERNAME }} username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }} password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Login to Quay.io - name: Login to Quay.io
if: needs.build-arch.outputs.push-external == 'true' if: needs.build-arch.outputs.push-external == 'true'
uses: docker/login-action@abd2ef45e78c5afb21d64d4ca52ee8550d9572c7 # v4.5.1 uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
with: with:
registry: quay.io registry: quay.io
username: ${{ secrets.QUAY_USERNAME }} username: ${{ secrets.QUAY_USERNAME }}
+2 -2
View File
@@ -50,7 +50,7 @@ jobs:
- name: Detect changes - name: Detect changes
id: filter id: filter
if: steps.force.outputs.run_all != 'true' if: steps.force.outputs.run_all != 'true'
uses: dorny/paths-filter@7b450fff21473bca461d4b92ce414b9d0420d706 # v4.0.2 uses: dorny/paths-filter@ceb8a2b8f2d89434be7ff52d3de7ec3738c5cc9d # v4.0.3
with: with:
base: ${{ steps.range.outputs.base }} base: ${{ steps.range.outputs.base }}
ref: ${{ steps.range.outputs.ref }} ref: ${{ steps.range.outputs.ref }}
@@ -78,7 +78,7 @@ jobs:
with: with:
python-version: ${{ env.DEFAULT_PYTHON_VERSION }} python-version: ${{ env.DEFAULT_PYTHON_VERSION }}
- name: Install uv - name: Install uv
uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0 uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1
with: with:
version: ${{ env.DEFAULT_UV_VERSION }} version: ${{ env.DEFAULT_UV_VERSION }}
enable-cache: true enable-cache: true
+29 -15
View File
@@ -60,7 +60,7 @@ jobs:
- name: Detect changes - name: Detect changes
id: filter id: filter
if: steps.force.outputs.run_all != 'true' if: steps.force.outputs.run_all != 'true'
uses: dorny/paths-filter@7b450fff21473bca461d4b92ce414b9d0420d706 # v4.0.2 uses: dorny/paths-filter@ceb8a2b8f2d89434be7ff52d3de7ec3738c5cc9d # v4.0.3
with: with:
base: ${{ steps.range.outputs.base }} base: ${{ steps.range.outputs.base }}
ref: ${{ steps.range.outputs.ref }} ref: ${{ steps.range.outputs.ref }}
@@ -81,7 +81,7 @@ jobs:
with: with:
persist-credentials: false persist-credentials: false
- name: Install pnpm - name: Install pnpm
uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9 uses: pnpm/action-setup@0977fd99725f1db4007ccb2928dbb4e90d06cc86 # v6.0.10
with: with:
package_json_file: src-ui/package.json package_json_file: src-ui/package.json
- name: Use Node.js 24 - name: Use Node.js 24
@@ -113,7 +113,7 @@ jobs:
with: with:
persist-credentials: false persist-credentials: false
- name: Install pnpm - name: Install pnpm
uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9 uses: pnpm/action-setup@0977fd99725f1db4007ccb2928dbb4e90d06cc86 # v6.0.10
with: with:
package_json_file: src-ui/package.json package_json_file: src-ui/package.json
- name: Use Node.js 24 - name: Use Node.js 24
@@ -152,7 +152,7 @@ jobs:
with: with:
persist-credentials: false persist-credentials: false
- name: Install pnpm - name: Install pnpm
uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9 uses: pnpm/action-setup@0977fd99725f1db4007ccb2928dbb4e90d06cc86 # v6.0.10
with: with:
package_json_file: src-ui/package.json package_json_file: src-ui/package.json
- name: Use Node.js 24 - name: Use Node.js 24
@@ -185,29 +185,23 @@ jobs:
flags: frontend-node-${{ matrix.node-version }} flags: frontend-node-${{ matrix.node-version }}
directory: src-ui/coverage/ directory: src-ui/coverage/
e2e-tests: e2e-tests:
name: "E2E Tests (${{ matrix.shard-index }}/${{ matrix.shard-count }})" name: E2E Tests
needs: [changes, install-dependencies] needs: [changes, install-dependencies]
if: needs.changes.outputs.frontend_changed == 'true' if: needs.changes.outputs.frontend_changed == 'true'
runs-on: ubuntu-24.04 runs-on: ubuntu-24.04
permissions: permissions:
contents: read contents: read
container: mcr.microsoft.com/playwright:v1.62.0-noble container: mcr.microsoft.com/playwright:v1.62.1-noble
env: env:
PLAYWRIGHT_BROWSERS_PATH: /ms-playwright PLAYWRIGHT_BROWSERS_PATH: /ms-playwright
PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD: 1 PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD: 1
strategy:
fail-fast: false
matrix:
node-version: [24.x]
shard-index: [1, 2]
shard-count: [2]
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with: with:
persist-credentials: false persist-credentials: false
- name: Install pnpm - name: Install pnpm
uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9 uses: pnpm/action-setup@0977fd99725f1db4007ccb2928dbb4e90d06cc86 # v6.0.10
with: with:
package_json_file: src-ui/package.json package_json_file: src-ui/package.json
- name: Use Node.js 24 - name: Use Node.js 24
@@ -216,6 +210,17 @@ jobs:
node-version: 24.x node-version: 24.x
cache: 'pnpm' cache: 'pnpm'
cache-dependency-path: 'src-ui/pnpm-lock.yaml' cache-dependency-path: 'src-ui/pnpm-lock.yaml'
- name: Set up Python
id: setup-python
uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
with:
python-version: '3.12'
- name: Install uv
uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1
with:
version: '0.12.x'
enable-cache: false
python-version: ${{ steps.setup-python.outputs.python-version }}
- name: Cache frontend dependencies - name: Cache frontend dependencies
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with: with:
@@ -225,8 +230,17 @@ jobs:
key: ${{ runner.os }}-frontend-${{ hashFiles('src-ui/pnpm-lock.yaml') }} key: ${{ runner.os }}-frontend-${{ hashFiles('src-ui/pnpm-lock.yaml') }}
- name: Install dependencies - name: Install dependencies
run: cd src-ui && pnpm install --frozen-lockfile run: cd src-ui && pnpm install --frozen-lockfile
- name: Install backend system dependencies
run: |
apt-get update
apt-get install --yes --quiet --no-install-recommends libmagic1
- name: Install backend dependencies
env:
PYTHON_VERSION: ${{ steps.setup-python.outputs.python-version }}
# The frozen repository lockfile and its build hooks are trusted.
run: uv sync --python "${PYTHON_VERSION}" --no-dev --frozen # NOSONAR
- name: Run Playwright E2E tests - name: Run Playwright E2E tests
run: cd src-ui && pnpm exec playwright test --shard ${{ matrix.shard-index }}/${{ matrix.shard-count }} run: cd src-ui && pnpm exec playwright test
frontend-build: frontend-build:
name: Frontend Build name: Frontend Build
needs: [changes, unit-tests, e2e-tests] needs: [changes, unit-tests, e2e-tests]
@@ -241,7 +255,7 @@ jobs:
fetch-depth: 2 fetch-depth: 2
persist-credentials: false persist-credentials: false
- name: Install pnpm - name: Install pnpm
uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9 uses: pnpm/action-setup@0977fd99725f1db4007ccb2928dbb4e90d06cc86 # v6.0.10
with: with:
package_json_file: src-ui/package.json package_json_file: src-ui/package.json
- name: Use Node.js 24 - name: Use Node.js 24
+1 -1
View File
@@ -25,4 +25,4 @@ jobs:
with: with:
python-version: "3.14" python-version: "3.14"
- name: Run prek - name: Run prek
uses: j178/prek-action@5337cb91e0fa35a7ff31b9ca345126d8bbbcdf16 # v2.0.6 uses: j178/prek-action@4e14d07f9231acabce116ccfca13b13dd9755ece # v3.0.0
+6 -6
View File
@@ -20,7 +20,7 @@ jobs:
statuses: read statuses: read
steps: steps:
- name: Wait for Docker build - name: Wait for Docker build
uses: lewagon/wait-on-check-action@2271c86c146b96545b4e871b855e10ffa6f50773 # v1.9.0 uses: lewagon/wait-on-check-action@369769072fe522a3a8a85c03c96af1e5242a1994 # v1.9.1
with: with:
ref: ${{ github.sha }} ref: ${{ github.sha }}
check-name: 'Merge and Push Manifest' check-name: 'Merge and Push Manifest'
@@ -40,7 +40,7 @@ jobs:
persist-credentials: false persist-credentials: false
# ---- Frontend Build ---- # ---- Frontend Build ----
- name: Install pnpm - name: Install pnpm
uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9 uses: pnpm/action-setup@0977fd99725f1db4007ccb2928dbb4e90d06cc86 # v6.0.10
with: with:
package_json_file: src-ui/package.json package_json_file: src-ui/package.json
- name: Use Node.js 24 - name: Use Node.js 24
@@ -59,7 +59,7 @@ jobs:
with: with:
python-version: ${{ env.DEFAULT_PYTHON_VERSION }} python-version: ${{ env.DEFAULT_PYTHON_VERSION }}
- name: Install uv - name: Install uv
uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0 uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1
with: with:
version: ${{ env.DEFAULT_UV_VERSION }} version: ${{ env.DEFAULT_UV_VERSION }}
enable-cache: false enable-cache: false
@@ -171,7 +171,7 @@ jobs:
fi fi
- name: Create release and changelog - name: Create release and changelog
id: create-release id: create-release
uses: release-drafter/release-drafter@eada3c96a64734dd381cfbda23511034e328ddb0 # v7.6.0 uses: release-drafter/release-drafter@34d80673e067bdc0c24568d3af899c216adcfaa9 # v7.7.0
with: with:
name: Paperless-ngx ${{ steps.get-version.outputs.version }} name: Paperless-ngx ${{ steps.get-version.outputs.version }}
tag: ${{ steps.get-version.outputs.version }} tag: ${{ steps.get-version.outputs.version }}
@@ -182,7 +182,7 @@ jobs:
env: env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Upload release archive - name: Upload release archive
uses: shogo82148/actions-upload-release-asset@394b3c11c3cfc038b5396ad265c074065cf875c3 # v1.10.2 uses: shogo82148/actions-upload-release-asset@aaba0f56bdbc1071f4af234d5cb16055e8a400de # v1.10.4
with: with:
github_token: ${{ secrets.GITHUB_TOKEN }} github_token: ${{ secrets.GITHUB_TOKEN }}
upload_url: ${{ steps.create-release.outputs.upload_url }} upload_url: ${{ steps.create-release.outputs.upload_url }}
@@ -212,7 +212,7 @@ jobs:
with: with:
python-version: ${{ env.DEFAULT_PYTHON_VERSION }} python-version: ${{ env.DEFAULT_PYTHON_VERSION }}
- name: Install uv - name: Install uv
uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0 uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1
with: with:
version: ${{ env.DEFAULT_UV_VERSION }} version: ${{ env.DEFAULT_UV_VERSION }}
enable-cache: false enable-cache: false
+2 -2
View File
@@ -26,7 +26,7 @@ jobs:
with: with:
persist-credentials: false persist-credentials: false
- name: Run zizmor - name: Run zizmor
uses: zizmorcore/zizmor-action@6fc4b006235f201fdab3722e17240ab420d580e5 # v0.6.1 uses: zizmorcore/zizmor-action@3dc1ecc9bcb9e94e9b2c709687979e1298497054 # v0.6.2
semgrep: semgrep:
name: Semgrep CE name: Semgrep CE
runs-on: ubuntu-24.04 runs-on: ubuntu-24.04
@@ -44,7 +44,7 @@ jobs:
- name: Run Semgrep - name: Run Semgrep
run: semgrep scan --config auto --sarif-output results.sarif run: semgrep scan --config auto --sarif-output results.sarif
- name: Upload results to GitHub code scanning - name: Upload results to GitHub code scanning
uses: github/codeql-action/upload-sarif@e4fba868fa4b1b91e1fdab776edc8cfbe6e9fb81 # v4.37.3 uses: github/codeql-action/upload-sarif@db488ddef3bf6cb639b32c2e9a7c0a7ea8271d28 # v4.37.8
if: always() if: always()
with: with:
sarif_file: results.sarif sarif_file: results.sarif
+2 -2
View File
@@ -39,7 +39,7 @@ jobs:
persist-credentials: false persist-credentials: false
# Initializes the CodeQL tools for scanning. # Initializes the CodeQL tools for scanning.
- name: Initialize CodeQL - name: Initialize CodeQL
uses: github/codeql-action/init@e4fba868fa4b1b91e1fdab776edc8cfbe6e9fb81 # v4.37.3 uses: github/codeql-action/init@db488ddef3bf6cb639b32c2e9a7c0a7ea8271d28 # v4.37.8
with: with:
languages: ${{ matrix.language }} languages: ${{ matrix.language }}
# If you wish to specify custom queries, you can do so here or in a config file. # If you wish to specify custom queries, you can do so here or in a config file.
@@ -47,4 +47,4 @@ jobs:
# Prefix the list here with "+" to use these queries and those in the config file. # Prefix the list here with "+" to use these queries and those in the config file.
# queries: ./path/to/local/query, your-org/your-repo/queries@main # queries: ./path/to/local/query, your-org/your-repo/queries@main
- name: Perform CodeQL Analysis - name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@e4fba868fa4b1b91e1fdab776edc8cfbe6e9fb81 # v4.37.3 uses: github/codeql-action/analyze@db488ddef3bf6cb639b32c2e9a7c0a7ea8271d28 # v4.37.8
+1 -1
View File
@@ -22,7 +22,7 @@ jobs:
token: ${{ secrets.PNGX_BOT_PAT }} token: ${{ secrets.PNGX_BOT_PAT }}
persist-credentials: false persist-credentials: false
- name: crowdin action - name: crowdin action
uses: crowdin/github-action@c7af9bc98b01694653031fef2a0dc6c7888ce9bc # v2.17.0 uses: crowdin/github-action@8f01d54f70f1713ee3f09d82c2bbb2daeac28689 # v2.17.1
with: with:
upload_translations: false upload_translations: false
download_translations: true download_translations: true
+1 -1
View File
@@ -19,6 +19,6 @@ jobs:
if: github.event_name == 'pull_request_target' && (github.event.action == 'opened' || github.event.action == 'reopened') && github.event.pull_request.user.login != 'dependabot' if: github.event_name == 'pull_request_target' && (github.event.action == 'opened' || github.event.action == 'reopened') && github.event.pull_request.user.login != 'dependabot'
steps: steps:
- name: Label PR with release-drafter - name: Label PR with release-drafter
uses: release-drafter/release-drafter@eada3c96a64734dd381cfbda23511034e328ddb0 # v7.6.0 uses: release-drafter/release-drafter@34d80673e067bdc0c24568d3af899c216adcfaa9 # v7.7.0
env: env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+1 -1
View File
@@ -14,7 +14,7 @@ jobs:
issues: write issues: write
pull-requests: write pull-requests: write
steps: steps:
- uses: actions/stale@1e223db275d687790206a7acac4d1a11bd6fe629 # v10.4.0 - uses: actions/stale@4391f3da665fdf50b6810c1a66712fb9ba21aa93 # v11.0.0
with: with:
days-before-stale: 7 days-before-stale: 7
days-before-close: 14 days-before-close: 14
+2 -2
View File
@@ -29,7 +29,7 @@ jobs:
sudo apt-get update -qq sudo apt-get update -qq
sudo apt-get install -qq --no-install-recommends gettext sudo apt-get install -qq --no-install-recommends gettext
- name: Install uv - name: Install uv
uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0 uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1
with: with:
version: ${{ env.DEFAULT_UV_VERSION }} version: ${{ env.DEFAULT_UV_VERSION }}
enable-cache: true enable-cache: true
@@ -43,7 +43,7 @@ jobs:
PAPERLESS_SECRET_KEY: "ci-translate-not-a-real-secret" PAPERLESS_SECRET_KEY: "ci-translate-not-a-real-secret"
run: cd src/ && uv run manage.py makemessages -l en_US -i "samples*" run: cd src/ && uv run manage.py makemessages -l en_US -i "samples*"
- name: Install pnpm - name: Install pnpm
uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9 uses: pnpm/action-setup@0977fd99725f1db4007ccb2928dbb4e90d06cc86 # v6.0.10
with: with:
package_json_file: src-ui/package.json package_json_file: src-ui/package.json
- name: Use Node.js 24 - name: Use Node.js 24
Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.8 MiB

After

Width:  |  Height:  |  Size: 1.4 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 501 KiB

After

Width:  |  Height:  |  Size: 487 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 21 KiB

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.2 MiB

After

Width:  |  Height:  |  Size: 558 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 644 KiB

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 667 KiB

After

Width:  |  Height:  |  Size: 1.4 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1003 KiB

After

Width:  |  Height:  |  Size: 1.2 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.7 MiB

After

Width:  |  Height:  |  Size: 1.4 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.1 MiB

After

Width:  |  Height:  |  Size: 1.5 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.8 MiB

After

Width:  |  Height:  |  Size: 1.4 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 925 KiB

After

Width:  |  Height:  |  Size: 972 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.7 MiB

After

Width:  |  Height:  |  Size: 1.4 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.8 MiB

After

Width:  |  Height:  |  Size: 1.4 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.3 MiB

After

Width:  |  Height:  |  Size: 558 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 726 KiB

After

Width:  |  Height:  |  Size: 1.3 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 169 KiB

After

Width:  |  Height:  |  Size: 294 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 432 KiB

After

Width:  |  Height:  |  Size: 298 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 280 KiB

After

Width:  |  Height:  |  Size: 322 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 246 KiB

After

Width:  |  Height:  |  Size: 205 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 27 KiB

After

Width:  |  Height:  |  Size: 43 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 29 KiB

After

Width:  |  Height:  |  Size: 44 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 48 KiB

After

Width:  |  Height:  |  Size: 57 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 45 KiB

After

Width:  |  Height:  |  Size: 65 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 559 KiB

After

Width:  |  Height:  |  Size: 516 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 116 KiB

After

Width:  |  Height:  |  Size: 230 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 87 KiB

After

Width:  |  Height:  |  Size: 333 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 792 KiB

After

Width:  |  Height:  |  Size: 1.2 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 137 KiB

After

Width:  |  Height:  |  Size: 291 KiB

+21
View File
@@ -1,5 +1,26 @@
# Changelog # Changelog
## paperless-ngx 3.1.2
### Bug Fixes
- Fix: fix dark mode select disabled color, ensure disabled cursor on display mode dropdown [@shamoon](https://github.com/shamoon) ([#13881](https://github.com/paperless-ngx/paperless-ngx/pull/13881))
- Fix: add disable to the drag-drop list component [@shamoon](https://github.com/shamoon) ([#13880](https://github.com/paperless-ngx/paperless-ngx/pull/13880))
### Documentation
- Chore: update screenshots for v3+ [@shamoon](https://github.com/shamoon) ([#13883](https://github.com/paperless-ngx/paperless-ngx/pull/13883))
### All App Changes
<details>
<summary>2 changes</summary>
- Fix: fix dark mode select disabled color, ensure disabled cursor on display mode dropdown [@shamoon](https://github.com/shamoon) ([#13881](https://github.com/paperless-ngx/paperless-ngx/pull/13881))
- Fix: add disable to the drag-drop list component [@shamoon](https://github.com/shamoon) ([#13880](https://github.com/paperless-ngx/paperless-ngx/pull/13880))
</details>
## paperless-ngx 3.1.1 ## paperless-ngx 3.1.1
### Bug Fixes ### Bug Fixes
+8 -2
View File
@@ -224,13 +224,19 @@ respectively, can be run non-interactively with:
```bash ```bash
pnpm ng test pnpm ng test
pnpm playwright test pnpm e2e
``` ```
The Playwright suite starts both the Angular development server and a disposable
Paperless instance on port 8001. The instance uses SQLite, temporary data and
media directories, and deterministic sample documents; it is removed when the
test run finishes. This requires the back-end Python dependencies from the
regular development setup to be installed with `uv sync`.
Playwright also includes a UI which can be run with: Playwright also includes a UI which can be run with:
```bash ```bash
pnpm playwright test --ui pnpm e2e:ui
``` ```
### Building the frontend ### Building the frontend
+2 -2
View File
@@ -1,6 +1,6 @@
[project] [project]
name = "paperless-ngx" name = "paperless-ngx"
version = "3.1.1" version = "3.1.2"
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.11" requires-python = ">=3.11"
@@ -47,7 +47,7 @@ dependencies = [
"httpx-oauth~=0.17", "httpx-oauth~=0.17",
"ijson>=3.5.1", "ijson>=3.5.1",
"imap-tools~=1.14.0", "imap-tools~=1.14.0",
"jinja2~=3.1.5", "jinja2~=3.1.6",
"langdetect~=1.0.9", "langdetect~=1.0.9",
"llama-index-core>=0.14.23", "llama-index-core>=0.14.23",
"llama-index-embeddings-huggingface>=0.6.1", "llama-index-embeddings-huggingface>=0.6.1",
+14
View File
@@ -139,6 +139,17 @@
} }
] ]
}, },
"e2e": {
"fileReplacements": [
{
"replace": "src/environments/environment.ts",
"with": "src/environments/environment.e2e.ts"
}
],
"localize": [
"en-US"
]
},
"en-US": { "en-US": {
"localize": [ "localize": [
"en-US" "en-US"
@@ -155,6 +166,9 @@
"configurations": { "configurations": {
"production": { "production": {
"buildTarget": "paperless-ui:build:production" "buildTarget": "paperless-ui:build:production"
},
"e2e": {
"buildTarget": "paperless-ui:build:e2e"
} }
} }
}, },
-194
View File
@@ -1,194 +0,0 @@
{
"log": {
"version": "1.2",
"creator": {
"name": "Playwright",
"version": "1.33.0"
},
"browser": {
"name": "chromium",
"version": "113.0.5672.53"
},
"entries": [
{
"startedDateTime": "2023-05-14T07:18:59.856Z",
"time": 6.025,
"request": {
"method": "GET",
"url": "http://localhost:8000/api/ui_settings/",
"httpVersion": "HTTP/1.1",
"cookies": [],
"headers": [
{ "name": "Accept", "value": "application/json; version=3" },
{ "name": "Accept-Encoding", "value": "gzip, deflate, br" },
{ "name": "Accept-Language", "value": "en-US" },
{ "name": "Connection", "value": "keep-alive" },
{ "name": "Host", "value": "localhost:8000" },
{ "name": "Origin", "value": "http://localhost:4200" },
{ "name": "Referer", "value": "http://localhost:4200/" },
{ "name": "Sec-Fetch-Dest", "value": "empty" },
{ "name": "Sec-Fetch-Mode", "value": "cors" },
{ "name": "Sec-Fetch-Site", "value": "same-site" },
{ "name": "User-Agent", "value": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/113.0.5672.53 Safari/537.36" }
],
"queryString": [],
"headersSize": -1,
"bodySize": -1
},
"response": {
"status": 200,
"statusText": "OK",
"httpVersion": "HTTP/1.1",
"cookies": [],
"headers": [
{ "name": "Access-Control-Allow-Origin", "value": "http://localhost:4200" },
{ "name": "Allow", "value": "GET, POST, HEAD, OPTIONS" },
{ "name": "Content-Encoding", "value": "br" },
{ "name": "Content-Language", "value": "en-us" },
{ "name": "Content-Length", "value": "953" },
{ "name": "Content-Type", "value": "application/json" },
{ "name": "Cross-Origin-Opener-Policy", "value": "same-origin" },
{ "name": "Referrer-Policy", "value": "same-origin" },
{ "name": "Vary", "value": "Accept, Accept-Language, Origin, Cookie, Accept-Encoding" },
{ "name": "X-Api-Version", "value": "3" },
{ "name": "X-Content-Type-Options", "value": "nosniff" },
{ "name": "X-Frame-Options", "value": "ANY" },
{ "name": "X-Version", "value": "1.14.4" }
],
"content": {
"size": -1,
"mimeType": "application/json",
"text": "{\"user\":{\"id\":2,\"username\":\"testuser\",\"is_superuser\":false,\"groups\":[]},\"settings\":{\"language\":\"\",\"bulk_edit\":{\"confirmation_dialogs\":true,\"apply_on_close\":false},\"documentListSize\":50,\"dark_mode\":{\"use_system\":true,\"enabled\":\"false\",\"thumb_inverted\":\"true\"},\"theme\":{\"color\":\"#9fbf2f\"},\"document_details\":{\"native_pdf_viewer\":false},\"date_display\":{\"date_locale\":\"\",\"date_format\":\"mediumDate\"},\"notifications\":{\"consumer_new_documents\":true,\"consumer_success\":true,\"consumer_failed\":true,\"consumer_suppress_on_dashboard\":true},\"comments_enabled\":true,\"slim_sidebar\":false,\"update_checking\":{\"enabled\":false,\"backend_setting\":\"default\"},\"saved_views\":{\"warn_on_unsaved_change\":true},\"notes_enabled\":true,\"tour_complete\":true},\"permissions\":[\"change_savedview\",\"change_schedule\",\"change_failure\",\"delete_token\",\"add_mailrule\",\"view_failure\",\"view_groupresult\",\"add_note\",\"change_taskresult\",\"view_tag\",\"view_user\",\"add_tag\",\"change_processedmail\",\"change_session\",\"view_taskattributes\",\"delete_groupresult\",\"delete_correspondent\",\"delete_schedule\",\"delete_contenttype\",\"view_chordcounter\",\"view_success\",\"delete_documenttype\",\"add_tokenproxy\",\"delete_paperlesstask\",\"add_log\",\"view_mailaccount\",\"add_uisettings\",\"view_savedview\",\"view_uisettings\",\"delete_storagepath\",\"delete_frontendsettings\",\"change_paperlesstask\",\"view_taskresult\",\"delete_processedmail\",\"view_processedmail\",\"view_session\",\"delete_chordcounter\",\"view_note\",\"delete_session\",\"view_document\",\"change_mailaccount\",\"delete_taskattributes\",\"add_groupobjectpermission\",\"view_mailrule\",\"change_savedviewfilterrule\",\"change_log\",\"change_comment\",\"add_mailaccount\",\"add_frontendsettings\",\"add_userobjectpermission\",\"delete_note\",\"view_token\",\"add_failure\",\"delete_user\",\"add_success\",\"view_ormq\",\"view_tokenproxy\",\"delete_uisettings\",\"change_groupobjectpermission\",\"add_logentry\",\"add_ormq\",\"view_frontendsettings\",\"view_schedule\",\"change_taskattributes\",\"view_documenttype\",\"view_logentry\",\"change_correspondent\",\"add_groupresult\",\"delete_groupobjectpermission\",\"change_mailrule\",\"change_permission\",\"delete_log\",\"view_userobjectpermission\",\"view_correspondent\",\"delete_document\",\"change_uisettings\",\"change_storagepath\",\"change_document\",\"delete_tokenproxy\",\"change_note\",\"delete_permission\",\"change_contenttype\",\"add_token\",\"change_success\",\"delete_logentry\",\"view_savedviewfilterrule\",\"delete_task\",\"add_savedview\",\"add_paperlesstask\",\"add_task\",\"change_documenttype\",\"add_documenttype\",\"change_token\",\"view_task\",\"view_permission\",\"change_task\",\"delete_userobjectpermission\",\"change_group\",\"add_group\",\"change_tag\",\"change_chordcounter\",\"add_storagepath\",\"delete_group\",\"add_taskattributes\",\"delete_mailaccount\",\"delete_tag\",\"add_schedule\",\"delete_failure\",\"delete_mailrule\",\"add_savedviewfilterrule\",\"change_ormq\",\"change_logentry\",\"add_taskresult\",\"view_group\",\"delete_comment\",\"add_contenttype\",\"add_document\",\"change_tokenproxy\",\"delete_success\",\"add_comment\",\"delete_ormq\",\"add_processedmail\",\"view_paperlesstask\",\"delete_savedview\",\"change_user\",\"add_session\",\"view_groupobjectpermission\",\"add_user\",\"add_correspondent\",\"delete_taskresult\",\"view_contenttype\",\"view_storagepath\",\"add_permission\",\"change_userobjectpermission\",\"delete_savedviewfilterrule\",\"change_groupresult\",\"add_chordcounter\",\"view_log\",\"view_comment\",\"change_frontendsettings\"]}"
},
"headersSize": -1,
"bodySize": -1,
"redirectURL": ""
},
"cache": {},
"timings": { "send": -1, "wait": -1, "receive": 6.025 }
},
{
"startedDateTime": "2023-05-14T07:18:59.990Z",
"time": 1.082,
"request": {
"method": "GET",
"url": "http://localhost:8000/api/saved_views/?page=1&page_size=100000",
"httpVersion": "HTTP/1.1",
"cookies": [],
"headers": [
{ "name": "Accept", "value": "application/json; version=3" },
{ "name": "Accept-Encoding", "value": "gzip, deflate, br" },
{ "name": "Accept-Language", "value": "en-US" },
{ "name": "Connection", "value": "keep-alive" },
{ "name": "Host", "value": "localhost:8000" },
{ "name": "Origin", "value": "http://localhost:4200" },
{ "name": "Referer", "value": "http://localhost:4200/" },
{ "name": "Sec-Fetch-Dest", "value": "empty" },
{ "name": "Sec-Fetch-Mode", "value": "cors" },
{ "name": "Sec-Fetch-Site", "value": "same-site" },
{ "name": "User-Agent", "value": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/113.0.5672.53 Safari/537.36" }
],
"queryString": [
{
"name": "page",
"value": "1"
},
{
"name": "page_size",
"value": "100000"
}
],
"headersSize": -1,
"bodySize": -1
},
"response": {
"status": 200,
"statusText": "OK",
"httpVersion": "HTTP/1.1",
"cookies": [],
"headers": [
{ "name": "Access-Control-Allow-Origin", "value": "http://localhost:4200" },
{ "name": "Allow", "value": "GET, POST, HEAD, OPTIONS" },
{ "name": "Content-Encoding", "value": "br" },
{ "name": "Content-Language", "value": "en-us" },
{ "name": "Content-Length", "value": "851" },
{ "name": "Content-Type", "value": "application/json" },
{ "name": "Cross-Origin-Opener-Policy", "value": "same-origin" },
{ "name": "Referrer-Policy", "value": "same-origin" },
{ "name": "Vary", "value": "Accept, Accept-Language, Origin, Cookie, Accept-Encoding" },
{ "name": "X-Api-Version", "value": "3" },
{ "name": "X-Content-Type-Options", "value": "nosniff" },
{ "name": "X-Frame-Options", "value": "ANY" },
{ "name": "X-Version", "value": "1.14.4" }
],
"content": {
"size": -1,
"mimeType": "application/json",
"text": "{\"count\":6,\"next\":null,\"previous\":null,\"all\":[8,17,7,4,11,15],\"results\":[{\"id\":8,\"name\":\"Correspondent 2\",\"show_on_dashboard\":false,\"show_in_sidebar\":false,\"sort_field\":\"created\",\"sort_reverse\":true,\"filter_rules\":[{\"rule_type\":3,\"value\":\"2\"}],\"owner\":\"2\",\"user_can_change\":true},{\"id\":17,\"name\":\"In the Last Month\",\"show_on_dashboard\":false,\"show_in_sidebar\":false,\"sort_field\":\"created\",\"sort_reverse\":true,\"filter_rules\":[{\"rule_type\":20,\"value\":\"created:[-1 month to now]\"}],\"owner\":\"2\",\"user_can_change\":true},{\"id\":7,\"name\":\"Inbox\",\"show_on_dashboard\":true,\"show_in_sidebar\":true,\"sort_field\":\"created\",\"sort_reverse\":true,\"filter_rules\":[{\"rule_type\":6,\"value\":\"9\"}],\"owner\":\"2\",\"user_can_change\":true},{\"id\":4,\"name\":\"Recently Added\",\"show_on_dashboard\":true,\"show_in_sidebar\":true,\"sort_field\":\"created\",\"sort_reverse\":true,\"filter_rules\":[],\"owner\":\"2\",\"user_can_change\":true},{\"id\":11,\"name\":\"Tag: Another Sample Tag\",\"show_on_dashboard\":false,\"show_in_sidebar\":true,\"sort_field\":\"created\",\"sort_reverse\":true,\"filter_rules\":[{\"rule_type\":6,\"value\":\"4\"}],\"owner\":\"2\",\"user_can_change\":true},{\"id\":15,\"name\":\"View ASN not empty\",\"show_on_dashboard\":false,\"show_in_sidebar\":false,\"sort_field\":\"created\",\"sort_reverse\":true,\"filter_rules\":[{\"rule_type\":18,\"value\":\"false\"}],\"owner\":\"2\",\"user_can_change\":true}]}"
},
"headersSize": -1,
"bodySize": -1,
"redirectURL": ""
},
"cache": {},
"timings": { "send": -1, "wait": -1, "receive": 1.082 }
},
{
"startedDateTime": "2023-05-14T07:18:59.990Z",
"time": 0.647,
"request": {
"method": "GET",
"url": "http://localhost:8000/api/tasks/",
"httpVersion": "HTTP/1.1",
"cookies": [],
"headers": [
{ "name": "Accept", "value": "application/json; version=3" },
{ "name": "Accept-Encoding", "value": "gzip, deflate, br" },
{ "name": "Accept-Language", "value": "en-US" },
{ "name": "Connection", "value": "keep-alive" },
{ "name": "Host", "value": "localhost:8000" },
{ "name": "Origin", "value": "http://localhost:4200" },
{ "name": "Referer", "value": "http://localhost:4200/" },
{ "name": "Sec-Fetch-Dest", "value": "empty" },
{ "name": "Sec-Fetch-Mode", "value": "cors" },
{ "name": "Sec-Fetch-Site", "value": "same-site" },
{ "name": "User-Agent", "value": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/113.0.5672.53 Safari/537.36" }
],
"queryString": [],
"headersSize": -1,
"bodySize": -1
},
"response": {
"status": 200,
"statusText": "OK",
"httpVersion": "HTTP/1.1",
"cookies": [],
"headers": [
{ "name": "Access-Control-Allow-Origin", "value": "http://localhost:4200" },
{ "name": "Allow", "value": "GET, HEAD, OPTIONS" },
{ "name": "Content-Language", "value": "en-us" },
{ "name": "Content-Length", "value": "2" },
{ "name": "Content-Type", "value": "application/json" },
{ "name": "Cross-Origin-Opener-Policy", "value": "same-origin" },
{ "name": "Referrer-Policy", "value": "same-origin" },
{ "name": "Vary", "value": "Accept, Accept-Language, Origin, Cookie" },
{ "name": "X-Api-Version", "value": "3" },
{ "name": "X-Content-Type-Options", "value": "nosniff" },
{ "name": "X-Frame-Options", "value": "ANY" },
{ "name": "X-Version", "value": "1.14.4" }
],
"content": {
"size": -1,
"mimeType": "application/json",
"text": "[]"
},
"headersSize": -1,
"bodySize": -1,
"redirectURL": ""
},
"cache": {},
"timings": { "send": -1, "wait": -1, "receive": 0.647 }
}
]
}
}
+16 -5
View File
@@ -1,12 +1,25 @@
import { expect, test } from '@playwright/test' import { expect, test } from '@playwright/test'
import path from 'node:path'
const REQUESTS_HAR = path.join(__dirname, 'requests/api-settings.har') test.beforeEach(async ({ page }) => {
await page.route('**/api/status/', (route) =>
route.fulfill({
json: {
database: { status: 'OK' },
tasks: {
redis_status: 'DISABLED',
celery_status: 'DISABLED',
index_status: 'OK',
classifier_status: 'OK',
sanity_check_status: 'OK',
},
},
})
)
})
test('should activate / deactivate save button when settings change', async ({ test('should activate / deactivate save button when settings change', async ({
page, page,
}) => { }) => {
await page.routeFromHAR(REQUESTS_HAR, { notFound: 'fallback' })
await page.goto('/settings') await page.goto('/settings')
await expect(page.getByRole('button', { name: 'Save' })).toBeDisabled() await expect(page.getByRole('button', { name: 'Save' })).toBeDisabled()
await page.getByLabel('Use system setting').click() await page.getByLabel('Use system setting').click()
@@ -15,7 +28,6 @@ test('should activate / deactivate save button when settings change', async ({
}) })
test('should warn on unsaved changes', async ({ page }) => { test('should warn on unsaved changes', async ({ page }) => {
await page.routeFromHAR(REQUESTS_HAR, { notFound: 'fallback' })
await page.goto('/settings') await page.goto('/settings')
await page.getByLabel('Use system setting').click() await page.getByLabel('Use system setting').click()
await page.getByRole('link', { name: 'Dashboard' }).click() await page.getByRole('link', { name: 'Dashboard' }).click()
@@ -27,7 +39,6 @@ test('should warn on unsaved changes', async ({ page }) => {
}) })
test('should apply appearance changes when set', async ({ page }) => { test('should apply appearance changes when set', async ({ page }) => {
await page.routeFromHAR(REQUESTS_HAR, { notFound: 'fallback' })
await page.goto('/settings') await page.goto('/settings')
await expect(page.locator('html')).toHaveAttribute('data-bs-theme', /auto/) await expect(page.locator('html')).toHaveAttribute('data-bs-theme', /auto/)
await page.getByLabel('Use system setting').click() await page.getByLabel('Use system setting').click()
+237
View File
@@ -0,0 +1,237 @@
"""Start a disposable Paperless instance for the Playwright test suite."""
# ruff: noqa: INP001, T201
from __future__ import annotations
import datetime
import logging
import logging.config
import os
import shutil
import sys
import tempfile
import time
from pathlib import Path
REPOSITORY_ROOT = Path(__file__).resolve().parents[2]
SOURCE_ROOT = REPOSITORY_ROOT / "src"
def configure_environment(instance_root: Path) -> None:
paths = {
"PAPERLESS_CONSUMPTION_DIR": instance_root / "consume",
"PAPERLESS_DATA_DIR": instance_root / "data",
"PAPERLESS_MEDIA_ROOT": instance_root / "media",
"PAPERLESS_SCRATCH_DIR": instance_root / "scratch",
}
for name, path in paths.items():
path.mkdir(parents=True)
os.environ[name] = str(path)
(paths["PAPERLESS_DATA_DIR"] / "index").mkdir()
os.environ.update(
{
"DJANGO_SETTINGS_MODULE": "paperless.settings",
"PAPERLESS_AI_ENABLED": "false",
"PAPERLESS_CHANNELS_BACKEND": "channels.layers.InMemoryChannelLayer",
"PAPERLESS_DEBUG": "true",
"PAPERLESS_SECRET_KEY": "playwright-only-not-a-real-secret",
},
)
sys.path.insert(0, str(SOURCE_ROOT))
def seed_database() -> None:
from django.contrib.auth.models import User
from django.core.management import call_command
from django.utils import timezone
from documents.models import Correspondent
from documents.models import Document
from documents.models import DocumentType
from documents.models import Note
from documents.models import SavedView
from documents.models import SavedViewFilterRule
from documents.models import StoragePath
from documents.models import Tag
from documents.models import UiSettings
# Fixed credentials are safe within this disposable, localhost-only instance.
admin = User.objects.create_superuser(
username="playwright",
password="playwright", # NOSONAR
)
User.objects.create_user(
username="viewer",
password="viewer", # NOSONAR
)
inbox = Tag.objects.create(name="Inbox", is_inbox_tag=True, owner=admin)
quick_filter = Tag.objects.create(name="Another Sample Tag", owner=admin)
Tag.objects.create(name="TagWithPartial", owner=admin)
invoice = DocumentType.objects.create(name="Invoice Test", owner=admin)
correspondent_1 = Correspondent.objects.create(
name="Test Correspondent 1",
owner=admin,
)
correspondent_2 = Correspondent.objects.create(name="Correspondent 9", owner=admin)
storage_path = StoragePath.objects.create(
name="Testing 12",
path="e2e/{created_year}/{title}",
owner=admin,
)
today = timezone.localdate()
documents = []
for number in range(1, 62):
title = f"test document {number}" if number <= 9 else f"document {number}"
content = (
f"Playwright test content for document {number}"
if number <= 32
else f"Seeded content for document {number}"
)
created = today if number == 1 else datetime.date(2021, 1, 1)
if number in (2, 3):
created = datetime.date(2022, 12, 11)
documents.append(
Document(
title=title,
content=content,
checksum=f"{number:064x}",
mime_type="application/pdf",
filename=f"{number:07}.pdf",
original_filename=f"document-{number}.pdf",
archive_serial_number=1122 + number if number <= 6 else None,
created=created,
owner=admin,
document_type=invoice if number <= 3 else None,
correspondent=(
correspondent_1
if number <= 4
else correspondent_2
if number <= 7
else None
),
storage_path=storage_path if number <= 8 else None,
),
)
Document.objects.bulk_create(documents)
originals = Path(os.environ["PAPERLESS_MEDIA_ROOT"]) / "documents" / "originals"
originals.mkdir(parents=True)
thumbnails = Path(os.environ["PAPERLESS_MEDIA_ROOT"]) / "documents" / "thumbnails"
thumbnails.mkdir(parents=True)
sample_pdf = SOURCE_ROOT / "documents" / "tests" / "samples" / "simple.pdf"
for document in documents:
shutil.copyfile(sample_pdf, originals / document.filename)
shutil.copyfile(
SOURCE_ROOT / "documents" / "resources" / "document.webp",
thumbnails / f"{document.pk:07}.webp",
)
for document in documents[:8]:
document.tags.add(inbox)
documents[0].tags.add(quick_filter)
for number in range(1, 5):
Note.objects.create(
note=f"Playwright note {number}",
document=documents[0],
user=admin,
)
inbox_view = SavedView.objects.create(
name="Inbox",
owner=admin,
sort_field="created",
sort_reverse=True,
page_size=10,
display_mode=SavedView.DisplayMode.TABLE,
display_fields=["created", "title", "tag", "documenttype"],
)
SavedViewFilterRule.objects.create(
saved_view=inbox_view,
rule_type=6,
value=str(inbox.pk),
)
UiSettings.objects.create(
user=admin,
settings={
"language": "",
"bulk_edit": {"confirmation_dialogs": True, "apply_on_close": False},
"documentListSize": 50,
"dark_mode": {
"use_system": True,
"enabled": False,
"thumb_inverted": True,
},
"theme": {"color": "#9fbf2f"},
"document_details": {"native_pdf_viewer": False},
"date_display": {"date_locale": "", "date_format": "mediumDate"},
"comments_enabled": True,
"slim_sidebar": False,
"update_checking": {"enabled": False},
"saved_views": {
"warn_on_unsaved_change": True,
"dashboard_views_visible_ids": [inbox_view.pk],
"sidebar_views_visible_ids": [inbox_view.pk],
},
"notes_enabled": True,
"tour_complete": True,
},
)
call_command(
"document_index",
"reindex",
recreate=True,
heap_size_mb=16,
verbosity=0,
)
def main() -> None:
started = time.monotonic()
with tempfile.TemporaryDirectory(prefix="paperless-playwright-") as instance:
configure_environment(Path(instance))
os.chdir(SOURCE_ROOT)
print("Loading the Paperless backend...", flush=True)
import django
django.setup()
from django.conf import settings
from django.core.management import call_command
settings.CELERY_TASK_ALWAYS_EAGER = True
settings.CELERY_TASK_EAGER_PROPAGATES = True
print("Migrating the disposable database...", flush=True)
call_command("migrate", interactive=False, verbosity=0)
print("Seeding Playwright data...", flush=True)
seed_database()
elapsed = time.monotonic() - started
print(f"Playwright backend ready in {elapsed:.1f}s", flush=True)
settings.LOGGING["handlers"]["console"]["level"] = "WARNING"
settings.LOGGING["handlers"]["playwright_null"] = {
"class": "logging.NullHandler",
}
settings.LOGGING["loggers"]["django.server"] = {
"handlers": ["playwright_null"],
"propagate": False,
}
logging.config.dictConfig(settings.LOGGING)
call_command(
"runserver",
"localhost:8001",
use_reloader=False,
verbosity=1,
)
if __name__ == "__main__":
main()
+3 -14
View File
@@ -1,21 +1,13 @@
import { expect, test } from '@playwright/test' import { expect, test } from '@playwright/test'
import path from 'node:path'
const REQUESTS_HAR1 = path.join(__dirname, 'requests/api-dashboard1.har')
const REQUESTS_HAR2 = path.join(__dirname, 'requests/api-dashboard2.har')
const REQUESTS_HAR3 = path.join(__dirname, 'requests/api-dashboard3.har')
const REQUESTS_HAR4 = path.join(__dirname, 'requests/api-dashboard4.har')
test('dashboard inbox link', async ({ page }) => { test('dashboard inbox link', async ({ page }) => {
await page.routeFromHAR(REQUESTS_HAR1, { notFound: 'fallback' })
await page.goto('/dashboard') await page.goto('/dashboard')
await page.getByRole('link', { name: 'Documents in inbox' }).click() await page.getByRole('link', { name: 'Documents in inbox' }).click()
await expect(page).toHaveURL(/tags__id__in=9/) await expect(page).toHaveURL(/tags__id__in=1/)
await expect(page.locator('pngx-document-list')).toHaveText(/8 documents/) await expect(page.locator('pngx-document-list')).toHaveText(/8 documents/)
}) })
test('dashboard total documents link', async ({ page }) => { test('dashboard total documents link', async ({ page }) => {
await page.routeFromHAR(REQUESTS_HAR2, { notFound: 'fallback' })
await page.goto('/dashboard') await page.goto('/dashboard')
await page.getByRole('link').filter({ hasText: 'Total documents' }).click() await page.getByRole('link').filter({ hasText: 'Total documents' }).click()
await expect(page).toHaveURL(/documents/) await expect(page).toHaveURL(/documents/)
@@ -24,7 +16,6 @@ test('dashboard total documents link', async ({ page }) => {
}) })
test('dashboard saved view show all', async ({ page }) => { test('dashboard saved view show all', async ({ page }) => {
await page.routeFromHAR(REQUESTS_HAR3, { notFound: 'fallback' })
await page.goto('/dashboard') await page.goto('/dashboard')
await page await page
.locator('pngx-widget-frame') .locator('pngx-widget-frame')
@@ -32,12 +23,11 @@ test('dashboard saved view show all', async ({ page }) => {
.getByRole('link', { name: 'Show all' }) .getByRole('link', { name: 'Show all' })
.first() .first()
.click() .click()
await expect(page).toHaveURL(/view\/7/) await expect(page).toHaveURL(/view\/1/)
await expect(page.locator('pngx-document-list')).toHaveText(/8 documents/) await expect(page.locator('pngx-document-list')).toHaveText(/8 documents/)
}) })
test('dashboard saved view document links', async ({ page }) => { test('dashboard saved view document links', async ({ page }) => {
await page.routeFromHAR(REQUESTS_HAR4, { notFound: 'fallback' })
await page.goto('/dashboard') await page.goto('/dashboard')
await page await page
.locator('pngx-widget-frame') .locator('pngx-widget-frame')
@@ -46,11 +36,10 @@ test('dashboard saved view document links', async ({ page }) => {
.getByRole('link', { name: /test/ }) .getByRole('link', { name: /test/ })
.first() .first()
.click({ position: { x: 0, y: 0 } }) .click({ position: { x: 0, y: 0 } })
await expect(page).toHaveURL(/documents\/310\/details/) await expect(page).toHaveURL(/documents\/1\/details/)
}) })
test('test slim sidebar', async ({ page }) => { test('test slim sidebar', async ({ page }) => {
await page.routeFromHAR(REQUESTS_HAR1, { notFound: 'fallback' })
await page.goto('/dashboard') await page.goto('/dashboard')
await page.locator('.sidebar-slim-toggler').click() await page.locator('.sidebar-slim-toggler').click()
await expect( await expect(
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -1,14 +1,9 @@
import { expect, test, type WebSocketRoute } from '@playwright/test' import { expect, test } from '@playwright/test'
import path from 'node:path'
const REQUESTS_HAR = path.join(__dirname, 'requests/api-document-detail.har')
const REQUESTS_HAR2 = path.join(__dirname, 'requests/api-document-detail2.har')
test('should activate / deactivate save button when changes are saved', async ({ test('should activate / deactivate save button when changes are saved', async ({
page, page,
}) => { }) => {
await page.routeFromHAR(REQUESTS_HAR, { notFound: 'fallback' }) await page.goto('/documents/1/')
await page.goto('/documents/175/')
await page.waitForSelector('pngx-document-detail pngx-input-text:first-child') await page.waitForSelector('pngx-document-detail pngx-input-text:first-child')
await expect(page.getByTitle('Storage path', { exact: true })).toHaveText( await expect(page.getByTitle('Storage path', { exact: true })).toHaveText(
/\w+/ /\w+/
@@ -19,8 +14,7 @@ test('should activate / deactivate save button when changes are saved', async ({
}) })
test('should warn on unsaved changes', async ({ page }) => { test('should warn on unsaved changes', async ({ page }) => {
await page.routeFromHAR(REQUESTS_HAR, { notFound: 'fallback' }) await page.goto('/documents/1/')
await page.goto('/documents/175/')
await expect(page.getByTitle('Correspondent', { exact: true })).toHaveText( await expect(page.getByTitle('Correspondent', { exact: true })).toHaveText(
/\w+/ /\w+/
) )
@@ -38,28 +32,27 @@ test('should warn on unsaved changes', async ({ page }) => {
}) })
test('should support tab direct navigation', async ({ page }) => { test('should support tab direct navigation', async ({ page }) => {
await page.routeFromHAR(REQUESTS_HAR, { notFound: 'fallback' }) await page.goto('/documents/1/details')
await page.goto('/documents/175/details')
await expect(page.getByRole('tab', { name: 'Details' })).toHaveAttribute( await expect(page.getByRole('tab', { name: 'Details' })).toHaveAttribute(
'aria-selected', 'aria-selected',
'true' 'true'
) )
await page.goto('/documents/175/content') await page.goto('/documents/1/content')
await expect(page.getByRole('tab', { name: 'Content' })).toHaveAttribute( await expect(page.getByRole('tab', { name: 'Content' })).toHaveAttribute(
'aria-selected', 'aria-selected',
'true' 'true'
) )
await page.goto('/documents/175/metadata') await page.goto('/documents/1/metadata')
await expect(page.getByRole('tab', { name: 'Metadata' })).toHaveAttribute( await expect(page.getByRole('tab', { name: 'Metadata' })).toHaveAttribute(
'aria-selected', 'aria-selected',
'true' 'true'
) )
await page.goto('/documents/175/notes') await page.goto('/documents/1/notes')
await expect(page.getByRole('tab', { name: 'Notes' })).toHaveAttribute( await expect(page.getByRole('tab', { name: 'Notes' })).toHaveAttribute(
'aria-selected', 'aria-selected',
'true' 'true'
) )
await page.goto('/documents/175/permissions') await page.goto('/documents/1/permissions')
await expect(page.getByRole('tab', { name: 'Permissions' })).toHaveAttribute( await expect(page.getByRole('tab', { name: 'Permissions' })).toHaveAttribute(
'aria-selected', 'aria-selected',
'true' 'true'
@@ -67,8 +60,7 @@ test('should support tab direct navigation', async ({ page }) => {
}) })
test('should show a mobile preview', async ({ page }) => { test('should show a mobile preview', async ({ page }) => {
await page.routeFromHAR(REQUESTS_HAR, { notFound: 'fallback' }) await page.goto('/documents/1/')
await page.goto('/documents/175/')
await page.setViewportSize({ width: 400, height: 1000 }) await page.setViewportSize({ width: 400, height: 1000 })
await expect(page.getByRole('tab', { name: 'Preview' })).toBeVisible() await expect(page.getByRole('tab', { name: 'Preview' })).toBeVisible()
await page.getByRole('tab', { name: 'Preview' }).click() await page.getByRole('tab', { name: 'Preview' }).click()
@@ -76,8 +68,7 @@ test('should show a mobile preview', async ({ page }) => {
}) })
test('should show a list of notes', async ({ page }) => { test('should show a list of notes', async ({ page }) => {
await page.routeFromHAR(REQUESTS_HAR, { notFound: 'fallback' }) await page.goto('/documents/1/notes')
await page.goto('/documents/175/notes')
await expect(page.locator('pngx-document-notes')).toBeVisible() await expect(page.locator('pngx-document-notes')).toBeVisible()
await expect( await expect(
await page.getByRole('button', { await page.getByRole('button', {
@@ -88,32 +79,25 @@ test('should show a list of notes', async ({ page }) => {
}) })
test('should support quick filters', async ({ page }) => { test('should support quick filters', async ({ page }) => {
await page.routeFromHAR(REQUESTS_HAR2, { notFound: 'fallback' }) await page.goto('/documents/1/details')
await page.goto('/documents/175/details')
await page await page
.getByRole('button', { name: 'Filter documents with these Tags' }) .getByRole('button', { name: 'Filter documents with these Tags' })
.click() .click()
await expect(page).toHaveURL(/tags__id__all=4&sort=created&reverse=1&page=1/) await expect(page).toHaveURL(
/tags__id__all=2,1&sort=created&reverse=1&page=1/
)
}) })
test('should finish reloading the preview after a remote document update', async ({ test('should finish reloading the preview after a remote document update', async ({
page, page,
}) => { }) => {
let resolveStatusSocket: (socket: WebSocketRoute) => void
const statusSocketReady = new Promise<WebSocketRoute>((resolve) => {
resolveStatusSocket = resolve
})
await page.routeWebSocket(/\/ws\/status\/$/, (socket) => {
resolveStatusSocket(socket)
})
await page.routeFromHAR(REQUESTS_HAR, { notFound: 'fallback' })
let previewRequestCount = 0 let previewRequestCount = 0
page.on('request', (request) => { page.on('request', (request) => {
if (request.url().includes('/api/documents/175/preview/')) { if (request.url().includes('/api/documents/1/preview/')) {
previewRequestCount++ previewRequestCount++
} }
}) })
await page.goto('/documents/175/details') await page.goto('/documents/1/details')
await page.locator('pngx-document-detail').waitFor() await page.locator('pngx-document-detail').waitFor()
await expect(page.getByTitle('Storage path', { exact: true })).toHaveText( await expect(page.getByTitle('Storage path', { exact: true })).toHaveText(
@@ -128,27 +112,37 @@ test('should finish reloading the preview after a remote document update', async
expect(previewWasLoaded).toBe(true) expect(previewWasLoaded).toBe(true)
const previewRequestsBeforeReload = previewRequestCount const previewRequestsBeforeReload = previewRequestCount
const statusSocket = await statusSocketReady await expect
const documentReloaded = page.waitForResponse( .poll(() =>
(response) => page.evaluate(() => {
response.url().includes('/api/documents/175/?full_perms=true') && const detail = document.querySelector('pngx-document-detail')
response.request().method() === 'GET' return (window as any).ng.getComponent(detail).networkActive()
)
statusSocket.send(
JSON.stringify({
type: 'document_updated',
data: {
document_id: 175,
modified: '2026-07-26T20:00:00Z',
},
}) })
) )
.toBe(false)
const documentReloaded = page.waitForResponse(
(response) =>
response.url().includes('/api/documents/1/?full_perms=true') &&
response.request().method() === 'GET'
)
await page.evaluate(() => {
const detail = document.querySelector('pngx-document-detail')
const component = (window as any).ng.getComponent(detail)
component.handleIncomingDocumentUpdated({
document_id: 1,
modified: '2099-07-26T20:00:00Z',
})
})
await documentReloaded await documentReloaded
await expect(
page.getByText('Document reloaded with latest changes.').first()
).toBeVisible()
await expect await expect
.poll(() => previewRequestCount) .poll(() => previewRequestCount)
.toBeGreaterThan(previewRequestsBeforeReload + 1) .toBeGreaterThan(previewRequestsBeforeReload)
await expect
.poll(() =>
page.evaluate(() => {
const detail = document.querySelector('pngx-document-detail')
return (window as any).ng.getComponent(detail).previewLoaded()
})
)
.toBe(true)
}) })
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+41 -31
View File
@@ -1,19 +1,10 @@
import { expect, test } from '@playwright/test' import { expect, test } from '@playwright/test'
import path from 'node:path'
const REQUESTS_HAR1 = path.join(__dirname, 'requests/api-document-list1.har')
const REQUESTS_HAR2 = path.join(__dirname, 'requests/api-document-list2.har')
const REQUESTS_HAR3 = path.join(__dirname, 'requests/api-document-list3.har')
const REQUESTS_HAR4 = path.join(__dirname, 'requests/api-document-list4.har')
const REQUESTS_HAR5 = path.join(__dirname, 'requests/api-document-list5.har')
const REQUESTS_HAR6 = path.join(__dirname, 'requests/api-document-list6.har')
test('basic filtering', async ({ page }) => { test('basic filtering', async ({ page }) => {
await page.routeFromHAR(REQUESTS_HAR1, { notFound: 'fallback' })
await page.goto('/documents') await page.goto('/documents')
await page.getByRole('button', { name: 'Tags' }).click() await page.getByRole('button', { name: 'Tags' }).click()
await page.getByRole('menuitem', { name: 'Inbox' }).click() await page.getByRole('menuitem', { name: 'Inbox' }).click()
await expect(page).toHaveURL(/tags__id__all=9/) await expect(page).toHaveURL(/tags__id__all=1/)
await expect(page.locator('pngx-document-list')).toHaveText(/8 documents/) await expect(page.locator('pngx-document-list')).toHaveText(/8 documents/)
await page.getByRole('button', { name: 'Document type' }).click() await page.getByRole('button', { name: 'Document type' }).click()
await page.getByRole('menuitem', { name: /^Invoice Test/ }).click() await page.getByRole('menuitem', { name: /^Invoice Test/ }).click()
@@ -23,28 +14,27 @@ test('basic filtering', async ({ page }) => {
await page.getByRole('button', { name: 'Correspondent' }).click() await page.getByRole('button', { name: 'Correspondent' }).click()
await page.getByRole('menuitem', { name: 'Test Correspondent 1' }).click() await page.getByRole('menuitem', { name: 'Test Correspondent 1' }).click()
await page.getByRole('menuitem', { name: 'Correspondent 9' }).click() await page.getByRole('menuitem', { name: 'Correspondent 9' }).click()
await expect(page).toHaveURL(/correspondent__id__in=12,1/) await expect(page).toHaveURL(/correspondent__id__in=(?:1,2|2,1)/)
await expect(page.locator('pngx-document-list')).toHaveText(/7 documents/) await expect(page.locator('pngx-document-list')).toHaveText(/7 documents/)
await page await page
.locator('pngx-filter-editor') .locator('pngx-filter-editor')
.getByTitle('Correspondent') .getByTitle('Correspondent')
.getByText('Exclude') .getByText('Exclude')
.click() .click()
await expect(page).toHaveURL(/correspondent__id__none=12,1/) await expect(page).toHaveURL(/correspondent__id__none=(?:1,2|2,1)/)
await expect(page.locator('pngx-document-list')).toHaveText(/54 documents/) await expect(page.locator('pngx-document-list')).toHaveText(/54 documents/)
// clear button // clear button
await page.getByRole('button', { name: '2 selected', exact: true }).click() await page.getByRole('button', { name: '2 selected', exact: true }).click()
await expect(page.locator('pngx-document-list')).toHaveText(/61 documents/) await expect(page.locator('pngx-document-list')).toHaveText(/61 documents/)
await page.getByRole('button', { name: 'Storage path' }).click() await page.getByRole('button', { name: 'Storage path' }).click()
await page.getByRole('menuitem', { name: 'Testing 12' }).click() await page.getByRole('menuitem', { name: 'Testing 12' }).click()
await expect(page).toHaveURL(/storage_path__id__in=5/) await expect(page).toHaveURL(/storage_path__id__in=1/)
await expect(page.locator('pngx-document-list')).toHaveText(/8 documents/) await expect(page.locator('pngx-document-list')).toHaveText(/8 documents/)
await page.getByRole('button', { name: 'Reset filters' }).first().click() await page.getByRole('button', { name: 'Reset filters' }).first().click()
await expect(page.locator('pngx-document-list')).toHaveText(/61 documents/) await expect(page.locator('pngx-document-list')).toHaveText(/61 documents/)
}) })
test('text filtering', async ({ page }) => { test('text filtering', async ({ page }) => {
await page.routeFromHAR(REQUESTS_HAR2, { notFound: 'fallback' })
await page.goto('/documents') await page.goto('/documents')
await page.getByRole('main').getByRole('combobox').click() await page.getByRole('main').getByRole('combobox').click()
await page.getByRole('main').getByRole('combobox').fill('test') await page.getByRole('main').getByRole('combobox').fill('test')
@@ -57,7 +47,7 @@ test('text filtering', async ({ page }) => {
await page.getByRole('button', { name: 'Title', exact: true }).click() await page.getByRole('button', { name: 'Title', exact: true }).click()
await page.getByRole('button', { name: 'Advanced search' }).click() await page.getByRole('button', { name: 'Advanced search' }).click()
await expect(page).toHaveURL(/query=test/) await expect(page).toHaveURL(/query=test/)
await expect(page.locator('pngx-document-list')).toHaveText(/26 documents/) await expect(page.locator('pngx-document-list')).toHaveText(/32 documents/)
await page.getByRole('button', { name: 'Advanced search' }).click() await page.getByRole('button', { name: 'Advanced search' }).click()
await page.getByRole('button', { name: 'ASN' }).click() await page.getByRole('button', { name: 'ASN' }).click()
await page.getByRole('main').getByRole('combobox').nth(1).fill('1123') await page.getByRole('main').getByRole('combobox').nth(1).fill('1123')
@@ -80,7 +70,6 @@ test('text filtering', async ({ page }) => {
}) })
test('date filtering', async ({ page }) => { test('date filtering', async ({ page }) => {
await page.routeFromHAR(REQUESTS_HAR3, { notFound: 'fallback' })
await page.goto('/documents') await page.goto('/documents')
await page.getByRole('button', { name: 'Dates' }).click() await page.getByRole('button', { name: 'Dates' }).click()
await page.locator('.ng-arrow-wrapper').first().click() await page.locator('.ng-arrow-wrapper').first().click()
@@ -94,15 +83,14 @@ test('date filtering', async ({ page }) => {
await page.getByRole('option', { name: 'Within 3 months' }).click() await page.getByRole('option', { name: 'Within 3 months' }).click()
await page.getByLabel('Dates selected').locator('button').first().click() await page.getByLabel('Dates selected').locator('button').first().click()
await page.getByLabel('Dates selected').locator('button').first().click() await page.getByLabel('Dates selected').locator('button').first().click()
await page.getByRole('combobox', { name: 'Select month' }).selectOption('12') const createdFrom = page.getByRole('textbox', { name: 'mm/dd/yyyy' }).first()
await page.getByRole('combobox', { name: 'Select year' }).selectOption('2022') await createdFrom.fill('12/11/2022')
await page.getByText('11', { exact: true }).click() await createdFrom.press('Enter')
await page.getByRole('button', { name: 'Title & content' }).click() await page.getByRole('button', { name: 'Title & content' }).click()
await expect(page.locator('pngx-document-list')).toHaveText(/2 documents/) await expect(page.locator('pngx-document-list')).toHaveText(/3 documents/)
}) })
test('sorting', async ({ page }) => { test('sorting', async ({ page }) => {
await page.routeFromHAR(REQUESTS_HAR4, { notFound: 'fallback' })
await page.goto('/documents') await page.goto('/documents')
await page.getByRole('button', { name: 'Sort' }).click() await page.getByRole('button', { name: 'Sort' }).click()
await page.getByRole('button', { name: 'ASN' }).click() await page.getByRole('button', { name: 'ASN' }).click()
@@ -140,7 +128,6 @@ test('sorting', async ({ page }) => {
}) })
test('change views', async ({ page }) => { test('change views', async ({ page }) => {
await page.routeFromHAR(REQUESTS_HAR5, { notFound: 'fallback' })
await page.goto('/documents') await page.goto('/documents')
await page.locator('.btn-group > label').first().click() await page.locator('.btn-group > label').first().click()
await expect(page.locator('pngx-document-list table')).toBeVisible() await expect(page.locator('pngx-document-list table')).toBeVisible()
@@ -151,12 +138,18 @@ test('change views', async ({ page }) => {
}) })
test('bulk edit', async ({ page }) => { test('bulk edit', async ({ page }) => {
await page.routeFromHAR(REQUESTS_HAR6, { notFound: 'fallback' }) const uiSettingsLoaded = page.waitForResponse(
(response) => response.url().includes('/api/ui_settings/') && response.ok()
)
await page.goto('/documents') await page.goto('/documents')
await uiSettingsLoaded
await page.locator('pngx-document-card-small').nth(0).click()
await page await page
.locator('pngx-document-card-small') .locator('pngx-document-card-small .doc-img-container')
.nth(0)
.click()
await page
.locator('pngx-document-card-small .doc-img-container')
.nth(3) .nth(3)
.click({ .click({
modifiers: ['Shift'], modifiers: ['Shift'],
@@ -176,8 +169,14 @@ test('bulk edit', async ({ page }) => {
) )
await page.getByRole('button', { name: 'None' }).click() await page.getByRole('button', { name: 'None' }).click()
await page.locator('pngx-document-card-small').nth(1).click() await page
await page.locator('pngx-document-card-small').nth(2).click() .locator('pngx-document-card-small .doc-img-container')
.nth(1)
.click()
await page
.locator('pngx-document-card-small .doc-img-container')
.nth(2)
.click()
await page.getByRole('button', { name: 'Tags' }).click() await page.getByRole('button', { name: 'Tags' }).click()
await page await page
@@ -185,15 +184,26 @@ test('bulk edit', async ({ page }) => {
.fill('TagWithPartial') .fill('TagWithPartial')
await page.getByRole('menuitem', { name: 'TagWithPartial' }).click() await page.getByRole('menuitem', { name: 'TagWithPartial' }).click()
await page.getByRole('button', { name: 'Apply' }).click()
const bulkEditPromise = page.waitForRequest((request) => { const bulkEditPromise = page.waitForRequest((request) => {
if (
!request.url().includes('/api/documents/bulk_edit/') ||
request.method() !== 'POST'
) {
return false
}
const postData = request.postDataJSON() const postData = request.postDataJSON()
let isValid = postData['method'] == 'modify_tags' let isValid = postData['method'] == 'modify_tags'
isValid = isValid && postData['parameters']['add_tags'].includes(5) isValid =
return request.url().toString().includes('bulk_edit') && isValid isValid &&
[
...postData['parameters']['add_tags'],
...postData['parameters']['remove_tags'],
].includes(3)
return isValid
}) })
await page.getByRole('button', { name: 'Apply' }).click()
await expect(page.getByRole('button', { name: 'Confirm' })).toBeVisible()
await page.getByRole('button', { name: 'Confirm' }).click() await page.getByRole('button', { name: 'Confirm' }).click()
await bulkEditPromise await bulkEditPromise
}) })
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -1,10 +1,12 @@
import { expect, test } from '@playwright/test' import { expect, test } from '@playwright/test'
import path from 'node:path'
const REQUESTS_HAR = path.join(__dirname, 'requests/api-global-permissions.har') test.use({
extraHTTPHeaders: {
Authorization: `Basic ${Buffer.from('viewer:viewer').toString('base64')}`,
},
})
test('should not allow user to edit settings', async ({ page }) => { test('should not allow user to edit settings', async ({ page }) => {
await page.routeFromHAR(REQUESTS_HAR, { notFound: 'fallback' })
await page.goto('/dashboard') await page.goto('/dashboard')
await expect(page.getByRole('link', { name: 'Settings' })).not.toBeAttached() await expect(page.getByRole('link', { name: 'Settings' })).not.toBeAttached()
await page.goto('/settings') await page.goto('/settings')
@@ -14,7 +16,6 @@ test('should not allow user to edit settings', async ({ page }) => {
}) })
test('should not allow user to view documents', async ({ page }) => { test('should not allow user to view documents', async ({ page }) => {
await page.routeFromHAR(REQUESTS_HAR, { notFound: 'fallback' })
await page.goto('/dashboard') await page.goto('/dashboard')
await expect( await expect(
page.locator('nav').getByRole('link', { name: 'Documents' }) page.locator('nav').getByRole('link', { name: 'Documents' })
@@ -30,7 +31,6 @@ test('should not allow user to view documents', async ({ page }) => {
}) })
test('should not allow user to view correspondents', async ({ page }) => { test('should not allow user to view correspondents', async ({ page }) => {
await page.routeFromHAR(REQUESTS_HAR, { notFound: 'fallback' })
await page.goto('/dashboard') await page.goto('/dashboard')
await expect( await expect(
page.getByRole('link', { name: 'Attributes' }) page.getByRole('link', { name: 'Attributes' })
@@ -42,7 +42,6 @@ test('should not allow user to view correspondents', async ({ page }) => {
}) })
test('should not allow user to view tags', async ({ page }) => { test('should not allow user to view tags', async ({ page }) => {
await page.routeFromHAR(REQUESTS_HAR, { notFound: 'fallback' })
await page.goto('/dashboard') await page.goto('/dashboard')
await expect( await expect(
page.getByRole('link', { name: 'Attributes' }) page.getByRole('link', { name: 'Attributes' })
@@ -54,7 +53,6 @@ test('should not allow user to view tags', async ({ page }) => {
}) })
test('should not allow user to view document types', async ({ page }) => { test('should not allow user to view document types', async ({ page }) => {
await page.routeFromHAR(REQUESTS_HAR, { notFound: 'fallback' })
await page.goto('/dashboard') await page.goto('/dashboard')
await expect( await expect(
page.getByRole('link', { name: 'Attributes' }) page.getByRole('link', { name: 'Attributes' })
@@ -66,7 +64,6 @@ test('should not allow user to view document types', async ({ page }) => {
}) })
test('should not allow user to view storage paths', async ({ page }) => { test('should not allow user to view storage paths', async ({ page }) => {
await page.routeFromHAR(REQUESTS_HAR, { notFound: 'fallback' })
await page.goto('/dashboard') await page.goto('/dashboard')
await expect( await expect(
page.getByRole('link', { name: 'Attributes' }) page.getByRole('link', { name: 'Attributes' })
@@ -78,7 +75,6 @@ test('should not allow user to view storage paths', async ({ page }) => {
}) })
test('should not allow user to view logs', async ({ page }) => { test('should not allow user to view logs', async ({ page }) => {
await page.routeFromHAR(REQUESTS_HAR, { notFound: 'fallback' })
await page.goto('/dashboard') await page.goto('/dashboard')
await expect(page.getByRole('link', { name: 'Logs' })).not.toBeAttached() await expect(page.getByRole('link', { name: 'Logs' })).not.toBeAttached()
await page.goto('/logs') await page.goto('/logs')
@@ -88,7 +84,6 @@ test('should not allow user to view logs', async ({ page }) => {
}) })
test('should not allow user to view tasks', async ({ page }) => { test('should not allow user to view tasks', async ({ page }) => {
await page.routeFromHAR(REQUESTS_HAR, { notFound: 'fallback' })
await page.goto('/dashboard') await page.goto('/dashboard')
await expect(page.getByRole('link', { name: 'Tasks' })).not.toBeAttached() await expect(page.getByRole('link', { name: 'Tasks' })).not.toBeAttached()
await page.goto('/tasks') await page.goto('/tasks')
@@ -1,353 +0,0 @@
{
"log": {
"version": "1.2",
"creator": {
"name": "Playwright",
"version": "1.33.0"
},
"browser": {
"name": "chromium",
"version": "113.0.5672.53"
},
"entries": [
{
"startedDateTime": "2023-05-14T07:16:51.455Z",
"time": 5.787,
"request": {
"method": "GET",
"url": "http://localhost:8000/api/ui_settings/",
"httpVersion": "HTTP/1.1",
"cookies": [],
"headers": [
{ "name": "Accept", "value": "application/json; version=3" },
{ "name": "Accept-Encoding", "value": "gzip, deflate, br" },
{ "name": "Accept-Language", "value": "en-US" },
{ "name": "Connection", "value": "keep-alive" },
{ "name": "Host", "value": "localhost:8000" },
{ "name": "Origin", "value": "http://localhost:4200" },
{ "name": "Referer", "value": "http://localhost:4200/" },
{ "name": "Sec-Fetch-Dest", "value": "empty" },
{ "name": "Sec-Fetch-Mode", "value": "cors" },
{ "name": "Sec-Fetch-Site", "value": "same-site" },
{ "name": "User-Agent", "value": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/113.0.5672.53 Safari/537.36" }
],
"queryString": [],
"headersSize": -1,
"bodySize": -1
},
"response": {
"status": 200,
"statusText": "OK",
"httpVersion": "HTTP/1.1",
"cookies": [],
"headers": [
{ "name": "Access-Control-Allow-Origin", "value": "http://localhost:4200" },
{ "name": "Allow", "value": "GET, POST, HEAD, OPTIONS" },
{ "name": "Content-Encoding", "value": "br" },
{ "name": "Content-Language", "value": "en-us" },
{ "name": "Content-Length", "value": "385" },
{ "name": "Content-Type", "value": "application/json" },
{ "name": "Cross-Origin-Opener-Policy", "value": "same-origin" },
{ "name": "Referrer-Policy", "value": "same-origin" },
{ "name": "Vary", "value": "Accept, Accept-Language, Origin, Cookie, Accept-Encoding" },
{ "name": "X-Api-Version", "value": "3" },
{ "name": "X-Content-Type-Options", "value": "nosniff" },
{ "name": "X-Frame-Options", "value": "ANY" },
{ "name": "X-Version", "value": "1.14.4" }
],
"content": {
"size": -1,
"mimeType": "application/json",
"text": "{\"user\":{\"id\":2,\"username\":\"testuser\",\"is_superuser\":false,\"groups\":[]},\"settings\":{\"language\":\"\",\"bulk_edit\":{\"confirmation_dialogs\":true,\"apply_on_close\":false},\"documentListSize\":50,\"dark_mode\":{\"use_system\":false,\"enabled\":\"false\",\"thumb_inverted\":\"true\"},\"theme\":{\"color\":\"#9fbf2f\"},\"document_details\":{\"native_pdf_viewer\":false},\"date_display\":{\"date_locale\":\"\",\"date_format\":\"mediumDate\"},\"notifications\":{\"consumer_new_documents\":true,\"consumer_success\":true,\"consumer_failed\":true,\"consumer_suppress_on_dashboard\":true},\"comments_enabled\":true,\"slim_sidebar\":false,\"update_checking\":{\"enabled\":false,\"backend_setting\":\"default\"},\"saved_views\":{\"warn_on_unsaved_change\":true},\"notes_enabled\":true,\"tour_complete\":true},\"permissions\":[]}"
},
"headersSize": -1,
"bodySize": -1,
"redirectURL": ""
},
"cache": {},
"timings": { "send": -1, "wait": -1, "receive": 5.787 }
},
{
"startedDateTime": "2023-05-14T07:16:51.578Z",
"time": 0.566,
"request": {
"method": "GET",
"url": "http://localhost:8000/api/tasks/",
"httpVersion": "HTTP/1.1",
"cookies": [],
"headers": [
{ "name": "Accept", "value": "application/json; version=3" },
{ "name": "Accept-Encoding", "value": "gzip, deflate, br" },
{ "name": "Accept-Language", "value": "en-US" },
{ "name": "Connection", "value": "keep-alive" },
{ "name": "Host", "value": "localhost:8000" },
{ "name": "Origin", "value": "http://localhost:4200" },
{ "name": "Referer", "value": "http://localhost:4200/" },
{ "name": "Sec-Fetch-Dest", "value": "empty" },
{ "name": "Sec-Fetch-Mode", "value": "cors" },
{ "name": "Sec-Fetch-Site", "value": "same-site" },
{ "name": "User-Agent", "value": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/113.0.5672.53 Safari/537.36" }
],
"queryString": [],
"headersSize": -1,
"bodySize": -1
},
"response": {
"status": 200,
"statusText": "OK",
"httpVersion": "HTTP/1.1",
"cookies": [],
"headers": [
{ "name": "Access-Control-Allow-Origin", "value": "http://localhost:4200" },
{ "name": "Allow", "value": "GET, HEAD, OPTIONS" },
{ "name": "Content-Language", "value": "en-us" },
{ "name": "Content-Length", "value": "2" },
{ "name": "Content-Type", "value": "application/json" },
{ "name": "Cross-Origin-Opener-Policy", "value": "same-origin" },
{ "name": "Referrer-Policy", "value": "same-origin" },
{ "name": "Vary", "value": "Accept, Accept-Language, Origin, Cookie" },
{ "name": "X-Api-Version", "value": "3" },
{ "name": "X-Content-Type-Options", "value": "nosniff" },
{ "name": "X-Frame-Options", "value": "ANY" },
{ "name": "X-Version", "value": "1.14.4" }
],
"content": {
"size": -1,
"mimeType": "application/json",
"text": "[]"
},
"headersSize": -1,
"bodySize": -1,
"redirectURL": ""
},
"cache": {},
"timings": { "send": -1, "wait": -1, "receive": 0.566 }
},
{
"startedDateTime": "2023-05-14T07:16:51.578Z",
"time": 0.452,
"request": {
"method": "GET",
"url": "http://localhost:8000/api/statistics/",
"httpVersion": "HTTP/1.1",
"cookies": [],
"headers": [
{ "name": "Accept", "value": "application/json; version=3" },
{ "name": "Accept-Encoding", "value": "gzip, deflate, br" },
{ "name": "Accept-Language", "value": "en-US" },
{ "name": "Connection", "value": "keep-alive" },
{ "name": "Host", "value": "localhost:8000" },
{ "name": "Origin", "value": "http://localhost:4200" },
{ "name": "Referer", "value": "http://localhost:4200/" },
{ "name": "Sec-Fetch-Dest", "value": "empty" },
{ "name": "Sec-Fetch-Mode", "value": "cors" },
{ "name": "Sec-Fetch-Site", "value": "same-site" },
{ "name": "User-Agent", "value": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/113.0.5672.53 Safari/537.36" }
],
"queryString": [],
"headersSize": -1,
"bodySize": -1
},
"response": {
"status": 200,
"statusText": "OK",
"httpVersion": "HTTP/1.1",
"cookies": [],
"headers": [
{ "name": "Access-Control-Allow-Origin", "value": "http://localhost:4200" },
{ "name": "Allow", "value": "GET, HEAD, OPTIONS" },
{ "name": "Content-Language", "value": "en-us" },
{ "name": "Content-Length", "value": "257" },
{ "name": "Content-Type", "value": "application/json" },
{ "name": "Cross-Origin-Opener-Policy", "value": "same-origin" },
{ "name": "Referrer-Policy", "value": "same-origin" },
{ "name": "Vary", "value": "Accept, Accept-Language, Origin, Cookie" },
{ "name": "X-Api-Version", "value": "3" },
{ "name": "X-Content-Type-Options", "value": "nosniff" },
{ "name": "X-Frame-Options", "value": "ANY" },
{ "name": "X-Version", "value": "1.14.4" }
],
"content": {
"size": -1,
"mimeType": "application/json",
"text": "{\"documents_total\":61,\"documents_inbox\":8,\"inbox_tag\":9,\"document_file_type_counts\":[{\"mime_type\":\"application/pdf\",\"mime_type_count\":57},{\"mime_type\":\"text/plain\",\"mime_type_count\":3},{\"mime_type\":\"text/csv\",\"mime_type_count\":1}],\"character_count\":2407053}"
},
"headersSize": -1,
"bodySize": -1,
"redirectURL": ""
},
"cache": {},
"timings": { "send": -1, "wait": -1, "receive": 0.452 }
},
{
"startedDateTime": "2023-05-14T07:16:51.691Z",
"time": 0.891,
"request": {
"method": "GET",
"url": "http://localhost:8000/api/ui_settings/",
"httpVersion": "HTTP/1.1",
"cookies": [],
"headers": [
{ "name": "Accept", "value": "application/json; version=3" },
{ "name": "Accept-Encoding", "value": "gzip, deflate, br" },
{ "name": "Accept-Language", "value": "en-US" },
{ "name": "Connection", "value": "keep-alive" },
{ "name": "Host", "value": "localhost:8000" },
{ "name": "Origin", "value": "http://localhost:4200" },
{ "name": "Referer", "value": "http://localhost:4200/" },
{ "name": "Sec-Fetch-Dest", "value": "empty" },
{ "name": "Sec-Fetch-Mode", "value": "cors" },
{ "name": "Sec-Fetch-Site", "value": "same-site" },
{ "name": "User-Agent", "value": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/113.0.5672.53 Safari/537.36" }
],
"queryString": [],
"headersSize": -1,
"bodySize": -1
},
"response": {
"status": 200,
"statusText": "OK",
"httpVersion": "HTTP/1.1",
"cookies": [],
"headers": [
{ "name": "Access-Control-Allow-Origin", "value": "http://localhost:4200" },
{ "name": "Allow", "value": "GET, POST, HEAD, OPTIONS" },
{ "name": "Content-Encoding", "value": "br" },
{ "name": "Content-Language", "value": "en-us" },
{ "name": "Content-Length", "value": "385" },
{ "name": "Content-Type", "value": "application/json" },
{ "name": "Cross-Origin-Opener-Policy", "value": "same-origin" },
{ "name": "Referrer-Policy", "value": "same-origin" },
{ "name": "Vary", "value": "Accept, Accept-Language, Origin, Cookie, Accept-Encoding" },
{ "name": "X-Api-Version", "value": "3" },
{ "name": "X-Content-Type-Options", "value": "nosniff" },
{ "name": "X-Frame-Options", "value": "ANY" },
{ "name": "X-Version", "value": "1.14.4" }
],
"content": {
"size": -1,
"mimeType": "application/json",
"text": "{\"user\":{\"id\":2,\"username\":\"testuser\",\"is_superuser\":false,\"groups\":[]},\"settings\":{\"language\":\"\",\"bulk_edit\":{\"confirmation_dialogs\":true,\"apply_on_close\":false},\"documentListSize\":50,\"dark_mode\":{\"use_system\":false,\"enabled\":\"false\",\"thumb_inverted\":\"true\"},\"theme\":{\"color\":\"#9fbf2f\"},\"document_details\":{\"native_pdf_viewer\":false},\"date_display\":{\"date_locale\":\"\",\"date_format\":\"mediumDate\"},\"notifications\":{\"consumer_new_documents\":true,\"consumer_success\":true,\"consumer_failed\":true,\"consumer_suppress_on_dashboard\":true},\"comments_enabled\":true,\"slim_sidebar\":false,\"update_checking\":{\"enabled\":false,\"backend_setting\":\"default\"},\"saved_views\":{\"warn_on_unsaved_change\":true},\"notes_enabled\":true,\"tour_complete\":true},\"permissions\":[]}"
},
"headersSize": -1,
"bodySize": -1,
"redirectURL": ""
},
"cache": {},
"timings": { "send": -1, "wait": -1, "receive": 0.891 }
},
{
"startedDateTime": "2023-05-14T07:16:51.739Z",
"time": 0.405,
"request": {
"method": "GET",
"url": "http://localhost:8000/api/tasks/",
"httpVersion": "HTTP/1.1",
"cookies": [],
"headers": [
{ "name": "Accept", "value": "application/json; version=3" },
{ "name": "Accept-Encoding", "value": "gzip, deflate, br" },
{ "name": "Accept-Language", "value": "en-US" },
{ "name": "Connection", "value": "keep-alive" },
{ "name": "Host", "value": "localhost:8000" },
{ "name": "Origin", "value": "http://localhost:4200" },
{ "name": "Referer", "value": "http://localhost:4200/" },
{ "name": "Sec-Fetch-Dest", "value": "empty" },
{ "name": "Sec-Fetch-Mode", "value": "cors" },
{ "name": "Sec-Fetch-Site", "value": "same-site" },
{ "name": "User-Agent", "value": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/113.0.5672.53 Safari/537.36" }
],
"queryString": [],
"headersSize": -1,
"bodySize": -1
},
"response": {
"status": 200,
"statusText": "OK",
"httpVersion": "HTTP/1.1",
"cookies": [],
"headers": [
{ "name": "Access-Control-Allow-Origin", "value": "http://localhost:4200" },
{ "name": "Allow", "value": "GET, HEAD, OPTIONS" },
{ "name": "Content-Language", "value": "en-us" },
{ "name": "Content-Length", "value": "2" },
{ "name": "Content-Type", "value": "application/json" },
{ "name": "Cross-Origin-Opener-Policy", "value": "same-origin" },
{ "name": "Referrer-Policy", "value": "same-origin" },
{ "name": "Vary", "value": "Accept, Accept-Language, Origin, Cookie" },
{ "name": "X-Api-Version", "value": "3" },
{ "name": "X-Content-Type-Options", "value": "nosniff" },
{ "name": "X-Frame-Options", "value": "ANY" },
{ "name": "X-Version", "value": "1.14.4" }
],
"content": {
"size": -1,
"mimeType": "application/json",
"text": "[]"
},
"headersSize": -1,
"bodySize": -1,
"redirectURL": ""
},
"cache": {},
"timings": { "send": -1, "wait": -1, "receive": 0.405 }
},
{
"startedDateTime": "2023-05-14T07:16:51.739Z",
"time": 0.665,
"request": {
"method": "GET",
"url": "http://localhost:8000/api/statistics/",
"httpVersion": "HTTP/1.1",
"cookies": [],
"headers": [
{ "name": "Accept", "value": "application/json; version=3" },
{ "name": "Accept-Encoding", "value": "gzip, deflate, br" },
{ "name": "Accept-Language", "value": "en-US" },
{ "name": "Connection", "value": "keep-alive" },
{ "name": "Host", "value": "localhost:8000" },
{ "name": "Origin", "value": "http://localhost:4200" },
{ "name": "Referer", "value": "http://localhost:4200/" },
{ "name": "Sec-Fetch-Dest", "value": "empty" },
{ "name": "Sec-Fetch-Mode", "value": "cors" },
{ "name": "Sec-Fetch-Site", "value": "same-site" },
{ "name": "User-Agent", "value": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/113.0.5672.53 Safari/537.36" }
],
"queryString": [],
"headersSize": -1,
"bodySize": -1
},
"response": {
"status": 200,
"statusText": "OK",
"httpVersion": "HTTP/1.1",
"cookies": [],
"headers": [
{ "name": "Access-Control-Allow-Origin", "value": "http://localhost:4200" },
{ "name": "Allow", "value": "GET, HEAD, OPTIONS" },
{ "name": "Content-Language", "value": "en-us" },
{ "name": "Content-Length", "value": "257" },
{ "name": "Content-Type", "value": "application/json" },
{ "name": "Cross-Origin-Opener-Policy", "value": "same-origin" },
{ "name": "Referrer-Policy", "value": "same-origin" },
{ "name": "Vary", "value": "Accept, Accept-Language, Origin, Cookie" },
{ "name": "X-Api-Version", "value": "3" },
{ "name": "X-Content-Type-Options", "value": "nosniff" },
{ "name": "X-Frame-Options", "value": "ANY" },
{ "name": "X-Version", "value": "1.14.4" }
],
"content": {
"size": -1,
"mimeType": "application/json",
"text": "{\"documents_total\":61,\"documents_inbox\":8,\"inbox_tag\":9,\"document_file_type_counts\":[{\"mime_type\":\"application/pdf\",\"mime_type_count\":57},{\"mime_type\":\"text/plain\",\"mime_type_count\":3},{\"mime_type\":\"text/csv\",\"mime_type_count\":1}],\"character_count\":2407053}"
},
"headersSize": -1,
"bodySize": -1,
"redirectURL": ""
},
"cache": {},
"timings": { "send": -1, "wait": -1, "receive": 0.665 }
}
]
}
}
+5 -1
View File
@@ -1,11 +1,15 @@
{ {
"name": "paperless-ngx-ui", "name": "paperless-ngx-ui",
"version": "3.1.1", "version": "3.1.2",
"scripts": { "scripts": {
"preinstall": "npx only-allow pnpm", "preinstall": "npx only-allow pnpm",
"ng": "ng", "ng": "ng",
"start": "ng serve", "start": "ng serve",
"build": "ng build", "build": "ng build",
"e2e": "playwright test",
"e2e:backend": "uv run --project .. --no-sync python e2e/backend.py",
"e2e:ui": "playwright test --ui",
"start:e2e": "ng serve --configuration=e2e",
"test": "ng test", "test": "ng test",
"lint": "ng lint" "lint": "ng lint"
}, },
+13 -5
View File
@@ -14,17 +14,25 @@ export default defineConfig({
forbidOnly: !!process.env.CI, forbidOnly: !!process.env.CI,
/* Retry on CI only */ /* Retry on CI only */
retries: process.env.CI ? 3 : 0, retries: process.env.CI ? 3 : 0,
/* Opt out of parallel tests on CI. */ /* Keep parallelism modest for the shared SQLite backend. */
workers: process.env.CI ? 1 : undefined, workers: 2,
/* Reporter to use. See https://playwright.dev/docs/test-reporters */ /* Reporter to use. See https://playwright.dev/docs/test-reporters */
reporter: 'html', reporter: 'html',
/* Run your local dev server before starting the tests */ /* Run the disposable backend and local UI before starting the tests. */
webServer: { webServer: [
{
url: 'http://localhost:8001/accounts/login/',
command: 'npm run e2e:backend',
reuseExistingServer: false,
timeout: 2 * 60 * 1000,
},
{
port, port,
command: 'pnpm run start', command: 'npm run start:e2e',
reuseExistingServer: !process.env.CI, reuseExistingServer: !process.env.CI,
timeout: 2 * 60 * 1000, timeout: 2 * 60 * 1000,
}, },
],
/* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */ /* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */
use: { use: {
/* Base URL to use in actions like `await page.goto('/')`. */ /* Base URL to use in actions like `await page.goto('/')`. */
@@ -111,7 +111,7 @@
</h6> </h6>
<ul class="nav flex-column mb-2" cdkDropList (cdkDropListDropped)="onDrop($event)"> <ul class="nav flex-column mb-2" cdkDropList (cdkDropListDropped)="onDrop($event)">
@for (view of savedViewService.sidebarViews; track view.id) { @for (view of savedViewService.sidebarViews; track view.id) {
<li class="nav-item w-100 app-link" cdkDrag [cdkDragDisabled]="!settingsService.organizingSidebarSavedViews() || !canSaveSettings" <li class="nav-item app-link" cdkDrag [cdkDragDisabled]="!settingsService.organizingSidebarSavedViews() || !canSaveSettings"
cdkDragPreviewContainer="parent" cdkDragPreviewClass="navItemDrag" (cdkDragStarted)="onDragStart($event)" cdkDragPreviewContainer="parent" cdkDragPreviewClass="navItemDrag" (cdkDragStarted)="onDragStart($event)"
(cdkDragEnded)="onDragEnd($event)"> (cdkDragEnded)="onDragEnd($event)">
<a class="nav-link" routerLink="view/{{view.id}}" <a class="nav-link" routerLink="view/{{view.id}}"
@@ -128,7 +128,7 @@
} }
</a> </a>
@if (settingsService.organizingSidebarSavedViews() && canSaveSettings) { @if (settingsService.organizingSidebarSavedViews() && canSaveSettings) {
<div class="position-absolute end-0 top-0 px-3 py-2" [class.me-n3]="slimSidebarEnabled" cdkDragHandle> <div class="position-absolute end-0 top-0 px-1 py-2" [class.me-n2]="slimSidebarEnabled" cdkDragHandle>
<i-bs name="grip-vertical"></i-bs> <i-bs name="grip-vertical"></i-bs>
</div> </div>
} }
@@ -332,7 +332,7 @@
</li> </li>
<li class="nav-item" [class.visually-hidden]="slimSidebarEnabled"> <li class="nav-item" [class.visually-hidden]="slimSidebarEnabled">
<div class="text-muted small d-flex align-items-center flex-wrap nav-label"> <div class="text-muted small d-flex align-items-center flex-wrap nav-label">
<div class="me-3"> <div class="me-2">
<a class="text-muted text-decoration-none" target="_blank" rel="noopener noreferrer" <a class="text-muted text-decoration-none" target="_blank" rel="noopener noreferrer"
href="https://github.com/paperless-ngx/paperless-ngx" ngbPopover="GitHub" i18n-ngbPopover href="https://github.com/paperless-ngx/paperless-ngx" ngbPopover="GitHub" i18n-ngbPopover
[disablePopover]="!slimSidebarPopoversEnabled" placement="end" container="body" [disablePopover]="!slimSidebarPopoversEnabled" placement="end" container="body"
@@ -341,7 +341,7 @@
</a> </a>
</div> </div>
@if (!settingsService.updateCheckingIsSet || appRemoteVersion()) { @if (!settingsService.updateCheckingIsSet || appRemoteVersion()) {
<div class="version-check"> <div class="version-check d-flex align-items-center">
<ng-template #updateAvailablePopContent> <ng-template #updateAvailablePopContent>
<span class="small">Paperless-ngx {{ appRemoteVersion().version }} <ng-container i18n>is <span class="small">Paperless-ngx {{ appRemoteVersion().version }} <ng-container i18n>is
available.</ng-container><br /><ng-container i18n>Click to view.</ng-container></span> available.</ng-container><br /><ng-container i18n>Click to view.</ng-container></span>
@@ -1,6 +1,6 @@
@if (useDropdown) { @if (useDropdown) {
<div class="btn-group w-100" role="group" ngbDropdown #dropdown="ngbDropdown" (openChange)="onOpenChange($event)" [popperOptions]="popperOptions"> <div class="btn-group w-100" role="group" ngbDropdown #dropdown="ngbDropdown" (openChange)="onOpenChange($event)" [popperOptions]="popperOptions">
<button class="btn btn-sm btn-outline-primary" id="dropdown_toggle" ngbDropdownToggle [disabled]="disabled" [aria-label]="title"> <button class="btn btn-sm" [ngClass]="!editing && isActive ? 'btn-primary' : 'btn-outline-primary'" id="dropdown_toggle" ngbDropdownToggle [disabled]="disabled" [aria-label]="title">
<i-bs name="{{icon}}"></i-bs><div class="d-none d-sm-inline ms-1">{{title}}</div> <i-bs name="{{icon}}"></i-bs><div class="d-none d-sm-inline ms-1">{{title}}</div>
@if (isActive) { @if (isActive) {
<pngx-clearable-badge [selected]="isActive" (cleared)="reset()"></pngx-clearable-badge> <pngx-clearable-badge [selected]="isActive" (cleared)="reset()"></pngx-clearable-badge>
@@ -1,5 +1,6 @@
import { import {
getLocaleNumberSymbol, getLocaleNumberSymbol,
NgClass,
NgTemplateOutlet, NgTemplateOutlet,
NumberSymbol, NumberSymbol,
} from '@angular/common' } from '@angular/common'
@@ -48,25 +49,26 @@ import { ClearableBadgeComponent } from '../clearable-badge/clearable-badge.comp
import { DocumentLinkComponent } from '../input/document-link/document-link.component' import { DocumentLinkComponent } from '../input/document-link/document-link.component'
export class CustomFieldQueriesModel { export class CustomFieldQueriesModel {
private _queries: CustomFieldQueryElement[] = [] private readonly _queries = signal<CustomFieldQueryElement[]>([])
private rootSubscriptions: Subscription[] = [] private rootSubscriptions: Subscription[] = []
public readonly changed = new Subject<CustomFieldQueriesModel>() public readonly changed = new Subject<CustomFieldQueriesModel>()
public get queries(): CustomFieldQueryElement[] { public get queries(): CustomFieldQueryElement[] {
return this._queries return this._queries()
} }
public set queries(value: CustomFieldQueryElement[]) { public set queries(value: CustomFieldQueryElement[]) {
this.teardownRootSubscriptions() this.teardownRootSubscriptions()
this._queries = value ?? [] const queries = value ?? []
for (const element of this._queries) { for (const element of queries) {
this.rootSubscriptions.push( this.rootSubscriptions.push(
element.changed.subscribe(() => { element.changed.subscribe(() => {
this.changed.next(this) this.changed.next(this)
}) })
) )
} }
this._queries.set(queries)
} }
public clear(fireEvent = true) { public clear(fireEvent = true) {
@@ -209,6 +211,7 @@ export class CustomFieldQueriesModel {
DocumentLinkComponent, DocumentLinkComponent,
ReactiveFormsModule, ReactiveFormsModule,
NgbDatepickerModule, NgbDatepickerModule,
NgClass,
NgTemplateOutlet, NgTemplateOutlet,
NgSelectModule, NgSelectModule,
NgxBootstrapIconsModule, NgxBootstrapIconsModule,
@@ -3,6 +3,7 @@
<div class="d-flex flex-wrap flex-row gap-2 w-100 mh-1" style="min-height: 1em;" <div class="d-flex flex-wrap flex-row gap-2 w-100 mh-1" style="min-height: 1em;"
cdkDropList #selectedList="cdkDropList" cdkDropList #selectedList="cdkDropList"
cdkDropListOrientation="mixed" cdkDropListOrientation="mixed"
[cdkDropListDisabled]="disabled"
(cdkDropListDropped)="drop($event)" (cdkDropListDropped)="drop($event)"
[cdkDropListConnectedTo]="[unselectedList]"> [cdkDropListConnectedTo]="[unselectedList]">
@for (item of selectedItems; track item.id) { @for (item of selectedItems; track item.id) {
@@ -17,6 +18,7 @@
<div class="d-flex flex-wrap flex-row gap-2 w-100" style="min-height: 1em;" <div class="d-flex flex-wrap flex-row gap-2 w-100" style="min-height: 1em;"
cdkDropList #unselectedList="cdkDropList" cdkDropList #unselectedList="cdkDropList"
cdkDropListOrientation="mixed" cdkDropListOrientation="mixed"
[cdkDropListDisabled]="disabled"
(cdkDropListDropped)="drop($event)" (cdkDropListDropped)="drop($event)"
[cdkDropListConnectedTo]="[selectedList]"> [cdkDropListConnectedTo]="[selectedList]">
@for (item of unselectedItems; track item.id) { @for (item of unselectedItems; track item.id) {
@@ -1,3 +1,11 @@
.badge { .badge {
cursor: move; cursor: move;
} }
.cdk-drop-list-disabled {
cursor: not-allowed !important;
* {
pointer-events: none !important;
}
}
@@ -98,4 +98,29 @@ describe('DragDropSelectComponent', () => {
{ id: '3', name: 'Item 3' }, { id: '3', name: 'Item 3' },
]) ])
}) })
it('should disable drag and drop when the control is disabled', () => {
component.items = [
{ id: '1', name: 'Item 1' },
{ id: '2', name: 'Item 2' },
]
component.writeValue(['1', '2'])
component.setDisabledState(true)
fixture.detectChanges()
expect(component.selectedList.disabled).toBe(true)
expect(component.unselectedList.disabled).toBe(true)
component.drop({
previousContainer: component.selectedList,
container: component.selectedList,
previousIndex: 0,
currentIndex: 1,
} as any)
expect(component.selectedItems).toEqual([
{ id: '1', name: 'Item 1' },
{ id: '2', name: 'Item 2' },
])
})
}) })
@@ -46,6 +46,8 @@ export class DragDropSelectComponent extends AbstractInputComponent<string[]> {
} }
public drop(event: CdkDragDrop<string[]>) { public drop(event: CdkDragDrop<string[]>) {
if (this.disabled) return
if ( if (
event.previousContainer === event.container && event.previousContainer === event.container &&
event.container === this.selectedList event.container === this.selectedList
@@ -14,7 +14,7 @@
</div> </div>
<div class="d-none d-sm-flex flex-fill me-3"> <div class="d-none d-sm-flex flex-fill me-3">
<div class="input-group input-group-sm"> <div class="input-group input-group-sm">
<span class="input-group-text border-0" i18n>Select:</span> <span class="input-group-text bg-transparent border-0" i18n>Select:</span>
</div> </div>
<div class="btn-group btn-group-sm flex-nowrap"> <div class="btn-group btn-group-sm flex-nowrap">
@if (list.hasSelection) { @if (list.hasSelection) {
@@ -116,7 +116,7 @@
</pngx-page-header> </pngx-page-header>
<div class="row sticky-top py-3 mt-n2 mt-md-n3 bg-body"> <div class="row sticky-top py-3 mt-n2 mt-md-n3 bg-body rounded shadow-sm">
<pngx-filter-editor [hidden]="isBulkEditing" [disabled]="isBulkEditing" [filterRules]="list.filterRules" (filterRulesChange)="onFilterRulesChange($event)" (resetFilterRules)="onFilterRulesReset($event)" [unmodifiedFilterRules]="unmodifiedFilterRules()" [selectionData]="list.selectionData" #filterEditor></pngx-filter-editor> <pngx-filter-editor [hidden]="isBulkEditing" [disabled]="isBulkEditing" [filterRules]="list.filterRules" (filterRulesChange)="onFilterRulesChange($event)" (resetFilterRules)="onFilterRulesReset($event)" [unmodifiedFilterRules]="unmodifiedFilterRules()" [selectionData]="list.selectionData" #filterEditor></pngx-filter-editor>
<pngx-bulk-editor [hidden]="!isBulkEditing" [disabled]="!isBulkEditing"></pngx-bulk-editor> <pngx-bulk-editor [hidden]="!isBulkEditing" [disabled]="!isBulkEditing"></pngx-bulk-editor>
</div> </div>
@@ -1034,6 +1034,49 @@ describe('FilterEditorComponent', () => {
).toEqual([42, CustomFieldQueryOperator.Exists, 'true']) ).toEqual([42, CustomFieldQueryOperator.Exists, 'true'])
}) })
it('should reflect ingested custom field query rules in the dropdown toggle', () => {
const dropdown = fixture.debugElement.query(
By.css('pngx-custom-fields-query-dropdown')
)
expect(
dropdown.nativeElement.querySelector('pngx-clearable-badge')
).toBeNull()
// switching to a view with a custom field query
component.filterRules = [
{
rule_type: FILTER_CUSTOM_FIELDS_QUERY,
value: '["OR",[[42,"exists","true"]]]',
},
]
fixture.detectChanges()
expect(
dropdown.nativeElement.querySelector('pngx-clearable-badge')
).not.toBeNull()
expect(
dropdown.nativeElement
.querySelector('#dropdown_toggle')
.classList.contains('btn-primary')
).toBeTruthy()
// and back to a view without one
component.filterRules = [
{
rule_type: FILTER_HAS_TAGS_ALL,
value: '19',
},
]
fixture.detectChanges()
expect(
dropdown.nativeElement.querySelector('pngx-clearable-badge')
).toBeNull()
expect(
dropdown.nativeElement
.querySelector('#dropdown_toggle')
.classList.contains('btn-primary')
).toBeFalsy()
})
it('should ingest filter rules for owner', () => { it('should ingest filter rules for owner', () => {
expect(component.permissionsSelectionModel.ownerFilter).toEqual( expect(component.permissionsSelectionModel.ownerFilter).toEqual(
OwnerFilterType.NONE OwnerFilterType.NONE
@@ -61,7 +61,7 @@
</div> </div>
<div class="col"> <div class="col">
<label class="form-label" for="display_mode_{{view.id}}" i18n>Display as</label> <label class="form-label" for="display_mode_{{view.id}}" i18n>Display as</label>
<select class="form-select" formControlName="display_mode"> <select class="form-select form-control" formControlName="display_mode">
<option [ngValue]="DisplayMode.TABLE" i18n>Table</option> <option [ngValue]="DisplayMode.TABLE" i18n>Table</option>
<option [ngValue]="DisplayMode.SMALL_CARDS" i18n>Small Cards</option> <option [ngValue]="DisplayMode.SMALL_CARDS" i18n>Small Cards</option>
<option [ngValue]="DisplayMode.LARGE_CARDS" i18n>Large Cards</option> <option [ngValue]="DisplayMode.LARGE_CARDS" i18n>Large Cards</option>
@@ -0,0 +1,13 @@
export const DEFAULT_APP_TITLE = 'Paperless-ngx'
export const environment = {
production: false,
apiBaseUrl: 'http://localhost:8001/api/',
apiVersion: '10',
appTitle: DEFAULT_APP_TITLE,
tag: 'e2e',
version: 'E2E',
webSocketHost: 'localhost:8001',
webSocketProtocol: 'ws:',
webSocketBaseUrl: '/ws/',
}
+1 -1
View File
@@ -8,7 +8,7 @@ export const environment = {
apiVersion: '10', // match src/paperless/settings.py apiVersion: '10', // match src/paperless/settings.py
appTitle: DEFAULT_APP_TITLE, appTitle: DEFAULT_APP_TITLE,
tag: 'prod', tag: 'prod',
version: '3.1.1', version: '3.1.2',
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/',
+5 -16
View File
@@ -47,6 +47,8 @@ $grid-breakpoints: (
); );
:root { :root {
--bs-border-radius: #{$border-radius};
@each $name, $value in $grid-breakpoints { @each $name, $value in $grid-breakpoints {
--bs-breakpoint-#{$name}: #{$value}; --bs-breakpoint-#{$name}: #{$value};
} }
@@ -78,19 +80,12 @@ body {
} }
.btn { .btn {
--bs-btn-border-radius: .425rem; --bs-border-radius-sm: #{$border-radius};
--bs-border-radius-sm: .425rem;
font-weight: 500; font-weight: 500;
} }
.form-control,
.form-select,
.input-group-text {
border-radius: .425rem;
}
.pagination, .input-group { .pagination, .input-group {
--bs-border-radius-sm: .425rem; --bs-border-radius-sm: #{$border-radius};
} }
@media(min-width: 768px) { @media(min-width: 768px) {
@@ -689,10 +684,6 @@ table.table {
--bs-toast-max-width: var(--pngx-toast-max-width); --bs-toast-max-width: var(--pngx-toast-max-width);
} }
.alert {
--bs-border-radius: .425rem;
}
.alert-primary { .alert-primary {
--bs-alert-color: var(--bs-primary); --bs-alert-color: var(--bs-primary);
--bs-alert-bg: var(--pngx-primary-faded); --bs-alert-bg: var(--pngx-primary-faded);
@@ -824,8 +815,6 @@ code {
--bs-accordion-bg: var(--bs-light); --bs-accordion-bg: var(--bs-light);
--bs-accordion-active-color: var(--bs-primary); --bs-accordion-active-color: var(--bs-primary);
--bs-accordion-active-bg: var(--pngx-bg-alt); --bs-accordion-active-bg: var(--pngx-bg-alt);
--bs-border-radius: .425rem;
--bs-accordion-inner-border-radius: calc(.425rem - 1px);
} }
.accordion-button::after { .accordion-button::after {
@@ -849,7 +838,7 @@ code {
} }
/* Animate items as they're being sorted. */ /* Animate items as they're being sorted. */
.cdk-drop-list-dragging .cdk-drag { .cdk-drop-list-dragging .cdk-drag:not(.cdk-drag-preview) {
transition: transform 250ms cubic-bezier(0, 0, 0.2, 1); transition: transform 250ms cubic-bezier(0, 0, 0.2, 1);
} }
+3
View File
@@ -103,6 +103,8 @@ $form-check-radio-checked-bg-image-dark: url("data:image/svg+xml,%3csvg xmlns='h
--bs-danger-rgb: 183, 22, 49; --bs-danger-rgb: 183, 22, 49;
--bs-body-bg: #161618; --bs-body-bg: #161618;
--bs-body-bg-rgb: 22, 22, 24; --bs-body-bg-rgb: 22, 22, 24;
--bs-secondary-bg: var(--pngx-bg-disabled);
--bs-secondary-bg-rgb: 36, 37, 41;
--bs-light: #1c1c1f; --bs-light: #1c1c1f;
--bs-light-rgb: 28, 28, 31; --bs-light-rgb: 28, 28, 31;
--bs-info: var(--pngx-bg-alt); --bs-info: var(--pngx-bg-alt);
@@ -111,6 +113,7 @@ $form-check-radio-checked-bg-image-dark: url("data:image/svg+xml,%3csvg xmlns='h
--bs-tertiary-bg: var(--pngx-bg-darker); --bs-tertiary-bg: var(--pngx-bg-darker);
--bs-dark-border-subtle: var(--pngx-bg-darker); --bs-dark-border-subtle: var(--pngx-bg-darker);
--bs-border-color-translucent: rgba(0, 0, 0, .175); // override bs --bs-border-color-translucent: rgba(0, 0, 0, .175); // override bs
--bs-box-shadow-sm: 0 0.125rem 0.25rem rgba(0, 0, 0, 0.15); // slightly darker than bs default
.text-dark, .text-light { .text-dark, .text-light {
color: var(--bs-body-color) !important; color: var(--bs-body-color) !important;
+8 -3
View File
@@ -16,6 +16,9 @@ from django.core.cache import cache
from django.core.cache import caches from django.core.cache import caches
from documents.models import Document from documents.models import Document
from paperless.signed_pickle import SignedPickleError
from paperless.signed_pickle import signed_pickle_dumps
from paperless.signed_pickle import signed_pickle_loads
if TYPE_CHECKING: if TYPE_CHECKING:
from django.core.cache.backends.base import BaseCache from django.core.cache.backends.base import BaseCache
@@ -118,9 +121,11 @@ class StoredLRUCache(LRUCache):
serialized_data = self._backend.get(self._backend_key) serialized_data = self._backend.get(self._backend_key)
try: try:
self._data = ( self._data = (
pickle.loads(serialized_data) if serialized_data else OrderedDict() signed_pickle_loads(serialized_data)
if serialized_data
else OrderedDict()
) )
except pickle.PickleError: except (SignedPickleError, pickle.PickleError):
logger.warning( logger.warning(
"Cache exists in backend but could not be read (possibly invalid format)", "Cache exists in backend but could not be read (possibly invalid format)",
) )
@@ -132,7 +137,7 @@ class StoredLRUCache(LRUCache):
""" """
self._backend.set( self._backend.set(
self._backend_key, self._backend_key,
pickle.dumps(self._data), signed_pickle_dumps(self._data),
self.backend_ttl, self.backend_ttl,
) )
+12 -2
View File
@@ -28,6 +28,9 @@ from documents.caching import CLASSIFIER_VERSION_KEY
from documents.caching import StoredLRUCache from documents.caching import StoredLRUCache
from documents.models import Document from documents.models import Document
from documents.models import MatchingModel from documents.models import MatchingModel
from paperless.signed_pickle import SignedPickleError
from paperless.signed_pickle import signed_pickle_dumps
from paperless.signed_pickle import signed_pickle_loads
logger = logging.getLogger("paperless.classifier") logger = logging.getLogger("paperless.classifier")
@@ -527,10 +530,17 @@ class DocumentClassifier:
serialized_result = read_cache.get(key) serialized_result = read_cache.get(key)
if serialized_result is None: if serialized_result is None:
result = self.data_vectorizer.transform([self.preprocess_content(content)]) result = self.data_vectorizer.transform([self.preprocess_content(content)])
read_cache.set(key, pickle.dumps(result), CACHE_5_MINUTES) read_cache.set(key, signed_pickle_dumps(result), CACHE_5_MINUTES)
else:
try:
result = signed_pickle_loads(serialized_result)
except SignedPickleError:
result = self.data_vectorizer.transform(
[self.preprocess_content(content)],
)
read_cache.set(key, signed_pickle_dumps(result), CACHE_5_MINUTES)
else: else:
read_cache.touch(key, CACHE_5_MINUTES) read_cache.touch(key, CACHE_5_MINUTES)
result = pickle.loads(serialized_result)
return result return result
def predict_correspondent(self, content: str) -> int | None: def predict_correspondent(self, content: str) -> int | None:
+9
View File
@@ -25,6 +25,7 @@ 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_filename
from documents.file_handling import generate_unique_filename from documents.file_handling import generate_unique_filename
from documents.file_handling import validate_path_in_root
from documents.loggers import LoggingMixin from documents.loggers import LoggingMixin
from documents.models import Correspondent from documents.models import Correspondent
from documents.models import CustomField from documents.models import CustomField
@@ -695,6 +696,10 @@ class ConsumerPlugin(
use_format=False, use_format=False,
) )
document.filename = generated_filename document.filename = generated_filename
validate_path_in_root(
document.source_path,
settings.ORIGINALS_DIR,
)
create_source_path_directory(document.source_path) create_source_path_directory(document.source_path)
self._write( self._write(
@@ -727,6 +732,10 @@ class ConsumerPlugin(
use_format=False, use_format=False,
) )
document.archive_filename = generated_archive_filename document.archive_filename = generated_archive_filename
validate_path_in_root(
document.archive_path,
settings.ARCHIVE_DIR,
)
create_source_path_directory(document.archive_path) create_source_path_directory(document.archive_path)
self._write( self._write(
archive_path, archive_path,
+29
View File
@@ -1,12 +1,33 @@
import logging
import os import os
from pathlib import Path from pathlib import Path
from django.conf import settings from django.conf import settings
from documents.models import Document from documents.models import Document
from documents.templating.filepath import is_safe_relative_path
from documents.templating.filepath import validate_filepath_template_and_render from documents.templating.filepath import validate_filepath_template_and_render
from documents.templating.utils import convert_format_str_to_template_format from documents.templating.utils import convert_format_str_to_template_format
logger = logging.getLogger("paperless.filehandling")
class UnsafeFilePathError(Exception):
"""
Raised when a path generated for a document would land outside of its root.
"""
def validate_path_in_root(path: Path, root: Path) -> None:
"""
Ensures the given absolute path is contained within root, the
equivalent guard for the later move.
"""
if not path.resolve().is_relative_to(root.resolve()):
msg = f"Refusing to write file outside of root {root}: {path}."
logger.warning(msg)
raise UnsafeFilePathError(msg)
def create_source_path_directory(source_path: Path) -> None: def create_source_path_directory(source_path: Path) -> None:
source_path.parent.mkdir(parents=True, exist_ok=True) source_path.parent.mkdir(parents=True, exist_ok=True)
@@ -121,6 +142,14 @@ def format_filename(document: Document, template_str: str) -> str | None:
"none", "none",
) # backward compatibility ) # backward compatibility
# Validate again after remove none
if not is_safe_relative_path(rendered_filename):
logger.warning(
"Filename became unsafe after placeholder removal, "
"falling back to default naming",
)
return None
return rendered_filename return rendered_filename
+5 -1
View File
@@ -462,7 +462,11 @@ class Document(SoftDeleteModel, ModelWithOwner): # type: ignore[django-manager-
""" """
Returns a sanitized filename for the document, not including any paths. Returns a sanitized filename for the document, not including any paths.
""" """
result = str(self) # Root owns metadata for all versions
context_document = (
self.root_document if self.root_document_id is not None else self
)
result = str(context_document)
if counter: if counter:
result += f"_{counter:02}" result += f"_{counter:02}"
+3
View File
@@ -81,6 +81,7 @@ 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 has_perms_owner_aware from documents.permissions import has_perms_owner_aware
from documents.permissions import permitted_document_ids from documents.permissions import permitted_document_ids
from documents.permissions import restrict_queryset_to_visible
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
@@ -661,6 +662,8 @@ class TagSerializer(MatchingModelSerializer, OwnedObjectSerializer):
.select_related("owner") .select_related("owner")
.annotate(document_count=Count("documents", filter=filter_q)) .annotate(document_count=Count("documents", filter=filter_q))
) )
user = getattr(request, "user", None) if request else self.user
children = restrict_queryset_to_visible(children, user, "view_tag")
view = self.context.get("view") view = self.context.get("view")
ordering = ( ordering = (
+2 -2
View File
@@ -340,7 +340,7 @@ def get_custom_fields_context(
return field_data return field_data
def _is_safe_relative_path(value: str) -> bool: def is_safe_relative_path(value: str) -> bool:
if value == "": if value == "":
return True return True
@@ -398,7 +398,7 @@ def validate_filepath_template_and_render(
) )
rendered_template = template.render(context) rendered_template = template.render(context)
if not _is_safe_relative_path(rendered_template): if not is_safe_relative_path(rendered_template):
logger.warning( logger.warning(
"Template rendered an unsafe path (absolute or containing traversal).", "Template rendered an unsafe path (absolute or containing traversal).",
) )
+27
View File
@@ -102,6 +102,7 @@ class TestApiObjects(DirectoriesMixin, APITestCase):
- API is called - API is called
THEN: THEN:
- Last correspondence date is returned only if requested for list, and for detail - Last correspondence date is returned only if requested for list, and for detail
- The date is scoped to documents the requesting user may view
""" """
Document.objects.create( Document.objects.create(
@@ -145,6 +146,32 @@ class TestApiObjects(DirectoriesMixin, APITestCase):
response.data["last_correspondence"], response.data["last_correspondence"],
) )
# A newer document owned by another user must not leak through the
# aggregate for a non-superuser who cannot view it
other = User.objects.create_user(username="other")
Document.objects.create(
mime_type="application/pdf",
correspondent=self.c1,
created=datetime.date(2023, 6, 1),
checksum="hidden",
owner=other,
)
user = User.objects.create_user(username="regular")
user.user_permissions.add(
Permission.objects.get(codename="view_correspondent"),
)
self.client.force_authenticate(user=user)
response = self.client.get("/api/correspondents/?last_correspondence=true")
self.assertEqual(response.status_code, status.HTTP_200_OK)
result = next(r for r in response.data["results"] if r["id"] == self.c1.id)
self.assertIn("2022-01-02", result["last_correspondence"])
response = self.client.get(f"/api/correspondents/{self.c1.id}/")
self.assertEqual(response.status_code, status.HTTP_200_OK)
self.assertIn("2022-01-02", response.data["last_correspondence"])
def test_paginated_objects_include_all_only_for_legacy_version(self) -> None: def test_paginated_objects_include_all_only_for_legacy_version(self) -> None:
response_v10 = self.client.get("/api/correspondents/") response_v10 = self.client.get("/api/correspondents/")
self.assertEqual(response_v10.status_code, status.HTTP_200_OK) self.assertEqual(response_v10.status_code, status.HTTP_200_OK)
+2 -1
View File
@@ -7,9 +7,10 @@ from rest_framework import status
from rest_framework.test import APITestCase from rest_framework.test import APITestCase
from documents.models import Document from documents.models import Document
from documents.tests.utils import DirectoriesMixin
class TestTrashAPI(APITestCase): class TestTrashAPI(DirectoriesMixin, APITestCase):
def setUp(self) -> None: def setUp(self) -> None:
super().setUp() super().setUp()
+16 -3
View File
@@ -1,6 +1,7 @@
import pickle
from documents.caching import StoredLRUCache from documents.caching import StoredLRUCache
from paperless.signed_pickle import HMAC_SIZE
from paperless.signed_pickle import signed_pickle_dumps
from paperless.signed_pickle import signed_pickle_loads
def test_lru_cache_entries() -> None: def test_lru_cache_entries() -> None:
@@ -42,4 +43,16 @@ def test_stored_lru_cache_key_ttl(mocker) -> None:
key, data, timeout = mock_backend.set.call_args[0] key, data, timeout = mock_backend.set.call_args[0]
assert key == "test_key" assert key == "test_key"
assert timeout == 321 assert timeout == 321
assert pickle.loads(data) == {"x": "X", "y": "Y"} assert signed_pickle_loads(data) == {"x": "X", "y": "Y"}
def test_stored_lru_cache_rejects_tampered_data(mocker) -> None:
serialized_data = bytearray(signed_pickle_dumps({"x": "X"}))
serialized_data[HMAC_SIZE] ^= 0xFF
mock_backend = mocker.Mock()
mock_backend.get.return_value = bytes(serialized_data)
cache = StoredLRUCache("test_key", backend=mock_backend)
cache.load()
assert cache.get("x") is None
+23
View File
@@ -19,6 +19,8 @@ from documents.models import MatchingModel
from documents.models import StoragePath from documents.models import StoragePath
from documents.models import Tag from documents.models import Tag
from documents.tests.utils import DirectoriesMixin from documents.tests.utils import DirectoriesMixin
from paperless.signed_pickle import HMAC_SIZE
from paperless.signed_pickle import signed_pickle_dumps
def dummy_preprocess(content: str, **kwargs): def dummy_preprocess(content: str, **kwargs):
@@ -265,6 +267,27 @@ class TestClassifier(DirectoriesMixin, TestCase):
self.assertEqual(mock_preprocess_content.call_count, 2) self.assertEqual(mock_preprocess_content.call_count, 2)
self.assertEqual(mock_transform.call_count, 2) self.assertEqual(mock_transform.call_count, 2)
def test_vectorize_recomputes_tampered_cache_entry(self) -> None:
cached = bytearray(signed_pickle_dumps(["cached vector"]))
cached[HMAC_SIZE] ^= 0xFF
self.classifier.data_vectorizer = mock.Mock()
self.classifier.data_vectorizer.transform.return_value = ["fresh vector"]
with (
mock.patch(
"documents.classifier.read_cache.get",
return_value=bytes(cached),
),
mock.patch("documents.classifier.read_cache.set") as cache_set,
mock.patch("documents.classifier.read_cache.touch") as cache_touch,
):
result = self.classifier._vectorize("content")
self.assertEqual(result, ["fresh vector"])
self.classifier.data_vectorizer.transform.assert_called_once()
cache_set.assert_called_once()
cache_touch.assert_not_called()
def test_no_retrain_if_no_change(self) -> None: def test_no_retrain_if_no_change(self) -> None:
""" """
GIVEN: GIVEN:
+23
View File
@@ -1179,6 +1179,29 @@ class TestConsumer(
produce_archive=True, produce_archive=True,
) )
@mock.patch("documents.consumer.generate_unique_filename")
def test_consume_refuses_to_write_outside_originals_dir(
self,
m: mock.Mock,
) -> None:
"""
GIVEN:
- Filename generation produces a path outside of the originals directory
WHEN:
- The document is consumed
THEN:
- The consumption fails and no file is written outside of the root
"""
m.return_value = Path("../../pwned.pdf")
escaped = (settings.ORIGINALS_DIR / ".." / ".." / "pwned.pdf").resolve()
with self.get_consumer(self.get_test_file()) as consumer:
with self.assertRaises(ConsumerError):
consumer.run()
self.assertIsNotFile(escaped)
self.assertEqual(Document.objects.count(), 0)
@mock.patch("documents.consumer.magic.from_file", fake_magic_from_file) @mock.patch("documents.consumer.magic.from_file", fake_magic_from_file)
class TestConsumerCreatedDate(DirectoriesMixin, GetConsumerMixin, TestCase): class TestConsumerCreatedDate(DirectoriesMixin, GetConsumerMixin, TestCase):
@@ -156,6 +156,40 @@ class TestDocument(TestCase):
) )
self.assertEqual(doc.get_public_filename(), "2020-12-25 test") self.assertEqual(doc.get_public_filename(), "2020-12-25 test")
def test_version_file_name_uses_root_document_metadata(self) -> None:
root_correspondent = Correspondent.objects.create(name="Root correspondent")
version_correspondent = Correspondent.objects.create(
name="Version correspondent",
)
root = Document.objects.create(
mime_type="application/pdf",
title="Root title",
created=date(2020, 12, 25),
correspondent=root_correspondent,
)
version = Document.objects.create(
mime_type="application/pdf",
title="Version title",
created=date(1990, 1, 1),
correspondent=version_correspondent,
root_document=root,
version_index=1,
)
self.assertEqual(
version.get_public_filename(),
"2020-12-25 Root correspondent Root title.pdf",
)
root.title = "Updated root title"
root.save(update_fields=("title",))
version.refresh_from_db()
self.assertEqual(
version.get_public_filename(),
"2020-12-25 Root correspondent Updated root title.pdf",
)
def test_suggestion_content_uses_latest_version_content_for_root_documents( def test_suggestion_content_uses_latest_version_content_for_root_documents(
self, self,
) -> None: ) -> None:
+97
View File
@@ -16,10 +16,12 @@ from django.test import override_settings
from django.test.utils import CaptureQueriesContext from django.test.utils import CaptureQueriesContext
from django.utils import timezone from django.utils import timezone
from documents.file_handling import UnsafeFilePathError
from documents.file_handling import create_source_path_directory from documents.file_handling import create_source_path_directory
from documents.file_handling import delete_empty_directories from documents.file_handling import delete_empty_directories
from documents.file_handling import generate_filename from documents.file_handling import generate_filename
from documents.file_handling import generate_unique_filename from documents.file_handling import generate_unique_filename
from documents.file_handling import validate_path_in_root
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 CustomFieldInstance
@@ -1511,6 +1513,101 @@ class TestFilenameGeneration(DirectoriesMixin, TestCase):
document.filename = generate_filename(document) document.filename = generate_filename(document)
self.assertEqual(document.filename, Path("XX/doc1.pdf")) self.assertEqual(document.filename, Path("XX/doc1.pdf"))
@override_settings(FILENAME_FORMAT_REMOVE_NONE=True)
def test_remove_none_cannot_create_traversal(self) -> None:
"""
GIVEN:
- A storage path whose components are safe when validated, but become
".." once the -none- placeholder is stripped out
- FILENAME_FORMAT_REMOVE_NONE is True
WHEN:
- the filename is generated for the document
THEN:
- The unsafe filename is rejected and the default naming is used
"""
sp = StoragePath.objects.create(
name="sp1",
path=".-none-./.-none-./tmp/pwned",
)
document = Document.objects.create(
title="doc1",
mime_type="application/pdf",
storage_path=sp,
)
filename = generate_filename(document)
self.assertNotIn("..", filename.parts)
self.assertEqual(filename, Path(f"{document.pk:07}.pdf"))
@override_settings(FILENAME_FORMAT_REMOVE_NONE=True)
def test_remove_none_still_removes_placeholder(self) -> None:
"""
GIVEN:
- A storage path with a placeholder for a value the document does not have
- FILENAME_FORMAT_REMOVE_NONE is True
WHEN:
- the filename is generated for the document
THEN:
- The placeholder is still removed as before
"""
sp = StoragePath.objects.create(
name="sp1",
path="{{ correspondent }}/{{ title }}",
)
document = Document.objects.create(
title="doc1",
mime_type="application/pdf",
storage_path=sp,
)
self.assertEqual(generate_filename(document), Path("doc1.pdf"))
@override_settings(
FILENAME_FORMAT="{{ correspondent }}/{{ title }}/{{ doc_pk }}",
FILENAME_FORMAT_REMOVE_NONE=True,
)
def test_remove_none_cannot_create_traversal_from_metadata(self) -> None:
"""
GIVEN:
- A global filename format with directory components
- A document whose title becomes ".." once -none- is stripped out
- FILENAME_FORMAT_REMOVE_NONE is True
WHEN:
- the filename is generated for the document
THEN:
- The unsafe filename is rejected and the default naming is used
"""
document = Document.objects.create(
title=".-none-.",
mime_type="application/pdf",
)
filename = generate_filename(document)
self.assertNotIn("..", filename.parts)
self.assertEqual(filename, Path(f"{document.pk:07}.pdf"))
def test_validate_path_in_root(self) -> None:
"""
GIVEN:
- A path inside of the root and a path outside of it
WHEN:
- The path is validated against the root
THEN:
- Only the path outside of the root is rejected
"""
validate_path_in_root(
settings.ORIGINALS_DIR / "0000001.pdf",
settings.ORIGINALS_DIR,
)
with self.assertRaises(UnsafeFilePathError):
validate_path_in_root(
(settings.ORIGINALS_DIR / ".." / ".." / "pwned.pdf"),
settings.ORIGINALS_DIR,
)
def test_complex_template_strings(self) -> None: def test_complex_template_strings(self) -> None:
""" """
GIVEN: GIVEN:
@@ -409,6 +409,7 @@ class TestBulkDownloadPermissionChecksRootDocument:
@pytest.mark.django_db @pytest.mark.django_db
@pytest.mark.usefixtures("_search_index")
class TestTrashRestorePermissionBoundary: class TestTrashRestorePermissionBoundary:
def test_restore_rejects_document_without_delete_permission( def test_restore_rejects_document_without_delete_permission(
self, self,
@@ -192,6 +192,50 @@ class ShareLinkBundleAPITests(DirectoriesMixin, APITestCase):
self.assertEqual(response.status_code, status.HTTP_302_FOUND) self.assertEqual(response.status_code, status.HTTP_302_FOUND)
self.assertIn("sharelink_notfound=1", response["Location"]) self.assertIn("sharelink_notfound=1", response["Location"])
def test_share_link_missing_file_redirects(self) -> None:
"""
GIVEN:
- A share link whose document file is missing from disk
WHEN:
- The public share link is requested anonymously
THEN:
- The user is redirected to login instead of a 500 error
"""
doc = DocumentFactory.create(filename="missing-original.pdf")
share_link = ShareLink.objects.create(
slug="missingfilelink",
document=doc,
file_version=ShareLink.FileVersion.ORIGINAL,
)
self.client.logout()
response = self.client.get(f"/share/{share_link.slug}/")
self.assertEqual(response.status_code, status.HTTP_302_FOUND)
self.assertIn("sharelink_notfound=1", response["Location"])
def test_download_ready_bundle_missing_file_returns_503(self) -> None:
"""
GIVEN:
- A READY bundle whose zip file is missing from disk
WHEN:
- The public share link is requested anonymously
THEN:
- A 503 is returned instead of a 500 error
"""
bundle = ShareLinkBundle.objects.create(
slug="missingbundlefile",
file_version=ShareLink.FileVersion.ARCHIVE,
status=ShareLinkBundle.Status.READY,
file_path="bundles/gone.zip",
)
bundle.documents.set([self.document])
self.client.logout()
response = self.client.get(f"/share/{bundle.slug}/")
self.assertEqual(response.status_code, status.HTTP_503_SERVICE_UNAVAILABLE)
class ShareLinkBundleTaskTests(DirectoriesMixin, APITestCase): class ShareLinkBundleTaskTests(DirectoriesMixin, APITestCase):
def setUp(self) -> None: def setUp(self) -> None:
+31
View File
@@ -1,5 +1,6 @@
from unittest import mock from unittest import mock
from django.contrib.auth.models import Permission
from django.contrib.auth.models import User from django.contrib.auth.models import User
from rest_framework.test import APITestCase from rest_framework.test import APITestCase
@@ -14,6 +15,36 @@ from documents.signals.handlers import run_workflows
from documents.tests.utils import DirectoriesMixin from documents.tests.utils import DirectoriesMixin
class TestTagHierarchyPermissions(APITestCase):
def test_children_only_include_visible_tags(self) -> None:
owner = User.objects.create_user(username="owner")
requester = User.objects.create_user(username="requester")
requester.user_permissions.add(
Permission.objects.get(codename="view_tag"),
)
parent = Tag.objects.create(name="Visible parent", owner=requester)
hidden_child = Tag.objects.create(
name="Hidden child",
owner=owner,
tn_parent=parent,
)
self.client.force_authenticate(user=requester)
response = self.client.get("/api/tags/")
assert response.status_code == 200
parent_result = next(
tag for tag in response.data["results"] if tag["id"] == parent.pk
)
assert parent_result["children"] == []
assert hidden_child.pk not in response.data.get("all", [])
response = self.client.get(f"/api/tags/{parent.pk}/")
assert response.status_code == 200
assert response.data["children"] == []
class TestTagHierarchy(DirectoriesMixin, APITestCase): class TestTagHierarchy(DirectoriesMixin, APITestCase):
def setUp(self) -> None: def setUp(self) -> None:
super().setUp() super().setUp()
+23 -4
View File
@@ -577,13 +577,19 @@ class CorrespondentViewSet(
def list(self, request, *args, **kwargs): def list(self, request, *args, **kwargs):
if request.query_params.get("last_correspondence", None): if request.query_params.get("last_correspondence", None):
self.queryset = self.queryset.annotate( self.queryset = self.queryset.annotate(
last_correspondence=Max("documents__created"), last_correspondence=Max(
"documents__created",
filter=self.get_document_count_filter(),
),
) )
return super().list(request, *args, **kwargs) return super().list(request, *args, **kwargs)
def retrieve(self, request, *args, **kwargs): def retrieve(self, request, *args, **kwargs):
self.queryset = self.queryset.annotate( self.queryset = self.queryset.annotate(
last_correspondence=Max("documents__created"), last_correspondence=Max(
"documents__created",
filter=self.get_document_count_filter(),
),
) )
return super().retrieve(request, *args, **kwargs) return super().retrieve(request, *args, **kwargs)
@@ -635,7 +641,9 @@ class TagViewSet(PermissionsAwareDocumentCountMixin, ModelViewSet[Tag]):
annotate_document_count_for_related_queryset( annotate_document_count_for_related_queryset(
Tag.objects.filter( Tag.objects.filter(
pk__in=descendant_pks | {t.pk for t in all_tags}, pk__in=descendant_pks | {t.pk for t in all_tags},
).select_related("owner"), )
.filter(pk__in=permitted_object_ids(user, Tag, "view_tag"))
.select_related("owner"),
through_model=self.document_count_through, through_model=self.document_count_through,
related_object_field=self._get_document_count_source_field(), related_object_field=self._get_document_count_source_field(),
user=user, user=user,
@@ -4571,6 +4579,10 @@ class ShareLinkViewSet(
class ShareLinkBundleViewSet(PassUserMixin, ModelViewSet[ShareLinkBundle]): class ShareLinkBundleViewSet(PassUserMixin, ModelViewSet[ShareLinkBundle]):
model = ShareLinkBundle model = ShareLinkBundle
# Bundles are immutable once created; rebuild via the dedicated action
# rather than PUT/PATCH.
http_method_names = ["get", "post", "delete", "head", "options"]
queryset = ShareLinkBundle.objects.all() queryset = ShareLinkBundle.objects.all()
serializer_class = ShareLinkBundleSerializer serializer_class = ShareLinkBundleSerializer
@@ -4705,12 +4717,15 @@ class SharedLinkView(View):
and share_link.expiration < timezone.now() and share_link.expiration < timezone.now()
): ):
return HttpResponseRedirect("/accounts/login/?sharelink_expired=1") return HttpResponseRedirect("/accounts/login/?sharelink_expired=1")
try:
return serve_file( return serve_file(
doc=share_link.document, doc=share_link.document,
use_archive=share_link.file_version == ShareLink.FileVersion.ARCHIVE use_archive=share_link.file_version == ShareLink.FileVersion.ARCHIVE
and share_link.document.has_archive_version, and share_link.document.has_archive_version,
disposition="inline", disposition="inline",
) )
except FileNotFoundError:
return HttpResponseRedirect("/accounts/login/?sharelink_notfound=1")
bundle = ShareLinkBundle.objects.filter(slug=slug).first() bundle = ShareLinkBundle.objects.filter(slug=slug).first()
if bundle is None: if bundle is None:
@@ -4732,7 +4747,11 @@ class SharedLinkView(View):
file_path = bundle.absolute_file_path file_path = bundle.absolute_file_path
if bundle.status == ShareLinkBundle.Status.FAILED or file_path is None: if (
bundle.status == ShareLinkBundle.Status.FAILED
or file_path is None
or not file_path.exists()
):
return HttpResponse( return HttpResponse(
_( _(
"The share link bundle is unavailable.", "The share link bundle is unavailable.",
+21 -21
View File
@@ -2,8 +2,8 @@ msgid ""
msgstr "" msgstr ""
"Project-Id-Version: paperless-ngx\n" "Project-Id-Version: paperless-ngx\n"
"Report-Msgid-Bugs-To: \n" "Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2026-08-29 20:02+0000\n" "POT-Creation-Date: 2026-09-01 14:47+0000\n"
"PO-Revision-Date: 2026-08-29 20:04\n" "PO-Revision-Date: 2026-09-01 14:48\n"
"Last-Translator: \n" "Last-Translator: \n"
"Language-Team: Afrikaans\n" "Language-Team: Afrikaans\n"
"Language: af_ZA\n" "Language: af_ZA\n"
@@ -1610,48 +1610,48 @@ msgstr ""
msgid "workflow runs" msgid "workflow runs"
msgstr "" msgstr ""
#: documents/serialisers.py:523 documents/serialisers.py:875 #: documents/serialisers.py:524 documents/serialisers.py:878
#: documents/serialisers.py:2827 documents/views.py:313 documents/views.py:2611 #: documents/serialisers.py:2830 documents/views.py:313 documents/views.py:2613
#: paperless_mail/serialisers.py:156 #: paperless_mail/serialisers.py:156
msgid "Insufficient permissions." msgid "Insufficient permissions."
msgstr "" msgstr ""
#: documents/serialisers.py:711 #: documents/serialisers.py:714
msgid "Invalid color." msgid "Invalid color."
msgstr "Ongeldige kleur." msgstr "Ongeldige kleur."
#: documents/serialisers.py:2304 #: documents/serialisers.py:2307
#, python-format #, python-format
msgid "File type %(type)s not supported" msgid "File type %(type)s not supported"
msgstr "Lêertipe %(type)s word nie ondersteun nie" msgstr "Lêertipe %(type)s word nie ondersteun nie"
#: documents/serialisers.py:2348 #: documents/serialisers.py:2351
#, python-format #, python-format
msgid "Custom field id must be an integer: %(id)s" msgid "Custom field id must be an integer: %(id)s"
msgstr "" msgstr ""
#: documents/serialisers.py:2355 #: documents/serialisers.py:2358
#, python-format #, python-format
msgid "Custom field with id %(id)s does not exist" msgid "Custom field with id %(id)s does not exist"
msgstr "" msgstr ""
#: documents/serialisers.py:2372 documents/serialisers.py:2382 #: documents/serialisers.py:2375 documents/serialisers.py:2385
msgid "Custom fields must be a list of integers or an object mapping ids to values." msgid "Custom fields must be a list of integers or an object mapping ids to values."
msgstr "" msgstr ""
#: documents/serialisers.py:2377 #: documents/serialisers.py:2380
msgid "Some custom fields don't exist or were specified twice." msgid "Some custom fields don't exist or were specified twice."
msgstr "" msgstr ""
#: documents/serialisers.py:2524 #: documents/serialisers.py:2527
msgid "Invalid variable detected." msgid "Invalid variable detected."
msgstr "Ongeldige veranderlike bespeur." msgstr "Ongeldige veranderlike bespeur."
#: documents/serialisers.py:2883 #: documents/serialisers.py:2886
msgid "Duplicate document identifiers are not allowed." msgid "Duplicate document identifiers are not allowed."
msgstr "" msgstr ""
#: documents/serialisers.py:2913 documents/views.py:4608 #: documents/serialisers.py:2916 documents/views.py:4610
#, python-format #, python-format
msgid "Documents not found: %(ids)s" msgid "Documents not found: %(ids)s"
msgstr "" msgstr ""
@@ -1906,36 +1906,36 @@ msgstr ""
msgid "Unable to parse URI {value}" msgid "Unable to parse URI {value}"
msgstr "" msgstr ""
#: documents/views.py:306 documents/views.py:2608 #: documents/views.py:306 documents/views.py:2610
msgid "Invalid more_like_id" msgid "Invalid more_like_id"
msgstr "" msgstr ""
#: documents/views.py:1582 #: documents/views.py:1584
msgid "Invalid AI configuration." msgid "Invalid AI configuration."
msgstr "" msgstr ""
#: documents/views.py:1593 #: documents/views.py:1595
msgid "AI backend request timed out." msgid "AI backend request timed out."
msgstr "" msgstr ""
#: documents/views.py:2433 documents/views.py:2754 #: documents/views.py:2435 documents/views.py:2756
msgid "Specify only one of text, title_search, query, or more_like_id." msgid "Specify only one of text, title_search, query, or more_like_id."
msgstr "" msgstr ""
#: documents/views.py:4621 #: documents/views.py:4623
#, python-format #, python-format
msgid "Insufficient permissions to share document %(id)s." msgid "Insufficient permissions to share document %(id)s."
msgstr "" msgstr ""
#: documents/views.py:4667 #: documents/views.py:4669
msgid "Bundle is already being processed." msgid "Bundle is already being processed."
msgstr "" msgstr ""
#: documents/views.py:4728 #: documents/views.py:4730
msgid "The share link bundle is still being prepared. Please try again later." msgid "The share link bundle is still being prepared. Please try again later."
msgstr "" msgstr ""
#: documents/views.py:4738 #: documents/views.py:4740
msgid "The share link bundle is unavailable." msgid "The share link bundle is unavailable."
msgstr "" msgstr ""
+21 -21
View File
@@ -2,8 +2,8 @@ msgid ""
msgstr "" msgstr ""
"Project-Id-Version: paperless-ngx\n" "Project-Id-Version: paperless-ngx\n"
"Report-Msgid-Bugs-To: \n" "Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2026-08-29 20:02+0000\n" "POT-Creation-Date: 2026-09-01 14:47+0000\n"
"PO-Revision-Date: 2026-08-29 20:04\n" "PO-Revision-Date: 2026-09-01 14:48\n"
"Last-Translator: \n" "Last-Translator: \n"
"Language-Team: Amharic\n" "Language-Team: Amharic\n"
"Language: am_ET\n" "Language: am_ET\n"
@@ -1610,48 +1610,48 @@ msgstr ""
msgid "workflow runs" msgid "workflow runs"
msgstr "" msgstr ""
#: documents/serialisers.py:523 documents/serialisers.py:875 #: documents/serialisers.py:524 documents/serialisers.py:878
#: documents/serialisers.py:2827 documents/views.py:313 documents/views.py:2611 #: documents/serialisers.py:2830 documents/views.py:313 documents/views.py:2613
#: paperless_mail/serialisers.py:156 #: paperless_mail/serialisers.py:156
msgid "Insufficient permissions." msgid "Insufficient permissions."
msgstr "" msgstr ""
#: documents/serialisers.py:711 #: documents/serialisers.py:714
msgid "Invalid color." msgid "Invalid color."
msgstr "" msgstr ""
#: documents/serialisers.py:2304 #: documents/serialisers.py:2307
#, python-format #, python-format
msgid "File type %(type)s not supported" msgid "File type %(type)s not supported"
msgstr "" msgstr ""
#: documents/serialisers.py:2348 #: documents/serialisers.py:2351
#, python-format #, python-format
msgid "Custom field id must be an integer: %(id)s" msgid "Custom field id must be an integer: %(id)s"
msgstr "" msgstr ""
#: documents/serialisers.py:2355 #: documents/serialisers.py:2358
#, python-format #, python-format
msgid "Custom field with id %(id)s does not exist" msgid "Custom field with id %(id)s does not exist"
msgstr "" msgstr ""
#: documents/serialisers.py:2372 documents/serialisers.py:2382 #: documents/serialisers.py:2375 documents/serialisers.py:2385
msgid "Custom fields must be a list of integers or an object mapping ids to values." msgid "Custom fields must be a list of integers or an object mapping ids to values."
msgstr "" msgstr ""
#: documents/serialisers.py:2377 #: documents/serialisers.py:2380
msgid "Some custom fields don't exist or were specified twice." msgid "Some custom fields don't exist or were specified twice."
msgstr "" msgstr ""
#: documents/serialisers.py:2524 #: documents/serialisers.py:2527
msgid "Invalid variable detected." msgid "Invalid variable detected."
msgstr "" msgstr ""
#: documents/serialisers.py:2883 #: documents/serialisers.py:2886
msgid "Duplicate document identifiers are not allowed." msgid "Duplicate document identifiers are not allowed."
msgstr "" msgstr ""
#: documents/serialisers.py:2913 documents/views.py:4608 #: documents/serialisers.py:2916 documents/views.py:4610
#, python-format #, python-format
msgid "Documents not found: %(ids)s" msgid "Documents not found: %(ids)s"
msgstr "" msgstr ""
@@ -1906,36 +1906,36 @@ msgstr ""
msgid "Unable to parse URI {value}" msgid "Unable to parse URI {value}"
msgstr "" msgstr ""
#: documents/views.py:306 documents/views.py:2608 #: documents/views.py:306 documents/views.py:2610
msgid "Invalid more_like_id" msgid "Invalid more_like_id"
msgstr "" msgstr ""
#: documents/views.py:1582 #: documents/views.py:1584
msgid "Invalid AI configuration." msgid "Invalid AI configuration."
msgstr "" msgstr ""
#: documents/views.py:1593 #: documents/views.py:1595
msgid "AI backend request timed out." msgid "AI backend request timed out."
msgstr "" msgstr ""
#: documents/views.py:2433 documents/views.py:2754 #: documents/views.py:2435 documents/views.py:2756
msgid "Specify only one of text, title_search, query, or more_like_id." msgid "Specify only one of text, title_search, query, or more_like_id."
msgstr "" msgstr ""
#: documents/views.py:4621 #: documents/views.py:4623
#, python-format #, python-format
msgid "Insufficient permissions to share document %(id)s." msgid "Insufficient permissions to share document %(id)s."
msgstr "" msgstr ""
#: documents/views.py:4667 #: documents/views.py:4669
msgid "Bundle is already being processed." msgid "Bundle is already being processed."
msgstr "" msgstr ""
#: documents/views.py:4728 #: documents/views.py:4730
msgid "The share link bundle is still being prepared. Please try again later." msgid "The share link bundle is still being prepared. Please try again later."
msgstr "" msgstr ""
#: documents/views.py:4738 #: documents/views.py:4740
msgid "The share link bundle is unavailable." msgid "The share link bundle is unavailable."
msgstr "" msgstr ""

Some files were not shown because too many files have changed in this diff Show More