Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ee19e8dcff | ||
|
|
b9cf877029 | ||
|
|
82cc1016ad | ||
|
|
dcedb531c6 | ||
|
|
8bedb07cea | ||
|
|
5e34d566a2 | ||
|
|
601ecce3f1 | ||
|
|
d67beba9f6 | ||
|
|
3bc8ee8425 | ||
|
|
fdef4a99a7 | ||
|
|
d16d05a391 | ||
|
|
10789e63cb | ||
|
|
2197781b39 | ||
|
|
981492bb33 | ||
|
|
0ef5ef5826 | ||
|
|
a6b1763149 | ||
|
|
90531525e2 | ||
|
|
8f00bfa931 | ||
|
|
a0479f1d9b | ||
|
|
9b8bd21044 | ||
|
|
a60172bc6f | ||
|
|
6c5bc1c0ff | ||
|
|
f4a7c478a9 | ||
|
|
bc07c19d9b | ||
|
|
bfcee24572 | ||
|
|
4fec4b0948 | ||
|
|
3e4ffc4132 | ||
|
|
6d61bcee7e | ||
|
|
d78754bff1 | ||
|
|
5c5b1ee6b5 | ||
|
|
08f2f4bfe2 | ||
|
|
f993462973 | ||
|
|
ae70b8d60f | ||
|
|
38db6b51db | ||
|
|
31e9f4272c | ||
|
|
b8659c1af3 | ||
|
|
741115b36b | ||
|
|
1211db5cbb | ||
|
|
ca98dffbd2 | ||
|
|
4db1451e41 | ||
|
|
624b7911e5 | ||
|
|
5a1a5333ad | ||
|
|
bfe8213b78 | ||
|
|
85e192f935 | ||
|
|
4fcd4961bb | ||
|
|
5d9401ac4a | ||
|
|
6935defe7c | ||
|
|
440049978b | ||
|
|
06e9c1c02b | ||
|
|
40d09ef309 |
@@ -185,22 +185,16 @@ 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
|
||||||
@@ -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@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0
|
||||||
|
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]
|
||||||
|
|||||||
@@ -61,7 +61,7 @@ def replace_with_symlinks(
|
|||||||
total_duplicates = 0
|
total_duplicates = 0
|
||||||
space_saved = 0
|
space_saved = 0
|
||||||
|
|
||||||
for file_hash, file_list in duplicate_groups.items():
|
for file_list in duplicate_groups.values():
|
||||||
# Keep the first file as the original, replace others with symlinks
|
# Keep the first file as the original, replace others with symlinks
|
||||||
original_file = file_list[0]
|
original_file = file_list[0]
|
||||||
duplicates = file_list[1:]
|
duplicates = file_list[1:]
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 1.8 MiB After Width: | Height: | Size: 1.4 MiB |
|
Before Width: | Height: | Size: 501 KiB After Width: | Height: | Size: 487 KiB |
|
Before Width: | Height: | Size: 21 KiB After Width: | Height: | Size: 34 KiB |
|
Before Width: | Height: | Size: 2.2 MiB After Width: | Height: | Size: 558 KiB |
|
Before Width: | Height: | Size: 644 KiB After Width: | Height: | Size: 1.1 MiB |
|
Before Width: | Height: | Size: 667 KiB After Width: | Height: | Size: 1.4 MiB |
|
Before Width: | Height: | Size: 1003 KiB After Width: | Height: | Size: 1.2 MiB |
|
Before Width: | Height: | Size: 1.7 MiB After Width: | Height: | Size: 1.4 MiB |
|
Before Width: | Height: | Size: 2.1 MiB After Width: | Height: | Size: 1.5 MiB |
|
Before Width: | Height: | Size: 1.8 MiB After Width: | Height: | Size: 1.4 MiB |
|
Before Width: | Height: | Size: 925 KiB After Width: | Height: | Size: 972 KiB |
|
Before Width: | Height: | Size: 1.7 MiB After Width: | Height: | Size: 1.4 MiB |
|
Before Width: | Height: | Size: 1.8 MiB After Width: | Height: | Size: 1.4 MiB |
|
Before Width: | Height: | Size: 2.3 MiB After Width: | Height: | Size: 558 KiB |
|
Before Width: | Height: | Size: 726 KiB After Width: | Height: | Size: 1.3 MiB |
|
Before Width: | Height: | Size: 169 KiB After Width: | Height: | Size: 294 KiB |
|
Before Width: | Height: | Size: 432 KiB After Width: | Height: | Size: 298 KiB |
|
Before Width: | Height: | Size: 280 KiB After Width: | Height: | Size: 322 KiB |
|
Before Width: | Height: | Size: 246 KiB After Width: | Height: | Size: 205 KiB |
|
Before Width: | Height: | Size: 27 KiB After Width: | Height: | Size: 43 KiB |
|
Before Width: | Height: | Size: 29 KiB After Width: | Height: | Size: 44 KiB |
|
Before Width: | Height: | Size: 48 KiB After Width: | Height: | Size: 57 KiB |
|
Before Width: | Height: | Size: 45 KiB After Width: | Height: | Size: 65 KiB |
|
Before Width: | Height: | Size: 559 KiB After Width: | Height: | Size: 516 KiB |
|
Before Width: | Height: | Size: 116 KiB After Width: | Height: | Size: 230 KiB |
|
Before Width: | Height: | Size: 87 KiB After Width: | Height: | Size: 333 KiB |
|
Before Width: | Height: | Size: 792 KiB After Width: | Height: | Size: 1.2 MiB |
|
Before Width: | Height: | Size: 137 KiB After Width: | Height: | Size: 291 KiB |
@@ -1,5 +1,62 @@
|
|||||||
# 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
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
- Fix: 3.1.0 llm suggestions remove existing metadata from prompt, dont drop name suggestions [@shamoon](https://github.com/shamoon) ([#13866](https://github.com/paperless-ngx/paperless-ngx/pull/13866))
|
||||||
|
- Fix: set global search earlier to avoid awaiting debounce [@shamoon](https://github.com/shamoon) ([#13865](https://github.com/paperless-ngx/paperless-ngx/pull/13865))
|
||||||
|
- Fix: responsive sidebar, centralize and make sizes saner [@shamoon](https://github.com/shamoon) ([#13863](https://github.com/paperless-ngx/paperless-ngx/pull/13863))
|
||||||
|
- Tweak/fix: show existing count for ai suggestions [@shamoon](https://github.com/shamoon) ([#13861](https://github.com/paperless-ngx/paperless-ngx/pull/13861))
|
||||||
|
- Fix: 3.1.0 llm suggestions simplify schema, fix docstrings [@shamoon](https://github.com/shamoon) ([#13850](https://github.com/paperless-ngx/paperless-ngx/pull/13850))
|
||||||
|
- Fix: ensure ui reset of suggestionsLoading when changing docs [@shamoon](https://github.com/shamoon) ([#13840](https://github.com/paperless-ngx/paperless-ngx/pull/13840))
|
||||||
|
- Fix: always pass a non-empty api key for OpenAI-like servers [@shamoon](https://github.com/shamoon) ([#13838](https://github.com/paperless-ngx/paperless-ngx/pull/13838))
|
||||||
|
- Fix: hide slim sidebar scrollbar in browsers with stupid scrollbars [@shamoon](https://github.com/shamoon) ([#13837](https://github.com/paperless-ngx/paperless-ngx/pull/13837))
|
||||||
|
- Fix: correct sharelink bundle + document link permissions display bugs [@shamoon](https://github.com/shamoon) ([#13827](https://github.com/paperless-ngx/paperless-ngx/pull/13827))
|
||||||
|
- Fix: immediately re-add doc to index after trash restore [@shamoon](https://github.com/shamoon) ([#13818](https://github.com/paperless-ngx/paperless-ngx/pull/13818))
|
||||||
|
- Fix: navbar brand anchor size + Safari position jitter [@shamoon](https://github.com/shamoon) ([#13810](https://github.com/paperless-ngx/paperless-ngx/pull/13810))
|
||||||
|
|
||||||
|
### All App Changes
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary>12 changes</summary>
|
||||||
|
|
||||||
|
- Fix: 3.1.0 llm suggestions remove existing metadata from prompt, dont drop name suggestions [@shamoon](https://github.com/shamoon) ([#13866](https://github.com/paperless-ngx/paperless-ngx/pull/13866))
|
||||||
|
- Fix: set global search earlier to avoid awaiting debounce [@shamoon](https://github.com/shamoon) ([#13865](https://github.com/paperless-ngx/paperless-ngx/pull/13865))
|
||||||
|
- Fix: responsive sidebar, centralize and make sizes saner [@shamoon](https://github.com/shamoon) ([#13863](https://github.com/paperless-ngx/paperless-ngx/pull/13863))
|
||||||
|
- Tweak/fix: show existing count for ai suggestions [@shamoon](https://github.com/shamoon) ([#13861](https://github.com/paperless-ngx/paperless-ngx/pull/13861))
|
||||||
|
- Fix: 3.1.0 llm suggestions simplify schema, fix docstrings [@shamoon](https://github.com/shamoon) ([#13850](https://github.com/paperless-ngx/paperless-ngx/pull/13850))
|
||||||
|
- Fixhancement: make imap port required, better error display [@shamoon](https://github.com/shamoon) ([#13845](https://github.com/paperless-ngx/paperless-ngx/pull/13845))
|
||||||
|
- Fix: ensure ui reset of suggestionsLoading when changing docs [@shamoon](https://github.com/shamoon) ([#13840](https://github.com/paperless-ngx/paperless-ngx/pull/13840))
|
||||||
|
- Fix: always pass a non-empty api key for OpenAI-like servers [@shamoon](https://github.com/shamoon) ([#13838](https://github.com/paperless-ngx/paperless-ngx/pull/13838))
|
||||||
|
- Fix: hide slim sidebar scrollbar in browsers with stupid scrollbars [@shamoon](https://github.com/shamoon) ([#13837](https://github.com/paperless-ngx/paperless-ngx/pull/13837))
|
||||||
|
- Fix: correct sharelink bundle + document link permissions display bugs [@shamoon](https://github.com/shamoon) ([#13827](https://github.com/paperless-ngx/paperless-ngx/pull/13827))
|
||||||
|
- Fix: immediately re-add doc to index after trash restore [@shamoon](https://github.com/shamoon) ([#13818](https://github.com/paperless-ngx/paperless-ngx/pull/13818))
|
||||||
|
- Fix: navbar brand anchor size + Safari position jitter [@shamoon](https://github.com/shamoon) ([#13810](https://github.com/paperless-ngx/paperless-ngx/pull/13810))
|
||||||
|
|
||||||
|
</details>
|
||||||
|
|
||||||
## paperless-ngx 3.1.0
|
## paperless-ngx 3.1.0
|
||||||
|
|
||||||
### Features / Enhancements
|
### Features / Enhancements
|
||||||
|
|||||||
@@ -2088,6 +2088,12 @@ password. All of these options come from their similarly-named [Django settings]
|
|||||||
|
|
||||||
Defaults to "always".
|
Defaults to "always".
|
||||||
|
|
||||||
|
#### [`PAPERLESS_REMOTE_OCR_ALLOW_INTERNAL_ENDPOINTS=<bool>`](#PAPERLESS_REMOTE_OCR_ALLOW_INTERNAL_ENDPOINTS) {#PAPERLESS_REMOTE_OCR_ALLOW_INTERNAL_ENDPOINTS}
|
||||||
|
|
||||||
|
: If set to false, Paperless blocks remote OCR endpoint URLs that resolve to non-public addresses (e.g., localhost, etc).
|
||||||
|
|
||||||
|
Defaults to True.
|
||||||
|
|
||||||
## AI {#ai}
|
## AI {#ai}
|
||||||
|
|
||||||
#### [`PAPERLESS_AI_ENABLED=<bool>`](#PAPERLESS_AI_ENABLED) {#PAPERLESS_AI_ENABLED}
|
#### [`PAPERLESS_AI_ENABLED=<bool>`](#PAPERLESS_AI_ENABLED) {#PAPERLESS_AI_ENABLED}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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",
|
||||||
@@ -186,64 +186,117 @@ line-ending = "lf"
|
|||||||
# https://docs.astral.sh/ruff/rules/
|
# https://docs.astral.sh/ruff/rules/
|
||||||
select = [ "E4", "E7", "E9", "F" ]
|
select = [ "E4", "E7", "E9", "F" ]
|
||||||
extend-select = [
|
extend-select = [
|
||||||
"COM", # https://docs.astral.sh/ruff/rules/#flake8-commas-com
|
"ASYNC", # https://docs.astral.sh/ruff/rules/#flake8-async-async
|
||||||
"DJ", # https://docs.astral.sh/ruff/rules/#flake8-django-dj
|
"B002", # https://docs.astral.sh/ruff/rules/#flake8-bugbear-b
|
||||||
"EXE", # https://docs.astral.sh/ruff/rules/#flake8-executable-exe
|
"B003",
|
||||||
"FBT", # https://docs.astral.sh/ruff/rules/#flake8-boolean-trap-fbt
|
"B004",
|
||||||
"FLY", # https://docs.astral.sh/ruff/rules/#flynt-fly
|
"B005",
|
||||||
"G201", # https://docs.astral.sh/ruff/rules/#flake8-logging-format-g
|
"B006",
|
||||||
"I", # https://docs.astral.sh/ruff/rules/#isort-i
|
"B008",
|
||||||
"ICN", # https://docs.astral.sh/ruff/rules/#flake8-import-conventions-icn
|
"B009",
|
||||||
"INP", # https://docs.astral.sh/ruff/rules/#flake8-no-pep420-inp
|
"B010",
|
||||||
"ISC", # https://docs.astral.sh/ruff/rules/#flake8-implicit-str-concat-isc
|
"B012",
|
||||||
"PIE", # https://docs.astral.sh/ruff/rules/#flake8-pie-pie
|
"B013",
|
||||||
"PLC", # https://docs.astral.sh/ruff/rules/#pylint-pl
|
"B014",
|
||||||
"PLE", # https://docs.astral.sh/ruff/rules/#pylint-pl
|
"B015",
|
||||||
"PTH", # https://docs.astral.sh/ruff/rules/#flake8-use-pathlib-pth
|
"B016",
|
||||||
"Q", # https://docs.astral.sh/ruff/rules/#flake8-quotes-q
|
"B017",
|
||||||
"RSE", # https://docs.astral.sh/ruff/rules/#flake8-raise-rse
|
"B018",
|
||||||
"RUF", # https://docs.astral.sh/ruff/rules/#ruff-specific-rules-ruf
|
"B019",
|
||||||
"SIM", # https://docs.astral.sh/ruff/rules/#flake8-simplify-sim
|
"B020",
|
||||||
"T20", # https://docs.astral.sh/ruff/rules/#flake8-print-t20
|
"B021",
|
||||||
"TC", # https://docs.astral.sh/ruff/rules/#flake8-type-checking-tc
|
"B022",
|
||||||
"TID", # https://docs.astral.sh/ruff/rules/#flake8-tidy-imports-tid
|
"B023",
|
||||||
"UP", # https://docs.astral.sh/ruff/rules/#pyupgrade-up
|
"B025",
|
||||||
"W", # https://docs.astral.sh/ruff/rules/#pycodestyle-e-w
|
"B026",
|
||||||
|
"B029",
|
||||||
|
"B030",
|
||||||
|
"B031",
|
||||||
|
"B032",
|
||||||
|
"B033",
|
||||||
|
"B035",
|
||||||
|
"B039",
|
||||||
|
"C4", # https://docs.astral.sh/ruff/rules/#flake8-comprehensions-c4
|
||||||
|
"COM", # https://docs.astral.sh/ruff/rules/#flake8-commas-com
|
||||||
|
"D419", # https://docs.astral.sh/ruff/rules/#pydocstyle-d
|
||||||
|
"DJ", # https://docs.astral.sh/ruff/rules/#flake8-django-dj
|
||||||
|
"DTZ", # https://docs.astral.sh/ruff/rules/#flake8-datetimez-dtz
|
||||||
|
"EXE", # https://docs.astral.sh/ruff/rules/#flake8-executable-exe
|
||||||
|
"FA", # https://docs.astral.sh/ruff/rules/#flake8-future-annotations-fa
|
||||||
|
"FBT", # https://docs.astral.sh/ruff/rules/#flake8-boolean-trap-fbt
|
||||||
|
"FLY", # https://docs.astral.sh/ruff/rules/#flynt-fly
|
||||||
|
"FURB", # https://docs.astral.sh/ruff/rules/#refurb-furb
|
||||||
|
"G", # https://docs.astral.sh/ruff/rules/#flake8-logging-format-g
|
||||||
|
"I", # https://docs.astral.sh/ruff/rules/#isort-i
|
||||||
|
"ICN", # https://docs.astral.sh/ruff/rules/#flake8-import-conventions-icn
|
||||||
|
"INP", # https://docs.astral.sh/ruff/rules/#flake8-no-pep420-inp
|
||||||
|
"INT", # https://docs.astral.sh/ruff/rules/#flake8-gettext-int
|
||||||
|
"ISC", # https://docs.astral.sh/ruff/rules/#flake8-implicit-str-concat-isc
|
||||||
|
"LOG", # https://docs.astral.sh/ruff/rules/#flake8-logging-log
|
||||||
|
"N999", # https://docs.astral.sh/ruff/rules/#pep8-naming-n
|
||||||
|
"PERF101", # https://docs.astral.sh/ruff/rules/#perflint-perf
|
||||||
|
"PERF102",
|
||||||
|
"PERF402",
|
||||||
|
"PGH005", # https://docs.astral.sh/ruff/rules/#pygrep-hooks-pgh
|
||||||
|
"PIE", # https://docs.astral.sh/ruff/rules/#flake8-pie-pie
|
||||||
|
"PLC", # https://docs.astral.sh/ruff/rules/#pylint-pl
|
||||||
|
"PLE", # https://docs.astral.sh/ruff/rules/#error-ple
|
||||||
|
"PLR0124", # https://docs.astral.sh/ruff/rules/#refactor-plr
|
||||||
|
"PLR0133",
|
||||||
|
"PLR0206",
|
||||||
|
"PLR0402",
|
||||||
|
"PLR1704",
|
||||||
|
"PLR1708",
|
||||||
|
"PLR1711",
|
||||||
|
"PLR1716",
|
||||||
|
"PLR1722",
|
||||||
|
"PLR1730",
|
||||||
|
"PLR1733",
|
||||||
|
"PLR1736",
|
||||||
|
"PLR2044",
|
||||||
|
"PLW", # https://docs.astral.sh/ruff/rules/#warning-plw
|
||||||
|
"PT010", # https://docs.astral.sh/ruff/rules/#flake8-pytest-style-pt
|
||||||
|
"PT014",
|
||||||
|
"PT020",
|
||||||
|
"PT025",
|
||||||
|
"PT026",
|
||||||
|
"PT031",
|
||||||
|
"PTH", # https://docs.astral.sh/ruff/rules/#flake8-use-pathlib-pth
|
||||||
|
"Q", # https://docs.astral.sh/ruff/rules/#flake8-quotes-q
|
||||||
|
"RSE", # https://docs.astral.sh/ruff/rules/#flake8-raise-rse
|
||||||
|
"RUF", # https://docs.astral.sh/ruff/rules/#ruff-specific-rules-ruf
|
||||||
|
"S102", # https://docs.astral.sh/ruff/rules/#flake8-bandit-s
|
||||||
|
"S110",
|
||||||
|
"S112",
|
||||||
|
"S113",
|
||||||
|
"SIM", # https://docs.astral.sh/ruff/rules/#flake8-simplify-sim
|
||||||
|
"T100", # https://docs.astral.sh/ruff/rules/#flake8-debugger-t10
|
||||||
|
"T20", # https://docs.astral.sh/ruff/rules/#flake8-print-t20
|
||||||
|
"TC", # https://docs.astral.sh/ruff/rules/#flake8-type-checking-tc
|
||||||
|
"TID", # https://docs.astral.sh/ruff/rules/#flake8-tidy-imports-tid
|
||||||
|
"TRY002", # https://docs.astral.sh/ruff/rules/#tryceratops-try
|
||||||
|
"TRY004",
|
||||||
|
"TRY201",
|
||||||
|
"TRY203",
|
||||||
|
"TRY401",
|
||||||
|
"UP", # https://docs.astral.sh/ruff/rules/#pyupgrade-up
|
||||||
|
"W", # https://docs.astral.sh/ruff/rules/#pycodestyle-e-w
|
||||||
|
"YTT", # https://docs.astral.sh/ruff/rules/#flake8-2020-ytt
|
||||||
]
|
]
|
||||||
ignore = [
|
ignore = [
|
||||||
"DJ001",
|
"DJ001",
|
||||||
"PLC0415",
|
"PLC0415",
|
||||||
"RUF012",
|
"RUF012",
|
||||||
"SIM105",
|
"SIM105",
|
||||||
|
"G004", # Logging statement uses f-string - good to do, but a large diff
|
||||||
]
|
]
|
||||||
# Migrations
|
# Migrations
|
||||||
per-file-ignores."*/migrations/*.py" = [
|
per-file-ignores."*/migrations/*.py" = []
|
||||||
"E501",
|
|
||||||
"SIM",
|
|
||||||
"T201",
|
|
||||||
]
|
|
||||||
# Testing
|
# Testing
|
||||||
per-file-ignores."*/tests/*.py" = [
|
per-file-ignores."*/tests/*.py" = [
|
||||||
"E501",
|
"DTZ",
|
||||||
"SIM117",
|
"SIM117",
|
||||||
]
|
]
|
||||||
per-file-ignores.".github/scripts/*.py" = [
|
|
||||||
"E501",
|
|
||||||
"INP001",
|
|
||||||
"SIM117",
|
|
||||||
]
|
|
||||||
# Docker specific
|
|
||||||
per-file-ignores."docker/rootfs/usr/local/bin/wait-for-redis.py" = [
|
|
||||||
"INP001",
|
|
||||||
"T201",
|
|
||||||
]
|
|
||||||
per-file-ignores."docker/wait-for-redis.py" = [
|
|
||||||
"INP001",
|
|
||||||
"T201",
|
|
||||||
]
|
|
||||||
per-file-ignores."src/documents/models.py" = [
|
|
||||||
"SIM115",
|
|
||||||
]
|
|
||||||
isort.force-single-line = true
|
isort.force-single-line = true
|
||||||
|
|
||||||
[tool.codespell]
|
[tool.codespell]
|
||||||
|
|||||||
@@ -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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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 }
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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()
|
||||||
|
|||||||
@@ -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()
|
||||||
@@ -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(
|
||||||
|
|||||||
@@ -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
|
||||||
|
.poll(() =>
|
||||||
|
page.evaluate(() => {
|
||||||
|
const detail = document.querySelector('pngx-document-detail')
|
||||||
|
return (window as any).ng.getComponent(detail).networkActive()
|
||||||
|
})
|
||||||
|
)
|
||||||
|
.toBe(false)
|
||||||
const documentReloaded = page.waitForResponse(
|
const documentReloaded = page.waitForResponse(
|
||||||
(response) =>
|
(response) =>
|
||||||
response.url().includes('/api/documents/175/?full_perms=true') &&
|
response.url().includes('/api/documents/1/?full_perms=true') &&
|
||||||
response.request().method() === 'GET'
|
response.request().method() === 'GET'
|
||||||
)
|
)
|
||||||
statusSocket.send(
|
await page.evaluate(() => {
|
||||||
JSON.stringify({
|
const detail = document.querySelector('pngx-document-detail')
|
||||||
type: 'document_updated',
|
const component = (window as any).ng.getComponent(detail)
|
||||||
data: {
|
component.handleIncomingDocumentUpdated({
|
||||||
document_id: 175,
|
document_id: 1,
|
||||||
modified: '2026-07-26T20:00:00Z',
|
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)
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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 }
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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: [
|
||||||
port,
|
{
|
||||||
command: 'pnpm run start',
|
url: 'http://localhost:8001/accounts/login/',
|
||||||
reuseExistingServer: !process.env.CI,
|
command: 'npm run e2e:backend',
|
||||||
timeout: 2 * 60 * 1000,
|
reuseExistingServer: false,
|
||||||
},
|
timeout: 2 * 60 * 1000,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
port,
|
||||||
|
command: 'npm run start:e2e',
|
||||||
|
reuseExistingServer: !process.env.CI,
|
||||||
|
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/',
|
||||||
|
}
|
||||||
@@ -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/',
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -507,8 +507,8 @@ def rotate(
|
|||||||
logger.info(
|
logger.info(
|
||||||
f"Queued new rotated version for document {pair.root_doc.id} by {degrees} degrees",
|
f"Queued new rotated version for document {pair.root_doc.id} by {degrees} degrees",
|
||||||
)
|
)
|
||||||
except Exception as e:
|
except Exception:
|
||||||
logger.exception(f"Error rotating document {pair.root_doc.id}: {e}")
|
logger.exception(f"Error rotating document {pair.root_doc.id}")
|
||||||
|
|
||||||
return "OK"
|
return "OK"
|
||||||
|
|
||||||
@@ -554,9 +554,9 @@ def merge(
|
|||||||
affected_docs.append(doc.id)
|
affected_docs.append(doc.id)
|
||||||
if handoff_asn is None and doc.archive_serial_number is not None:
|
if handoff_asn is None and doc.archive_serial_number is not None:
|
||||||
handoff_asn = doc.archive_serial_number
|
handoff_asn = doc.archive_serial_number
|
||||||
except Exception as e:
|
except Exception:
|
||||||
logger.exception(
|
logger.exception(
|
||||||
f"Error merging document {doc.id}, it will not be included in the merge: {e}",
|
f"Error merging document {doc.id}, it will not be included in the merge",
|
||||||
)
|
)
|
||||||
if len(affected_docs) == 0:
|
if len(affected_docs) == 0:
|
||||||
logger.warning("No documents were merged")
|
logger.warning("No documents were merged")
|
||||||
@@ -805,8 +805,8 @@ def split(
|
|||||||
else:
|
else:
|
||||||
group(consume_tasks).delay()
|
group(consume_tasks).delay()
|
||||||
|
|
||||||
except Exception as e:
|
except Exception:
|
||||||
logger.exception(f"Error splitting document {doc.id}: {e}")
|
logger.exception(f"Error splitting document {doc.id}")
|
||||||
|
|
||||||
return "OK"
|
return "OK"
|
||||||
|
|
||||||
@@ -858,8 +858,8 @@ def delete_pages(
|
|||||||
logger.info(
|
logger.info(
|
||||||
f"Queued new version for document {pair.root_doc.id} after deleting pages {pages}",
|
f"Queued new version for document {pair.root_doc.id} after deleting pages {pages}",
|
||||||
)
|
)
|
||||||
except Exception as e:
|
except Exception:
|
||||||
logger.exception(f"Error deleting pages from document {pair.root_doc.id}: {e}")
|
logger.exception(f"Error deleting pages from document {pair.root_doc.id}")
|
||||||
|
|
||||||
return "OK"
|
return "OK"
|
||||||
|
|
||||||
@@ -986,7 +986,7 @@ def edit_pdf(
|
|||||||
group(consume_tasks).delay()
|
group(consume_tasks).delay()
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.exception(f"Error editing document {pair.root_doc.id}: {e}")
|
logger.exception(f"Error editing document {pair.root_doc.id}")
|
||||||
raise ValueError(
|
raise ValueError(
|
||||||
f"An error occurred while editing the document: {e}",
|
f"An error occurred while editing the document: {e}",
|
||||||
) from e
|
) from e
|
||||||
@@ -1097,7 +1097,7 @@ def remove_password(
|
|||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.exception(
|
logger.exception(
|
||||||
f"Error removing password from document {pair.root_doc.id}: {e}",
|
f"Error removing password from document {pair.root_doc.id}",
|
||||||
)
|
)
|
||||||
raise ValueError(
|
raise ValueError(
|
||||||
f"An error occurred while removing the password: {e}",
|
f"An error occurred while removing the password: {e}",
|
||||||
|
|||||||
@@ -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,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|
||||||
@@ -69,8 +72,8 @@ def load_classifier(*, raise_exception: bool = False) -> DocumentClassifier | No
|
|||||||
Path(settings.MODEL_FILE).unlink()
|
Path(settings.MODEL_FILE).unlink()
|
||||||
classifier = None
|
classifier = None
|
||||||
if raise_exception:
|
if raise_exception:
|
||||||
raise e
|
raise
|
||||||
except ClassifierModelCorruptError as e:
|
except ClassifierModelCorruptError:
|
||||||
# there's something wrong with the model file.
|
# there's something wrong with the model file.
|
||||||
logger.exception(
|
logger.exception(
|
||||||
"Unrecoverable error while loading document "
|
"Unrecoverable error while loading document "
|
||||||
@@ -79,17 +82,17 @@ def load_classifier(*, raise_exception: bool = False) -> DocumentClassifier | No
|
|||||||
Path(settings.MODEL_FILE).unlink()
|
Path(settings.MODEL_FILE).unlink()
|
||||||
classifier = None
|
classifier = None
|
||||||
if raise_exception:
|
if raise_exception:
|
||||||
raise e
|
raise
|
||||||
except OSError as e:
|
except OSError:
|
||||||
logger.exception("IO error while loading document classification model")
|
logger.exception("IO error while loading document classification model")
|
||||||
classifier = None
|
classifier = None
|
||||||
if raise_exception:
|
if raise_exception:
|
||||||
raise e
|
raise
|
||||||
except Exception as e: # pragma: no cover
|
except Exception: # pragma: no cover
|
||||||
logger.exception("Unknown error while loading document classification model")
|
logger.exception("Unknown error while loading document classification model")
|
||||||
classifier = None
|
classifier = None
|
||||||
if raise_exception:
|
if raise_exception:
|
||||||
raise e
|
raise
|
||||||
|
|
||||||
return classifier
|
return 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:
|
else:
|
||||||
read_cache.touch(key, CACHE_5_MINUTES)
|
try:
|
||||||
result = pickle.loads(serialized_result)
|
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:
|
||||||
|
read_cache.touch(key, CACHE_5_MINUTES)
|
||||||
return result
|
return result
|
||||||
|
|
||||||
def predict_correspondent(self, content: str) -> int | None:
|
def predict_correspondent(self, content: str) -> int | None:
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -216,7 +217,7 @@ class ConsumerPluginMixin:
|
|||||||
current_progress,
|
current_progress,
|
||||||
max_progress,
|
max_progress,
|
||||||
document_id=document_id,
|
document_id=document_id,
|
||||||
owner_id=self.metadata.owner_id if self.metadata.owner_id else None,
|
owner_id=self.metadata.owner_id or None,
|
||||||
users_can_view=(self.metadata.view_users or [])
|
users_can_view=(self.metadata.view_users or [])
|
||||||
+ (self.metadata.change_users or []),
|
+ (self.metadata.change_users or []),
|
||||||
groups_can_view=(self.metadata.view_groups or [])
|
groups_can_view=(self.metadata.view_groups or [])
|
||||||
@@ -674,9 +675,7 @@ class ConsumerPlugin(
|
|||||||
document=document,
|
document=document,
|
||||||
logging_group=self.logging_group,
|
logging_group=self.logging_group,
|
||||||
classifier=classifier,
|
classifier=classifier,
|
||||||
original_file=self.unmodified_original
|
original_file=self.unmodified_original or self.working_copy,
|
||||||
if self.unmodified_original
|
|
||||||
else self.working_copy,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
# After everything is in the database, copy the files into
|
# After everything is in the database, copy the files into
|
||||||
@@ -695,6 +694,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 +730,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,
|
||||||
@@ -849,7 +856,7 @@ class ConsumerPlugin(
|
|||||||
else:
|
else:
|
||||||
stats = Path(self.input_doc.original_file).stat()
|
stats = Path(self.input_doc.original_file).stat()
|
||||||
create_date = timezone.make_aware(
|
create_date = timezone.make_aware(
|
||||||
datetime.datetime.fromtimestamp(stats.st_mtime),
|
datetime.datetime.fromtimestamp(stats.st_mtime), # noqa: DTZ006 - make_aware() requires a naive datetime
|
||||||
)
|
)
|
||||||
self.log.debug(f"Creation date from st_mtime: {create_date}")
|
self.log.debug(f"Creation date from st_mtime: {create_date}")
|
||||||
|
|
||||||
@@ -963,7 +970,7 @@ class ConsumerPlugin(
|
|||||||
try:
|
try:
|
||||||
copy_basic_file_stats(source, target)
|
copy_basic_file_stats(source, target)
|
||||||
except Exception: # pragma: no cover
|
except Exception: # pragma: no cover
|
||||||
pass
|
self.log.debug("Unable to copy file stats from %s to %s", source, target)
|
||||||
|
|
||||||
|
|
||||||
class ConsumerPreflightPlugin(
|
class ConsumerPreflightPlugin(
|
||||||
|
|||||||
@@ -78,7 +78,9 @@ class CollatePlugin(NoCleanupPluginMixin, NoSetupPluginMixin, ConsumeTaskPlugin)
|
|||||||
stats = staging.stat()
|
stats = staging.stat()
|
||||||
# if the file is older than the timeout, we don't consider
|
# if the file is older than the timeout, we don't consider
|
||||||
# it valid
|
# it valid
|
||||||
if (dt.datetime.now().timestamp() - stats.st_mtime) > TIMEOUT_SECONDS:
|
if (
|
||||||
|
dt.datetime.now(tz=dt.UTC).timestamp() - stats.st_mtime
|
||||||
|
) > TIMEOUT_SECONDS:
|
||||||
logger.warning("Outdated double sided staging file exists, deleting it")
|
logger.warning("Outdated double sided staging file exists, deleting it")
|
||||||
staging.unlink()
|
staging.unlink()
|
||||||
else:
|
else:
|
||||||
@@ -134,7 +136,7 @@ class CollatePlugin(NoCleanupPluginMixin, NoSetupPluginMixin, ConsumeTaskPlugin)
|
|||||||
shutil.move(pdf_file, staging)
|
shutil.move(pdf_file, staging)
|
||||||
# update access to modification time so we know if the file
|
# update access to modification time so we know if the file
|
||||||
# is outdated when another file gets uploaded
|
# is outdated when another file gets uploaded
|
||||||
timestamp = dt.datetime.now().timestamp()
|
timestamp = dt.datetime.now(tz=dt.UTC).timestamp()
|
||||||
os.utime(staging, (timestamp, timestamp))
|
os.utime(staging, (timestamp, timestamp))
|
||||||
logger.info(
|
logger.info(
|
||||||
"Got scan with odd numbered pages of double-sided scan, moved it to %s",
|
"Got scan with odd numbered pages of double-sided scan, moved it to %s",
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -734,7 +734,7 @@ class CustomFieldQueryParser:
|
|||||||
)
|
)
|
||||||
|
|
||||||
# Check if any of the requested IDs are missing.
|
# Check if any of the requested IDs are missing.
|
||||||
missing_ids = set(value) - set(link.document_id for link in links)
|
missing_ids = set(value) - {link.document_id for link in links}
|
||||||
if missing_ids:
|
if missing_ids:
|
||||||
# The result should be an empty set in this case.
|
# The result should be an empty set in this case.
|
||||||
return Q(id__in=[])
|
return Q(id__in=[])
|
||||||
|
|||||||
@@ -631,23 +631,25 @@ class Command(BaseCommand):
|
|||||||
):
|
):
|
||||||
# Process each change
|
# Process each change
|
||||||
for change_type, path in changes:
|
for change_type, path in changes:
|
||||||
path = Path(path).resolve()
|
resolved_path = Path(path).resolve()
|
||||||
if change_type == Change.deleted:
|
if change_type == Change.deleted:
|
||||||
# Consumed (or otherwise removed); a later file
|
# Consumed (or otherwise removed); a later file
|
||||||
# reusing this name must not be skipped as
|
# reusing this name must not be skipped as
|
||||||
# already-queued.
|
# already-queued.
|
||||||
queued.discard(path)
|
queued.discard(resolved_path)
|
||||||
if not path.is_file():
|
if not resolved_path.is_file():
|
||||||
continue
|
continue
|
||||||
if path in queued:
|
if resolved_path in queued:
|
||||||
# Already queued and awaiting consumption; a stray
|
# Already queued and awaiting consumption; a stray
|
||||||
# event (NAS metadata touch, AV scan, etc.) while
|
# event (NAS metadata touch, AV scan, etc.) while
|
||||||
# the file sits on disk mid-consumption must not
|
# the file sits on disk mid-consumption must not
|
||||||
# cause it to be queued a second time (GH #13511).
|
# cause it to be queued a second time (GH #13511).
|
||||||
logger.debug(f"Ignoring event for queued file: {path}")
|
logger.debug(
|
||||||
|
f"Ignoring event for queued file: {resolved_path}",
|
||||||
|
)
|
||||||
continue
|
continue
|
||||||
logger.debug(f"Event: {change_type.name} for {path}")
|
logger.debug(f"Event: {change_type.name} for {resolved_path}")
|
||||||
tracker.track(path, change_type)
|
tracker.track(resolved_path, change_type)
|
||||||
|
|
||||||
# Check for stable files
|
# Check for stable files
|
||||||
for stable_path in tracker.get_stable_files():
|
for stable_path in tracker.get_stable_files():
|
||||||
|
|||||||
@@ -30,6 +30,10 @@ if TYPE_CHECKING:
|
|||||||
logger = logging.getLogger("paperless.matching")
|
logger = logging.getLogger("paperless.matching")
|
||||||
|
|
||||||
|
|
||||||
|
class UnsupportedWorkflowTriggerTypeError(Exception):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
def log_reason(
|
def log_reason(
|
||||||
matching_model: MatchingModel | WorkflowTrigger,
|
matching_model: MatchingModel | WorkflowTrigger,
|
||||||
document: Document,
|
document: Document,
|
||||||
@@ -691,7 +695,9 @@ def document_matches_workflow(
|
|||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
# New trigger types need to be explicitly checked above
|
# New trigger types need to be explicitly checked above
|
||||||
raise Exception(f"Trigger type {trigger_type} not yet supported")
|
raise UnsupportedWorkflowTriggerTypeError(
|
||||||
|
f"Trigger type {trigger_type} not yet supported",
|
||||||
|
)
|
||||||
|
|
||||||
if trigger_matched:
|
if trigger_matched:
|
||||||
logger.info(f"Document matched {trigger} from {workflow}")
|
logger.info(f"Document matched {trigger} from {workflow}")
|
||||||
|
|||||||
@@ -75,7 +75,7 @@ def recompute_checksums(apps, schema_editor):
|
|||||||
if updated_fields:
|
if updated_fields:
|
||||||
batch.append(doc)
|
batch.append(doc)
|
||||||
|
|
||||||
processed += 1
|
processed += 1 # noqa: SIM113
|
||||||
|
|
||||||
if len(batch) >= _BATCH_SIZE:
|
if len(batch) >= _BATCH_SIZE:
|
||||||
Document.objects.bulk_update(batch, ["checksum", "archive_checksum"])
|
Document.objects.bulk_update(batch, ["checksum", "archive_checksum"])
|
||||||
|
|||||||
@@ -377,7 +377,7 @@ class Document(SoftDeleteModel, ModelWithOwner): # type: ignore[django-manager-
|
|||||||
from documents.versioning import versions_newest_first
|
from documents.versioning import versions_newest_first
|
||||||
|
|
||||||
if hasattr(self, "effective_content"):
|
if hasattr(self, "effective_content"):
|
||||||
return getattr(self, "effective_content")
|
return self.effective_content
|
||||||
|
|
||||||
if self.root_document_id is not None or self.pk is None:
|
if self.root_document_id is not None or self.pk is None:
|
||||||
return self.content
|
return self.content
|
||||||
@@ -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}"
|
||||||
|
|||||||
@@ -41,7 +41,7 @@ def get_default_file_extension(mime_type: str) -> str:
|
|||||||
return supported[mime_type]
|
return supported[mime_type]
|
||||||
|
|
||||||
ext = mimetypes.guess_extension(mime_type)
|
ext = mimetypes.guess_extension(mime_type)
|
||||||
return ext if ext else ""
|
return ext or ""
|
||||||
|
|
||||||
|
|
||||||
def is_file_ext_supported(ext: str) -> bool:
|
def is_file_ext_supported(ext: str) -> bool:
|
||||||
@@ -110,7 +110,7 @@ def run_convert(
|
|||||||
args += ["-define", "pdf:use-cropbox=true"] if use_cropbox else []
|
args += ["-define", "pdf:use-cropbox=true"] if use_cropbox else []
|
||||||
args += [str(input_file), str(output_file)]
|
args += [str(input_file), str(output_file)]
|
||||||
|
|
||||||
logger.debug("Execute: " + " ".join(args), extra={"group": logging_group})
|
logger.debug("Execute: %s", " ".join(args), extra={"group": logging_group})
|
||||||
|
|
||||||
try:
|
try:
|
||||||
run_subprocess(args, environment, logger)
|
run_subprocess(args, environment, logger)
|
||||||
|
|||||||
@@ -43,8 +43,8 @@ def _discover_parser_class() -> type[DateParserPluginBase]:
|
|||||||
valid_plugins.append(ep)
|
valid_plugins.append(ep)
|
||||||
else:
|
else:
|
||||||
logger.warning(f"Plugin {ep.name} does not subclass DateParser.")
|
logger.warning(f"Plugin {ep.name} does not subclass DateParser.")
|
||||||
except Exception as e:
|
except Exception:
|
||||||
logger.exception(f"Unable to load date parser plugin {ep.name}: {e}")
|
logger.exception(f"Unable to load date parser plugin {ep.name}")
|
||||||
|
|
||||||
if not valid_plugins:
|
if not valid_plugins:
|
||||||
return RegexDateParserPlugin
|
return RegexDateParserPlugin
|
||||||
|
|||||||
@@ -91,8 +91,8 @@ class DateParserPluginBase(ABC):
|
|||||||
},
|
},
|
||||||
locales=self.config.languages,
|
locales=self.config.languages,
|
||||||
)
|
)
|
||||||
except Exception as e:
|
except Exception:
|
||||||
logger.exception(f"Error while parsing date string '{date_string}': {e}")
|
logger.exception(f"Error while parsing date string '{date_string}'")
|
||||||
return None
|
return None
|
||||||
|
|
||||||
def _filter_date(
|
def _filter_date(
|
||||||
|
|||||||
@@ -59,11 +59,10 @@ def safe_regex_match(pattern: str, text: str, *, flags: int = 0):
|
|||||||
try:
|
try:
|
||||||
validate_regex_pattern(pattern)
|
validate_regex_pattern(pattern)
|
||||||
compiled = regex.compile(pattern, flags=flags)
|
compiled = regex.compile(pattern, flags=flags)
|
||||||
except (regex.error, ValueError) as exc:
|
except (regex.error, ValueError):
|
||||||
logger.exception(
|
logger.exception(
|
||||||
"Error while processing regular expression %s: %s",
|
"Error while processing regular expression %s",
|
||||||
textwrap.shorten(pattern, width=80, placeholder="…"),
|
textwrap.shorten(pattern, width=80, placeholder="…"),
|
||||||
exc,
|
|
||||||
)
|
)
|
||||||
return None
|
return None
|
||||||
|
|
||||||
@@ -86,11 +85,10 @@ def safe_regex_sub(pattern: str, repl: str, text: str, *, flags: int = 0) -> str
|
|||||||
try:
|
try:
|
||||||
validate_regex_pattern(pattern)
|
validate_regex_pattern(pattern)
|
||||||
compiled = regex.compile(pattern, flags=flags)
|
compiled = regex.compile(pattern, flags=flags)
|
||||||
except (regex.error, ValueError) as exc:
|
except (regex.error, ValueError):
|
||||||
logger.exception(
|
logger.exception(
|
||||||
"Error while processing regular expression %s: %s",
|
"Error while processing regular expression %s",
|
||||||
textwrap.shorten(pattern, width=80, placeholder="…"),
|
textwrap.shorten(pattern, width=80, placeholder="…"),
|
||||||
exc,
|
|
||||||
)
|
)
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|||||||
@@ -1142,7 +1142,7 @@ def get_backend() -> TantivyBackend:
|
|||||||
Returns:
|
Returns:
|
||||||
Thread-safe singleton TantivyBackend instance
|
Thread-safe singleton TantivyBackend instance
|
||||||
"""
|
"""
|
||||||
global _backend, _backend_path
|
global _backend, _backend_path # noqa: PLW0603 - module-level singleton, no class to hold this state
|
||||||
|
|
||||||
current_path: Path = settings.INDEX_DIR
|
current_path: Path = settings.INDEX_DIR
|
||||||
|
|
||||||
@@ -1173,7 +1173,7 @@ def reset_backend() -> None:
|
|||||||
Forces creation of a new backend instance on the next get_backend() call.
|
Forces creation of a new backend instance on the next get_backend() call.
|
||||||
Used for test isolation and when switching between different index directories.
|
Used for test isolation and when switching between different index directories.
|
||||||
"""
|
"""
|
||||||
global _backend, _backend_path
|
global _backend, _backend_path # noqa: PLW0603 - module-level singleton, no class to hold this state
|
||||||
|
|
||||||
with _backend_lock:
|
with _backend_lock:
|
||||||
if _backend is not None:
|
if _backend is not None:
|
||||||
|
|||||||
@@ -240,7 +240,7 @@ def parse_user_query(
|
|||||||
DEFAULT_SEARCH_FIELDS,
|
DEFAULT_SEARCH_FIELDS,
|
||||||
field_boosts=_FIELD_BOOSTS,
|
field_boosts=_FIELD_BOOSTS,
|
||||||
# (prefix=True, distance=1, transposition_cost_one=True) — edit-distance fuzziness
|
# (prefix=True, distance=1, transposition_cost_one=True) — edit-distance fuzziness
|
||||||
fuzzy_fields={f: (True, 1, True) for f in DEFAULT_SEARCH_FIELDS},
|
fuzzy_fields=dict.fromkeys(DEFAULT_SEARCH_FIELDS, (True, 1, True)),
|
||||||
)
|
)
|
||||||
# 0.1 boost keeps fuzzy hits ranked below exact matches (intentional)
|
# 0.1 boost keeps fuzzy hits ranked below exact matches (intentional)
|
||||||
clauses.append((tantivy.Occur.Should, tantivy.Query.boost_query(fuzzy, 0.1)))
|
clauses.append((tantivy.Occur.Should, tantivy.Query.boost_query(fuzzy, 0.1)))
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -433,7 +434,7 @@ class OwnedObjectSerializer(
|
|||||||
return set()
|
return set()
|
||||||
|
|
||||||
ctype = ContentType.objects.get_for_model(first_obj)
|
ctype = ContentType.objects.get_for_model(first_obj)
|
||||||
object_pks = list(obj.pk for obj in objects)
|
object_pks = [obj.pk for obj in objects]
|
||||||
pk_type = type(first_obj.pk)
|
pk_type = type(first_obj.pk)
|
||||||
|
|
||||||
def get_pks_for_permission_type(model):
|
def get_pks_for_permission_type(model):
|
||||||
@@ -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 = (
|
||||||
@@ -727,7 +730,7 @@ class TagSerializer(MatchingModelSerializer, OwnedObjectSerializer):
|
|||||||
self.instance.clean()
|
self.instance.clean()
|
||||||
except ValidationError as e:
|
except ValidationError as e:
|
||||||
logger.debug("Tag parent validation failed: %s", e)
|
logger.debug("Tag parent validation failed: %s", e)
|
||||||
raise e
|
raise
|
||||||
finally:
|
finally:
|
||||||
self.instance.tn_parent = original_parent
|
self.instance.tn_parent = original_parent
|
||||||
else:
|
else:
|
||||||
@@ -737,7 +740,7 @@ class TagSerializer(MatchingModelSerializer, OwnedObjectSerializer):
|
|||||||
temp.clean()
|
temp.clean()
|
||||||
except ValidationError as e:
|
except ValidationError as e:
|
||||||
logger.debug("Tag parent validation failed: %s", e)
|
logger.debug("Tag parent validation failed: %s", e)
|
||||||
raise e
|
raise
|
||||||
|
|
||||||
return super().validate(attrs)
|
return super().validate(attrs)
|
||||||
|
|
||||||
@@ -1147,7 +1150,7 @@ class DocumentSerializer(
|
|||||||
def to_representation(self, instance):
|
def to_representation(self, instance):
|
||||||
doc = super().to_representation(instance)
|
doc = super().to_representation(instance)
|
||||||
if "content" in self.fields and hasattr(instance, "effective_content"):
|
if "content" in self.fields and hasattr(instance, "effective_content"):
|
||||||
doc["content"] = getattr(instance, "effective_content") or ""
|
doc["content"] = instance.effective_content or ""
|
||||||
if self.truncate_content and "content" in self.fields:
|
if self.truncate_content and "content" in self.fields:
|
||||||
doc["content"] = doc.get("content")[0:550]
|
doc["content"] = doc.get("content")[0:550]
|
||||||
return doc
|
return doc
|
||||||
@@ -1857,8 +1860,8 @@ class BulkEditSerializer(
|
|||||||
if isinstance(custom_fields, dict):
|
if isinstance(custom_fields, dict):
|
||||||
try:
|
try:
|
||||||
ids = [int(i[0]) for i in custom_fields.items()]
|
ids = [int(i[0]) for i in custom_fields.items()]
|
||||||
except Exception as e:
|
except Exception:
|
||||||
logger.exception(f"Error validating custom fields: {e}")
|
logger.exception("Error validating custom fields")
|
||||||
raise serializers.ValidationError(
|
raise serializers.ValidationError(
|
||||||
f"{name} must be a list of integers or a dict of id:value pairs, see the log for details",
|
f"{name} must be a list of integers or a dict of id:value pairs, see the log for details",
|
||||||
)
|
)
|
||||||
@@ -2056,13 +2059,12 @@ class BulkEditSerializer(
|
|||||||
for doc in docs:
|
for doc in docs:
|
||||||
if "-" in doc:
|
if "-" in doc:
|
||||||
pages.append(
|
pages.append(
|
||||||
[
|
list(
|
||||||
x
|
range(
|
||||||
for x in range(
|
|
||||||
int(doc.split("-")[0]),
|
int(doc.split("-")[0]),
|
||||||
int(doc.split("-")[1]) + 1,
|
int(doc.split("-")[1]) + 1,
|
||||||
)
|
),
|
||||||
],
|
),
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
pages.append([int(doc)])
|
pages.append([int(doc)])
|
||||||
@@ -2923,7 +2925,7 @@ class ShareLinkBundleSerializer(OwnedObjectSerializer):
|
|||||||
return share_link_bundle
|
return share_link_bundle
|
||||||
|
|
||||||
def get_document_count(self, obj: ShareLinkBundle) -> int:
|
def get_document_count(self, obj: ShareLinkBundle) -> int:
|
||||||
return getattr(obj, "document_total") or obj.documents.count()
|
return obj.document_total or obj.documents.count()
|
||||||
|
|
||||||
|
|
||||||
class BulkEditObjectsSerializer(SerializerWithPerms, SetPermissionsMixin):
|
class BulkEditObjectsSerializer(SerializerWithPerms, SetPermissionsMixin):
|
||||||
|
|||||||
@@ -637,7 +637,7 @@ def update_filename_and_move_files(
|
|||||||
# so this is not the end of the world.
|
# so this is not the end of the world.
|
||||||
# B: if moving the original file failed, nothing has changed
|
# B: if moving the original file failed, nothing has changed
|
||||||
# anyway.
|
# anyway.
|
||||||
pass
|
logger.exception("Error reverting document changes")
|
||||||
|
|
||||||
# restore old values on the instance
|
# restore old values on the instance
|
||||||
instance.filename = old_filename
|
instance.filename = old_filename
|
||||||
@@ -1102,10 +1102,11 @@ def _extract_input_data(
|
|||||||
if v is None or k.startswith("_"):
|
if v is None or k.startswith("_"):
|
||||||
continue
|
continue
|
||||||
if isinstance(v, datetime.date):
|
if isinstance(v, datetime.date):
|
||||||
v = v.isoformat()
|
override_dict[k] = v.isoformat()
|
||||||
elif isinstance(v, Path):
|
elif isinstance(v, Path):
|
||||||
v = str(v)
|
override_dict[k] = str(v)
|
||||||
override_dict[k] = v
|
else:
|
||||||
|
override_dict[k] = v
|
||||||
if override_dict:
|
if override_dict:
|
||||||
data["overrides"] = override_dict
|
data["overrides"] = override_dict
|
||||||
return data
|
return data
|
||||||
|
|||||||
@@ -217,9 +217,9 @@ def consume_file(
|
|||||||
overrides.filename or input_doc.original_file.name,
|
overrides.filename or input_doc.original_file.name,
|
||||||
self.request.id,
|
self.request.id,
|
||||||
) as status_mgr,
|
) as status_mgr,
|
||||||
TemporaryDirectory(dir=settings.SCRATCH_DIR) as tmp_dir,
|
TemporaryDirectory(dir=settings.SCRATCH_DIR) as tmp_dir_name,
|
||||||
):
|
):
|
||||||
tmp_dir = Path(tmp_dir)
|
tmp_dir = Path(tmp_dir_name)
|
||||||
msg = None
|
msg = None
|
||||||
for plugin_class in plugins:
|
for plugin_class in plugins:
|
||||||
plugin_name = plugin_class.NAME
|
plugin_name = plugin_class.NAME
|
||||||
@@ -261,7 +261,7 @@ def consume_file(
|
|||||||
)
|
)
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.exception(f"{plugin_name} failed: {e}")
|
logger.exception(f"{plugin_name} failed")
|
||||||
status_mgr.send_progress(
|
status_mgr.send_progress(
|
||||||
ProgressStatusOptions.FAILED,
|
ProgressStatusOptions.FAILED,
|
||||||
f"{e}",
|
f"{e}",
|
||||||
@@ -495,8 +495,8 @@ def empty_trash(doc_ids=None) -> None:
|
|||||||
content_type=ContentType.objects.get_for_model(Document),
|
content_type=ContentType.objects.get_for_model(Document),
|
||||||
object_id__in=deleted_document_ids,
|
object_id__in=deleted_document_ids,
|
||||||
).delete()
|
).delete()
|
||||||
except Exception as e: # pragma: no cover
|
except Exception: # pragma: no cover
|
||||||
logger.exception(f"Error while emptying trash: {e}")
|
logger.exception("Error while emptying trash")
|
||||||
finally:
|
finally:
|
||||||
models.signals.post_delete.disconnect(
|
models.signals.post_delete.disconnect(
|
||||||
cleanup_document_deletion,
|
cleanup_document_deletion,
|
||||||
@@ -832,9 +832,8 @@ def build_share_link_bundle(bundle_id: int) -> None:
|
|||||||
logger.info("Built share link bundle %s", bundle.pk)
|
logger.info("Built share link bundle %s", bundle.pk)
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
logger.exception(
|
logger.exception(
|
||||||
"Failed to build share link bundle %s: %s",
|
"Failed to build share link bundle %s",
|
||||||
bundle_id,
|
bundle_id,
|
||||||
exc,
|
|
||||||
)
|
)
|
||||||
bundle.status = ShareLinkBundle.Status.FAILED
|
bundle.status = ShareLinkBundle.Status.FAILED
|
||||||
bundle.last_error = {
|
bundle.last_error = {
|
||||||
|
|||||||
@@ -78,6 +78,10 @@ class PlaceholderString(str):
|
|||||||
def __ne__(self, other) -> bool:
|
def __ne__(self, other) -> bool:
|
||||||
return not self.__eq__(other)
|
return not self.__eq__(other)
|
||||||
|
|
||||||
|
def __hash__(self) -> int:
|
||||||
|
# Equal to both "-none-" and "none", so hash to a single canonical value
|
||||||
|
return hash("-none-")
|
||||||
|
|
||||||
|
|
||||||
NO_VALUE_PLACEHOLDER = PlaceholderString("-none-")
|
NO_VALUE_PLACEHOLDER = PlaceholderString("-none-")
|
||||||
|
|
||||||
@@ -340,7 +344,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 +402,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).",
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -138,9 +138,9 @@ def parse_w_workflow_placeholders(
|
|||||||
|
|
||||||
# We're good!
|
# We're good!
|
||||||
return rendered_template
|
return rendered_template
|
||||||
except UndefinedError as e:
|
except UndefinedError:
|
||||||
# The undefined class logs this already for us
|
# The undefined class logs this already for us
|
||||||
raise e
|
raise
|
||||||
except TemplateSyntaxError as e:
|
except TemplateSyntaxError as e:
|
||||||
logger.warning(f"Template syntax error in title generation: {e}")
|
logger.warning(f"Template syntax error in title generation: {e}")
|
||||||
except SecurityError as e:
|
except SecurityError as e:
|
||||||
@@ -150,5 +150,5 @@ def parse_w_workflow_placeholders(
|
|||||||
logger.warning(
|
logger.warning(
|
||||||
f"Invalid title format '{text}', workflow not applied: {e}",
|
f"Invalid title format '{text}', workflow not applied: {e}",
|
||||||
)
|
)
|
||||||
raise e
|
raise
|
||||||
return None
|
return None
|
||||||
|
|||||||
@@ -296,7 +296,7 @@ class TestRegexDateParser:
|
|||||||
|
|
||||||
# simulate parse failure for malformed input
|
# simulate parse failure for malformed input
|
||||||
if "99/99/9999" in date_string or "bad date" in date_string:
|
if "99/99/9999" in date_string or "bad date" in date_string:
|
||||||
raise Exception("parse failed for malformed date")
|
raise Exception("parse failed for malformed date") # noqa: TRY002 - simulates a generic parser failure
|
||||||
|
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|||||||
@@ -57,13 +57,13 @@ class MultiprocessCommand(PaperlessCommand):
|
|||||||
|
|
||||||
def handle(self, *args, **options):
|
def handle(self, *args, **options):
|
||||||
items = list(range(5))
|
items = list(range(5))
|
||||||
results = []
|
results = list(
|
||||||
for result in self.process_parallel(
|
self.process_parallel(
|
||||||
_double_value,
|
_double_value,
|
||||||
items,
|
items,
|
||||||
description="Processing...",
|
description="Processing...",
|
||||||
):
|
),
|
||||||
results.append(result)
|
)
|
||||||
successes = sum(1 for r in results if r.success)
|
successes = sum(1 for r in results if r.success)
|
||||||
self.stdout.write(f"Successes: {successes}")
|
self.stdout.write(f"Successes: {successes}")
|
||||||
|
|
||||||
|
|||||||
@@ -66,7 +66,7 @@ class TestWriteBatchLockRetry:
|
|||||||
)
|
)
|
||||||
mock_sleep = mocker.patch(
|
mock_sleep = mocker.patch(
|
||||||
"documents.search._backend.time.sleep",
|
"documents.search._backend.time.sleep",
|
||||||
side_effect=lambda s: sleep_values.append(s),
|
side_effect=sleep_values.append,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Should not raise — 4th attempt succeeds
|
# Should not raise — 4th attempt succeeds
|
||||||
@@ -111,7 +111,7 @@ class TestWriteBatchLockRetry:
|
|||||||
sleep_values: list[float] = []
|
sleep_values: list[float] = []
|
||||||
mocker.patch(
|
mocker.patch(
|
||||||
"documents.search._backend.time.sleep",
|
"documents.search._backend.time.sleep",
|
||||||
side_effect=lambda s: sleep_values.append(s),
|
side_effect=sleep_values.append,
|
||||||
)
|
)
|
||||||
for _ in range(50):
|
for _ in range(50):
|
||||||
sleep_values.clear()
|
sleep_values.clear()
|
||||||
|
|||||||
@@ -1063,3 +1063,79 @@ class TestApiAppConfig(DirectoriesMixin, APITestCase):
|
|||||||
)
|
)
|
||||||
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
|
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
|
||||||
self.assertIn("non-public address", str(response.data).lower())
|
self.assertIn("non-public address", str(response.data).lower())
|
||||||
|
|
||||||
|
@override_settings(REMOTE_OCR_ALLOW_INTERNAL_ENDPOINTS=False)
|
||||||
|
def test_update_remote_ocr_endpoint_blocks_internal_endpoint_when_disallowed(
|
||||||
|
self,
|
||||||
|
) -> None:
|
||||||
|
"""
|
||||||
|
GIVEN:
|
||||||
|
- Internal remote OCR endpoints are disallowed
|
||||||
|
WHEN:
|
||||||
|
- The config is updated with a remote OCR endpoint resolving internally
|
||||||
|
THEN:
|
||||||
|
- The request is rejected
|
||||||
|
"""
|
||||||
|
response = self.client.patch(
|
||||||
|
f"{self.ENDPOINT}1/",
|
||||||
|
json.dumps(
|
||||||
|
{
|
||||||
|
"remote_ocr_endpoint": "http://127.0.0.1:5000",
|
||||||
|
},
|
||||||
|
),
|
||||||
|
content_type="application/json",
|
||||||
|
)
|
||||||
|
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
|
||||||
|
self.assertIn("non-public address", str(response.data).lower())
|
||||||
|
|
||||||
|
@override_settings(REMOTE_OCR_ALLOW_INTERNAL_ENDPOINTS=True)
|
||||||
|
def test_update_remote_ocr_endpoint_allows_internal_endpoint_by_default(
|
||||||
|
self,
|
||||||
|
) -> None:
|
||||||
|
"""
|
||||||
|
GIVEN:
|
||||||
|
- Internal remote OCR endpoints are allowed (the default)
|
||||||
|
WHEN:
|
||||||
|
- The config is updated with a remote OCR endpoint resolving internally
|
||||||
|
THEN:
|
||||||
|
- The request is accepted, preserving existing self-hosted deployments
|
||||||
|
"""
|
||||||
|
response = self.client.patch(
|
||||||
|
f"{self.ENDPOINT}1/",
|
||||||
|
json.dumps(
|
||||||
|
{
|
||||||
|
"remote_ocr_endpoint": "http://127.0.0.1:5000",
|
||||||
|
},
|
||||||
|
),
|
||||||
|
content_type="application/json",
|
||||||
|
)
|
||||||
|
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||||
|
self.assertEqual(
|
||||||
|
response.data["remote_ocr_endpoint"],
|
||||||
|
"http://127.0.0.1:5000",
|
||||||
|
)
|
||||||
|
|
||||||
|
@override_settings(REMOTE_OCR_ALLOW_INTERNAL_ENDPOINTS=False)
|
||||||
|
def test_update_remote_ocr_endpoint_empty_value_skips_validation(
|
||||||
|
self,
|
||||||
|
) -> None:
|
||||||
|
"""
|
||||||
|
GIVEN:
|
||||||
|
- Internal remote OCR endpoints are disallowed
|
||||||
|
WHEN:
|
||||||
|
- The config is updated with an empty remote OCR endpoint
|
||||||
|
THEN:
|
||||||
|
- The request is accepted; clearing the field never needs
|
||||||
|
outbound URL validation
|
||||||
|
"""
|
||||||
|
response = self.client.patch(
|
||||||
|
f"{self.ENDPOINT}1/",
|
||||||
|
json.dumps(
|
||||||
|
{
|
||||||
|
"remote_ocr_endpoint": "",
|
||||||
|
},
|
||||||
|
),
|
||||||
|
content_type="application/json",
|
||||||
|
)
|
||||||
|
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||||
|
self.assertEqual(response.data["remote_ocr_endpoint"], "")
|
||||||
|
|||||||
@@ -1003,8 +1003,8 @@ class TestBulkEditAPI(DirectoriesMixin, APITestCase):
|
|||||||
for correspondent in response.data[field]:
|
for correspondent in response.data[field]:
|
||||||
self.assertEqual(correspondent["document_count"], 0)
|
self.assertEqual(correspondent["document_count"], 0)
|
||||||
self.assertCountEqual(
|
self.assertCountEqual(
|
||||||
map(lambda c: c["id"], response.data[field]),
|
(c["id"] for c in response.data[field]),
|
||||||
map(lambda c: c["id"], Entity.objects.values("id")),
|
(c["id"] for c in Entity.objects.values("id")),
|
||||||
)
|
)
|
||||||
|
|
||||||
def test_api_selection_data(self) -> None:
|
def test_api_selection_data(self) -> None:
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -18,8 +18,8 @@ class MockOpenIDProvider:
|
|||||||
|
|
||||||
def get_brands(self):
|
def get_brands(self):
|
||||||
default_servers = [
|
default_servers = [
|
||||||
dict(id="yahoo", name="Yahoo", openid_url="http://me.yahoo.com"),
|
{"id": "yahoo", "name": "Yahoo", "openid_url": "http://me.yahoo.com"},
|
||||||
dict(id="hyves", name="Hyves", openid_url="http://hyves.nl"),
|
{"id": "hyves", "name": "Hyves", "openid_url": "http://hyves.nl"},
|
||||||
]
|
]
|
||||||
return default_servers
|
return default_servers
|
||||||
|
|
||||||
|
|||||||