Compare commits

..
Author SHA1 Message Date
Trenton Holmes d8d98d31ab sort similar documents by weight, use unrestricted-user check consistently (existed before, but good to be consistent) 2026-08-31 06:11:02 -07:00
stumpylog b754d8f7e0 Not bad catches from Copilot, if a little extra secure 2026-08-31 06:11:02 -07:00
stumpylog 1d29590085 feature: add Tantivy full-text fallback adapter for taxonomy candidates
This brings users without an embedding backend configured to closer
parity with those who do.  Reuse the search backend to locate similar
documents and use them to provide the LLM with the better suggestion pool
to draw from
2026-08-31 06:11:02 -07:00
192 changed files with 42050 additions and 2918 deletions
+9 -23
View File
@@ -185,16 +185,22 @@ 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 name: "E2E Tests (${{ matrix.shard-index }}/${{ matrix.shard-count }})"
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.1-noble container: mcr.microsoft.com/playwright:v1.62.0-noble
env: env:
PLAYWRIGHT_BROWSERS_PATH: /ms-playwright PLAYWRIGHT_BROWSERS_PATH: /ms-playwright
PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD: 1 PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD: 1
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
@@ -210,17 +216,6 @@ 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:
@@ -230,17 +225,8 @@ 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 run: cd src-ui && pnpm exec playwright test --shard ${{ matrix.shard-index }}/${{ matrix.shard-count }}
frontend-build: frontend-build:
name: Frontend Build name: Frontend Build
needs: [changes, unit-tests, e2e-tests] needs: [changes, unit-tests, e2e-tests]
+1 -1
View File
@@ -61,7 +61,7 @@ def replace_with_symlinks(
total_duplicates = 0 total_duplicates = 0
space_saved = 0 space_saved = 0
for file_list in duplicate_groups.values(): for file_hash, file_list in duplicate_groups.items():
# 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:]
Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 MiB

After

Width:  |  Height:  |  Size: 1.8 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 487 KiB

After

Width:  |  Height:  |  Size: 501 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 34 KiB

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 558 KiB

After

Width:  |  Height:  |  Size: 2.2 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 MiB

After

Width:  |  Height:  |  Size: 644 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 MiB

After

Width:  |  Height:  |  Size: 667 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.2 MiB

After

Width:  |  Height:  |  Size: 1003 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 MiB

After

Width:  |  Height:  |  Size: 1.7 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.5 MiB

After

Width:  |  Height:  |  Size: 2.1 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 MiB

After

Width:  |  Height:  |  Size: 1.8 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 972 KiB

After

Width:  |  Height:  |  Size: 925 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 MiB

After

Width:  |  Height:  |  Size: 1.7 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 MiB

After

Width:  |  Height:  |  Size: 1.8 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 558 KiB

After

Width:  |  Height:  |  Size: 2.3 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.3 MiB

After

Width:  |  Height:  |  Size: 726 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 294 KiB

After

Width:  |  Height:  |  Size: 169 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 298 KiB

After

Width:  |  Height:  |  Size: 432 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 322 KiB

After

Width:  |  Height:  |  Size: 280 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 205 KiB

After

Width:  |  Height:  |  Size: 246 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 43 KiB

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 44 KiB

After

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 57 KiB

After

Width:  |  Height:  |  Size: 48 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 65 KiB

After

Width:  |  Height:  |  Size: 45 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 516 KiB

After

Width:  |  Height:  |  Size: 559 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 230 KiB

After

Width:  |  Height:  |  Size: 116 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 333 KiB

After

Width:  |  Height:  |  Size: 87 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.2 MiB

After

Width:  |  Height:  |  Size: 792 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 291 KiB

After

Width:  |  Height:  |  Size: 137 KiB

-21
View File
@@ -1,26 +1,5 @@
# Changelog # Changelog
## paperless-ngx 3.1.2
### Bug Fixes
- Fix: fix dark mode select disabled color, ensure disabled cursor on display mode dropdown [@shamoon](https://github.com/shamoon) ([#13881](https://github.com/paperless-ngx/paperless-ngx/pull/13881))
- Fix: add disable to the drag-drop list component [@shamoon](https://github.com/shamoon) ([#13880](https://github.com/paperless-ngx/paperless-ngx/pull/13880))
### Documentation
- Chore: update screenshots for v3+ [@shamoon](https://github.com/shamoon) ([#13883](https://github.com/paperless-ngx/paperless-ngx/pull/13883))
### All App Changes
<details>
<summary>2 changes</summary>
- Fix: fix dark mode select disabled color, ensure disabled cursor on display mode dropdown [@shamoon](https://github.com/shamoon) ([#13881](https://github.com/paperless-ngx/paperless-ngx/pull/13881))
- Fix: add disable to the drag-drop list component [@shamoon](https://github.com/shamoon) ([#13880](https://github.com/paperless-ngx/paperless-ngx/pull/13880))
</details>
## paperless-ngx 3.1.1 ## paperless-ngx 3.1.1
### Bug Fixes ### Bug Fixes
-6
View File
@@ -2088,12 +2088,6 @@ 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}
+2 -8
View File
@@ -224,19 +224,13 @@ respectively, can be run non-interactively with:
```bash ```bash
pnpm ng test pnpm ng test
pnpm e2e pnpm playwright test
``` ```
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 e2e:ui pnpm playwright test --ui
``` ```
### Building the frontend ### Building the frontend
+48 -101
View File
@@ -1,6 +1,6 @@
[project] [project]
name = "paperless-ngx" name = "paperless-ngx"
version = "3.1.2" version = "3.1.1"
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.6", "jinja2~=3.1.5",
"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,117 +186,64 @@ 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 = [
"ASYNC", # https://docs.astral.sh/ruff/rules/#flake8-async-async "COM", # https://docs.astral.sh/ruff/rules/#flake8-commas-com
"B002", # https://docs.astral.sh/ruff/rules/#flake8-bugbear-b "DJ", # https://docs.astral.sh/ruff/rules/#flake8-django-dj
"B003", "EXE", # https://docs.astral.sh/ruff/rules/#flake8-executable-exe
"B004", "FBT", # https://docs.astral.sh/ruff/rules/#flake8-boolean-trap-fbt
"B005", "FLY", # https://docs.astral.sh/ruff/rules/#flynt-fly
"B006", "G201", # https://docs.astral.sh/ruff/rules/#flake8-logging-format-g
"B008", "I", # https://docs.astral.sh/ruff/rules/#isort-i
"B009", "ICN", # https://docs.astral.sh/ruff/rules/#flake8-import-conventions-icn
"B010", "INP", # https://docs.astral.sh/ruff/rules/#flake8-no-pep420-inp
"B012", "ISC", # https://docs.astral.sh/ruff/rules/#flake8-implicit-str-concat-isc
"B013", "PIE", # https://docs.astral.sh/ruff/rules/#flake8-pie-pie
"B014", "PLC", # https://docs.astral.sh/ruff/rules/#pylint-pl
"B015", "PLE", # https://docs.astral.sh/ruff/rules/#pylint-pl
"B016", "PTH", # https://docs.astral.sh/ruff/rules/#flake8-use-pathlib-pth
"B017", "Q", # https://docs.astral.sh/ruff/rules/#flake8-quotes-q
"B018", "RSE", # https://docs.astral.sh/ruff/rules/#flake8-raise-rse
"B019", "RUF", # https://docs.astral.sh/ruff/rules/#ruff-specific-rules-ruf
"B020", "SIM", # https://docs.astral.sh/ruff/rules/#flake8-simplify-sim
"B021", "T20", # https://docs.astral.sh/ruff/rules/#flake8-print-t20
"B022", "TC", # https://docs.astral.sh/ruff/rules/#flake8-type-checking-tc
"B023", "TID", # https://docs.astral.sh/ruff/rules/#flake8-tidy-imports-tid
"B025", "UP", # https://docs.astral.sh/ruff/rules/#pyupgrade-up
"B026", "W", # https://docs.astral.sh/ruff/rules/#pycodestyle-e-w
"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" = [
"DTZ", "E501",
"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]
-14
View File
@@ -139,17 +139,6 @@
} }
] ]
}, },
"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"
@@ -166,9 +155,6 @@
"configurations": { "configurations": {
"production": { "production": {
"buildTarget": "paperless-ui:build:production" "buildTarget": "paperless-ui:build:production"
},
"e2e": {
"buildTarget": "paperless-ui:build:e2e"
} }
} }
}, },
+194
View File
@@ -0,0 +1,194 @@
{
"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 }
}
]
}
}
+5 -16
View File
@@ -1,25 +1,12 @@
import { expect, test } from '@playwright/test' import { expect, test } from '@playwright/test'
import path from 'node:path'
test.beforeEach(async ({ page }) => { const REQUESTS_HAR = path.join(__dirname, 'requests/api-settings.har')
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()
@@ -28,6 +15,7 @@ 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()
@@ -39,6 +27,7 @@ test('should warn on unsaved changes', async ({ page }) => {
}) })
test('should apply appearance changes when set', async ({ page }) => { test('should apply appearance changes when set', async ({ page }) => {
await page.routeFromHAR(REQUESTS_HAR, { notFound: 'fallback' })
await page.goto('/settings') await page.goto('/settings')
await expect(page.locator('html')).toHaveAttribute('data-bs-theme', /auto/) await expect(page.locator('html')).toHaveAttribute('data-bs-theme', /auto/)
await page.getByLabel('Use system setting').click() await page.getByLabel('Use system setting').click()
-237
View File
@@ -1,237 +0,0 @@
"""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()
+14 -3
View File
@@ -1,13 +1,21 @@
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=1/) await expect(page).toHaveURL(/tags__id__in=9/)
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/)
@@ -16,6 +24,7 @@ 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')
@@ -23,11 +32,12 @@ 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\/1/) await expect(page).toHaveURL(/view\/7/)
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')
@@ -36,10 +46,11 @@ 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\/1\/details/) await expect(page).toHaveURL(/documents\/310\/details/)
}) })
test('test slim sidebar', async ({ page }) => { test('test slim sidebar', async ({ page }) => {
await page.routeFromHAR(REQUESTS_HAR1, { notFound: 'fallback' })
await page.goto('/dashboard') await page.goto('/dashboard')
await page.locator('.sidebar-slim-toggler').click() await page.locator('.sidebar-slim-toggler').click()
await expect( await expect(
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -1,9 +1,14 @@
import { expect, test } from '@playwright/test' import { expect, test, type WebSocketRoute } 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.goto('/documents/1/') await page.routeFromHAR(REQUESTS_HAR, { notFound: 'fallback' })
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+/
@@ -14,7 +19,8 @@ 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.goto('/documents/1/') await page.routeFromHAR(REQUESTS_HAR, { notFound: 'fallback' })
await page.goto('/documents/175/')
await expect(page.getByTitle('Correspondent', { exact: true })).toHaveText( await expect(page.getByTitle('Correspondent', { exact: true })).toHaveText(
/\w+/ /\w+/
) )
@@ -32,27 +38,28 @@ 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.goto('/documents/1/details') await page.routeFromHAR(REQUESTS_HAR, { notFound: 'fallback' })
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/1/content') await page.goto('/documents/175/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/1/metadata') await page.goto('/documents/175/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/1/notes') await page.goto('/documents/175/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/1/permissions') await page.goto('/documents/175/permissions')
await expect(page.getByRole('tab', { name: 'Permissions' })).toHaveAttribute( await expect(page.getByRole('tab', { name: 'Permissions' })).toHaveAttribute(
'aria-selected', 'aria-selected',
'true' 'true'
@@ -60,7 +67,8 @@ 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.goto('/documents/1/') await page.routeFromHAR(REQUESTS_HAR, { notFound: 'fallback' })
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()
@@ -68,7 +76,8 @@ 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.goto('/documents/1/notes') await page.routeFromHAR(REQUESTS_HAR, { notFound: 'fallback' })
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', {
@@ -79,25 +88,32 @@ 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.goto('/documents/1/details') await page.routeFromHAR(REQUESTS_HAR2, { notFound: 'fallback' })
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( await expect(page).toHaveURL(/tags__id__all=4&sort=created&reverse=1&page=1/)
/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/1/preview/')) { if (request.url().includes('/api/documents/175/preview/')) {
previewRequestCount++ previewRequestCount++
} }
}) })
await page.goto('/documents/1/details') await page.goto('/documents/175/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(
@@ -112,37 +128,27 @@ 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
await expect const statusSocket = await statusSocketReady
.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/1/?full_perms=true') && response.url().includes('/api/documents/175/?full_perms=true') &&
response.request().method() === 'GET' response.request().method() === 'GET'
) )
await page.evaluate(() => { statusSocket.send(
const detail = document.querySelector('pngx-document-detail') JSON.stringify({
const component = (window as any).ng.getComponent(detail) type: 'document_updated',
component.handleIncomingDocumentUpdated({ data: {
document_id: 1, document_id: 175,
modified: '2099-07-26T20:00:00Z', modified: '2026-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) .toBeGreaterThan(previewRequestsBeforeReload + 1)
await expect
.poll(() =>
page.evaluate(() => {
const detail = document.querySelector('pngx-document-detail')
return (window as any).ng.getComponent(detail).previewLoaded()
})
)
.toBe(true)
}) })
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+31 -41
View File
@@ -1,10 +1,19 @@
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=1/) await expect(page).toHaveURL(/tags__id__all=9/)
await expect(page.locator('pngx-document-list')).toHaveText(/8 documents/) await expect(page.locator('pngx-document-list')).toHaveText(/8 documents/)
await page.getByRole('button', { name: 'Document type' }).click() await page.getByRole('button', { name: 'Document type' }).click()
await page.getByRole('menuitem', { name: /^Invoice Test/ }).click() await page.getByRole('menuitem', { name: /^Invoice Test/ }).click()
@@ -14,27 +23,28 @@ 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=(?:1,2|2,1)/) await expect(page).toHaveURL(/correspondent__id__in=12,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=(?:1,2|2,1)/) await expect(page).toHaveURL(/correspondent__id__none=12,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=1/) await expect(page).toHaveURL(/storage_path__id__in=5/)
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')
@@ -47,7 +57,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(/32 documents/) await expect(page.locator('pngx-document-list')).toHaveText(/26 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')
@@ -70,6 +80,7 @@ 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()
@@ -83,14 +94,15 @@ 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()
const createdFrom = page.getByRole('textbox', { name: 'mm/dd/yyyy' }).first() await page.getByRole('combobox', { name: 'Select month' }).selectOption('12')
await createdFrom.fill('12/11/2022') await page.getByRole('combobox', { name: 'Select year' }).selectOption('2022')
await createdFrom.press('Enter') await page.getByText('11', { exact: true }).click()
await page.getByRole('button', { name: 'Title & content' }).click() await page.getByRole('button', { name: 'Title & content' }).click()
await expect(page.locator('pngx-document-list')).toHaveText(/3 documents/) await expect(page.locator('pngx-document-list')).toHaveText(/2 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()
@@ -128,6 +140,7 @@ 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()
@@ -138,18 +151,12 @@ test('change views', async ({ page }) => {
}) })
test('bulk edit', async ({ page }) => { test('bulk edit', async ({ page }) => {
const uiSettingsLoaded = page.waitForResponse( await page.routeFromHAR(REQUESTS_HAR6, { notFound: 'fallback' })
(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 .doc-img-container') .locator('pngx-document-card-small')
.nth(0)
.click()
await page
.locator('pngx-document-card-small .doc-img-container')
.nth(3) .nth(3)
.click({ .click({
modifiers: ['Shift'], modifiers: ['Shift'],
@@ -169,14 +176,8 @@ test('bulk edit', async ({ page }) => {
) )
await page.getByRole('button', { name: 'None' }).click() await page.getByRole('button', { name: 'None' }).click()
await page await page.locator('pngx-document-card-small').nth(1).click()
.locator('pngx-document-card-small .doc-img-container') await page.locator('pngx-document-card-small').nth(2).click()
.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
@@ -184,26 +185,15 @@ 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 = isValid && postData['parameters']['add_tags'].includes(5)
isValid && return request.url().toString().includes('bulk_edit') && isValid
[
...postData['parameters']['add_tags'],
...postData['parameters']['remove_tags'],
].includes(3)
return isValid
}) })
await page.getByRole('button', { name: 'Apply' }).click()
await expect(page.getByRole('button', { name: 'Confirm' })).toBeVisible()
await page.getByRole('button', { name: 'Confirm' }).click() await page.getByRole('button', { name: 'Confirm' }).click()
await bulkEditPromise await bulkEditPromise
}) })
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -1,12 +1,10 @@
import { expect, test } from '@playwright/test' import { expect, test } from '@playwright/test'
import path from 'node:path'
test.use({ const REQUESTS_HAR = path.join(__dirname, 'requests/api-global-permissions.har')
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')
@@ -16,6 +14,7 @@ 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' })
@@ -31,6 +30,7 @@ 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,6 +42,7 @@ 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' })
@@ -53,6 +54,7 @@ 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' })
@@ -64,6 +66,7 @@ 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' })
@@ -75,6 +78,7 @@ 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')
@@ -84,6 +88,7 @@ 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')
@@ -0,0 +1,353 @@
{
"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 -5
View File
@@ -1,15 +1,11 @@
{ {
"name": "paperless-ngx-ui", "name": "paperless-ngx-ui",
"version": "3.1.2", "version": "3.1.1",
"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"
}, },
+9 -17
View File
@@ -14,25 +14,17 @@ 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,
/* Keep parallelism modest for the shared SQLite backend. */ /* Opt out of parallel tests on CI. */
workers: 2, workers: process.env.CI ? 1 : undefined,
/* 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 the disposable backend and local UI before starting the tests. */ /* Run your local dev server before starting the tests */
webServer: [ webServer: {
{ port,
url: 'http://localhost:8001/accounts/login/', command: 'pnpm run start',
command: 'npm run e2e:backend', reuseExistingServer: !process.env.CI,
reuseExistingServer: false, timeout: 2 * 60 * 1000,
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 app-link" cdkDrag [cdkDragDisabled]="!settingsService.organizingSidebarSavedViews() || !canSaveSettings" <li class="nav-item w-100 app-link" cdkDrag [cdkDragDisabled]="!settingsService.organizingSidebarSavedViews() || !canSaveSettings"
cdkDragPreviewContainer="parent" cdkDragPreviewClass="navItemDrag" (cdkDragStarted)="onDragStart($event)" cdkDragPreviewContainer="parent" cdkDragPreviewClass="navItemDrag" (cdkDragStarted)="onDragStart($event)"
(cdkDragEnded)="onDragEnd($event)"> (cdkDragEnded)="onDragEnd($event)">
<a class="nav-link" routerLink="view/{{view.id}}" <a class="nav-link" routerLink="view/{{view.id}}"
@@ -128,7 +128,7 @@
} }
</a> </a>
@if (settingsService.organizingSidebarSavedViews() && canSaveSettings) { @if (settingsService.organizingSidebarSavedViews() && canSaveSettings) {
<div class="position-absolute end-0 top-0 px-1 py-2" [class.me-n2]="slimSidebarEnabled" cdkDragHandle> <div class="position-absolute end-0 top-0 px-3 py-2" [class.me-n3]="slimSidebarEnabled" cdkDragHandle>
<i-bs name="grip-vertical"></i-bs> <i-bs name="grip-vertical"></i-bs>
</div> </div>
} }
@@ -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-2"> <div class="me-3">
<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 d-flex align-items-center"> <div class="version-check">
<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" [ngClass]="!editing && isActive ? 'btn-primary' : 'btn-outline-primary'" id="dropdown_toggle" ngbDropdownToggle [disabled]="disabled" [aria-label]="title"> <button class="btn btn-sm 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,6 +1,5 @@
import { import {
getLocaleNumberSymbol, getLocaleNumberSymbol,
NgClass,
NgTemplateOutlet, NgTemplateOutlet,
NumberSymbol, NumberSymbol,
} from '@angular/common' } from '@angular/common'
@@ -49,26 +48,25 @@ 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 readonly _queries = signal<CustomFieldQueryElement[]>([]) private _queries: 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()
const queries = value ?? [] this._queries = value ?? []
for (const element of queries) { for (const element of this._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) {
@@ -211,7 +209,6 @@ export class CustomFieldQueriesModel {
DocumentLinkComponent, DocumentLinkComponent,
ReactiveFormsModule, ReactiveFormsModule,
NgbDatepickerModule, NgbDatepickerModule,
NgClass,
NgTemplateOutlet, NgTemplateOutlet,
NgSelectModule, NgSelectModule,
NgxBootstrapIconsModule, NgxBootstrapIconsModule,
@@ -3,7 +3,6 @@
<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) {
@@ -18,7 +17,6 @@
<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,11 +1,3 @@
.badge { .badge {
cursor: move; cursor: move;
} }
.cdk-drop-list-disabled {
cursor: not-allowed !important;
* {
pointer-events: none !important;
}
}
@@ -98,29 +98,4 @@ 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,8 +46,6 @@ 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 bg-transparent border-0" i18n>Select:</span> <span class="input-group-text border-0" i18n>Select:</span>
</div> </div>
<div class="btn-group btn-group-sm flex-nowrap"> <div class="btn-group btn-group-sm flex-nowrap">
@if (list.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 rounded shadow-sm"> <div class="row sticky-top py-3 mt-n2 mt-md-n3 bg-body">
<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,49 +1034,6 @@ 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 form-control" formControlName="display_mode"> <select class="form-select" 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>
@@ -1,13 +0,0 @@
export const DEFAULT_APP_TITLE = 'Paperless-ngx'
export const environment = {
production: false,
apiBaseUrl: 'http://localhost:8001/api/',
apiVersion: '10',
appTitle: DEFAULT_APP_TITLE,
tag: 'e2e',
version: 'E2E',
webSocketHost: 'localhost:8001',
webSocketProtocol: 'ws:',
webSocketBaseUrl: '/ws/',
}
+1 -1
View File
@@ -8,7 +8,7 @@ export const environment = {
apiVersion: '10', // match src/paperless/settings.py apiVersion: '10', // match src/paperless/settings.py
appTitle: DEFAULT_APP_TITLE, appTitle: DEFAULT_APP_TITLE,
tag: 'prod', tag: 'prod',
version: '3.1.2', version: '3.1.1',
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/',
+16 -5
View File
@@ -47,8 +47,6 @@ $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};
} }
@@ -80,12 +78,19 @@ body {
} }
.btn { .btn {
--bs-border-radius-sm: #{$border-radius}; --bs-btn-border-radius: .425rem;
--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: #{$border-radius}; --bs-border-radius-sm: .425rem;
} }
@media(min-width: 768px) { @media(min-width: 768px) {
@@ -684,6 +689,10 @@ 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);
@@ -815,6 +824,8 @@ 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 {
@@ -838,7 +849,7 @@ code {
} }
/* Animate items as they're being sorted. */ /* Animate items as they're being sorted. */
.cdk-drop-list-dragging .cdk-drag:not(.cdk-drag-preview) { .cdk-drop-list-dragging .cdk-drag {
transition: transform 250ms cubic-bezier(0, 0, 0.2, 1); transition: transform 250ms cubic-bezier(0, 0, 0.2, 1);
} }
-3
View File
@@ -103,8 +103,6 @@ $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);
@@ -113,7 +111,6 @@ $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;
+10 -10
View File
@@ -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: except Exception as e:
logger.exception(f"Error rotating document {pair.root_doc.id}") logger.exception(f"Error rotating document {pair.root_doc.id}: {e}")
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: except Exception as e:
logger.exception( logger.exception(
f"Error merging document {doc.id}, it will not be included in the merge", f"Error merging document {doc.id}, it will not be included in the merge: {e}",
) )
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: except Exception as e:
logger.exception(f"Error splitting document {doc.id}") logger.exception(f"Error splitting document {doc.id}: {e}")
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: except Exception as e:
logger.exception(f"Error deleting pages from document {pair.root_doc.id}") logger.exception(f"Error deleting pages from document {pair.root_doc.id}: {e}")
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}") logger.exception(f"Error editing document {pair.root_doc.id}: {e}")
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}", f"Error removing password from document {pair.root_doc.id}: {e}",
) )
raise ValueError( raise ValueError(
f"An error occurred while removing the password: {e}", f"An error occurred while removing the password: {e}",
+3 -8
View File
@@ -16,9 +16,6 @@ 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
@@ -121,11 +118,9 @@ 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 = (
signed_pickle_loads(serialized_data) pickle.loads(serialized_data) if serialized_data else OrderedDict()
if serialized_data
else OrderedDict()
) )
except (SignedPickleError, pickle.PickleError): except 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)",
) )
@@ -137,7 +132,7 @@ class StoredLRUCache(LRUCache):
""" """
self._backend.set( self._backend.set(
self._backend_key, self._backend_key,
signed_pickle_dumps(self._data), pickle.dumps(self._data),
self.backend_ttl, self.backend_ttl,
) )
+10 -20
View File
@@ -28,9 +28,6 @@ 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")
@@ -72,8 +69,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 raise e
except ClassifierModelCorruptError: except ClassifierModelCorruptError as e:
# 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 "
@@ -82,17 +79,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 raise e
except OSError: except OSError as e:
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 raise e
except Exception: # pragma: no cover except Exception as e: # 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 raise e
return classifier return classifier
@@ -530,17 +527,10 @@ 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, signed_pickle_dumps(result), CACHE_5_MINUTES) read_cache.set(key, pickle.dumps(result), CACHE_5_MINUTES)
else: else:
try: read_cache.touch(key, CACHE_5_MINUTES)
result = signed_pickle_loads(serialized_result) result = 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:
+6 -13
View File
@@ -25,7 +25,6 @@ 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
@@ -217,7 +216,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 or None, owner_id=self.metadata.owner_id if self.metadata.owner_id else 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 [])
@@ -675,7 +674,9 @@ 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 or self.working_copy, original_file=self.unmodified_original
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
@@ -694,10 +695,6 @@ 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(
@@ -730,10 +727,6 @@ 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,
@@ -856,7 +849,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), # noqa: DTZ006 - make_aware() requires a naive datetime datetime.datetime.fromtimestamp(stats.st_mtime),
) )
self.log.debug(f"Creation date from st_mtime: {create_date}") self.log.debug(f"Creation date from st_mtime: {create_date}")
@@ -970,7 +963,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
self.log.debug("Unable to copy file stats from %s to %s", source, target) pass
class ConsumerPreflightPlugin( class ConsumerPreflightPlugin(
+2 -4
View File
@@ -78,9 +78,7 @@ 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 ( if (dt.datetime.now().timestamp() - stats.st_mtime) > TIMEOUT_SECONDS:
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:
@@ -136,7 +134,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(tz=dt.UTC).timestamp() timestamp = dt.datetime.now().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",
-29
View File
@@ -1,33 +1,12 @@
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)
@@ -142,14 +121,6 @@ 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
+1 -1
View File
@@ -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) - {link.document_id for link in links} missing_ids = set(value) - set(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,25 +631,23 @@ class Command(BaseCommand):
): ):
# Process each change # Process each change
for change_type, path in changes: for change_type, path in changes:
resolved_path = Path(path).resolve() 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(resolved_path) queued.discard(path)
if not resolved_path.is_file(): if not path.is_file():
continue continue
if resolved_path in queued: if 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( logger.debug(f"Ignoring event for queued file: {path}")
f"Ignoring event for queued file: {resolved_path}",
)
continue continue
logger.debug(f"Event: {change_type.name} for {resolved_path}") logger.debug(f"Event: {change_type.name} for {path}")
tracker.track(resolved_path, change_type) tracker.track(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():
+1 -7
View File
@@ -30,10 +30,6 @@ 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,
@@ -695,9 +691,7 @@ 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 UnsupportedWorkflowTriggerTypeError( raise Exception(f"Trigger type {trigger_type} not yet supported")
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 # noqa: SIM113 processed += 1
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"])
+2 -6
View File
@@ -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 self.effective_content return getattr(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,11 +462,7 @@ 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.
""" """
# Root owns metadata for all versions result = str(self)
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}"
+2 -2
View File
@@ -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 or "" return ext if ext else ""
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: %s", " ".join(args), extra={"group": logging_group}) logger.debug("Execute: " + " ".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: except Exception as e:
logger.exception(f"Unable to load date parser plugin {ep.name}") logger.exception(f"Unable to load date parser plugin {ep.name}: {e}")
if not valid_plugins: if not valid_plugins:
return RegexDateParserPlugin return RegexDateParserPlugin
+2 -2
View File
@@ -91,8 +91,8 @@ class DateParserPluginBase(ABC):
}, },
locales=self.config.languages, locales=self.config.languages,
) )
except Exception: except Exception as e:
logger.exception(f"Error while parsing date string '{date_string}'") logger.exception(f"Error while parsing date string '{date_string}': {e}")
return None return None
def _filter_date( def _filter_date(
+6 -4
View File
@@ -59,10 +59,11 @@ 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): except (regex.error, ValueError) as exc:
logger.exception( logger.exception(
"Error while processing regular expression %s", "Error while processing regular expression %s: %s",
textwrap.shorten(pattern, width=80, placeholder=""), textwrap.shorten(pattern, width=80, placeholder=""),
exc,
) )
return None return None
@@ -85,10 +86,11 @@ 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): except (regex.error, ValueError) as exc:
logger.exception( logger.exception(
"Error while processing regular expression %s", "Error while processing regular expression %s: %s",
textwrap.shorten(pattern, width=80, placeholder=""), textwrap.shorten(pattern, width=80, placeholder=""),
exc,
) )
return None return None
+2 -2
View File
@@ -1142,7 +1142,7 @@ def get_backend() -> TantivyBackend:
Returns: Returns:
Thread-safe singleton TantivyBackend instance Thread-safe singleton TantivyBackend instance
""" """
global _backend, _backend_path # noqa: PLW0603 - module-level singleton, no class to hold this state global _backend, _backend_path
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 # noqa: PLW0603 - module-level singleton, no class to hold this state global _backend, _backend_path
with _backend_lock: with _backend_lock:
if _backend is not None: if _backend is not None:
+1 -1
View File
@@ -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=dict.fromkeys(DEFAULT_SEARCH_FIELDS, (True, 1, True)), fuzzy_fields={f: (True, 1, True) for f in DEFAULT_SEARCH_FIELDS},
) )
# 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)))
+12 -14
View File
@@ -81,7 +81,6 @@ 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
@@ -434,7 +433,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 = [obj.pk for obj in objects] object_pks = list(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):
@@ -662,8 +661,6 @@ 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 = (
@@ -730,7 +727,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 raise e
finally: finally:
self.instance.tn_parent = original_parent self.instance.tn_parent = original_parent
else: else:
@@ -740,7 +737,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 raise e
return super().validate(attrs) return super().validate(attrs)
@@ -1150,7 +1147,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"] = instance.effective_content or "" doc["content"] = getattr(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
@@ -1860,8 +1857,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: except Exception as e:
logger.exception("Error validating custom fields") logger.exception(f"Error validating custom fields: {e}")
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",
) )
@@ -2059,12 +2056,13 @@ class BulkEditSerializer(
for doc in docs: for doc in docs:
if "-" in doc: if "-" in doc:
pages.append( pages.append(
list( [
range( x
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)])
@@ -2925,7 +2923,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 obj.document_total or obj.documents.count() return getattr(obj, "document_total") or obj.documents.count()
class BulkEditObjectsSerializer(SerializerWithPerms, SetPermissionsMixin): class BulkEditObjectsSerializer(SerializerWithPerms, SetPermissionsMixin):
+4 -5
View File
@@ -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.
logger.exception("Error reverting document changes") pass
# restore old values on the instance # restore old values on the instance
instance.filename = old_filename instance.filename = old_filename
@@ -1102,11 +1102,10 @@ 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):
override_dict[k] = v.isoformat() v = v.isoformat()
elif isinstance(v, Path): elif isinstance(v, Path):
override_dict[k] = str(v) v = str(v)
else: override_dict[k] = v
override_dict[k] = v
if override_dict: if override_dict:
data["overrides"] = override_dict data["overrides"] = override_dict
return data return data
+7 -6
View File
@@ -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_name, TemporaryDirectory(dir=settings.SCRATCH_DIR) as tmp_dir,
): ):
tmp_dir = Path(tmp_dir_name) tmp_dir = Path(tmp_dir)
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") logger.exception(f"{plugin_name} failed: {e}")
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: # pragma: no cover except Exception as e: # pragma: no cover
logger.exception("Error while emptying trash") logger.exception(f"Error while emptying trash: {e}")
finally: finally:
models.signals.post_delete.disconnect( models.signals.post_delete.disconnect(
cleanup_document_deletion, cleanup_document_deletion,
@@ -832,8 +832,9 @@ 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", "Failed to build share link bundle %s: %s",
bundle_id, bundle_id,
exc,
) )
bundle.status = ShareLinkBundle.Status.FAILED bundle.status = ShareLinkBundle.Status.FAILED
bundle.last_error = { bundle.last_error = {
+2 -6
View File
@@ -78,10 +78,6 @@ 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-")
@@ -344,7 +340,7 @@ def get_custom_fields_context(
return field_data return field_data
def is_safe_relative_path(value: str) -> bool: def _is_safe_relative_path(value: str) -> bool:
if value == "": if value == "":
return True return True
@@ -402,7 +398,7 @@ def validate_filepath_template_and_render(
) )
rendered_template = template.render(context) rendered_template = template.render(context)
if not is_safe_relative_path(rendered_template): if not _is_safe_relative_path(rendered_template):
logger.warning( logger.warning(
"Template rendered an unsafe path (absolute or containing traversal).", "Template rendered an unsafe path (absolute or containing traversal).",
) )
+3 -3
View File
@@ -138,9 +138,9 @@ def parse_w_workflow_placeholders(
# We're good! # We're good!
return rendered_template return rendered_template
except UndefinedError: except UndefinedError as e:
# The undefined class logs this already for us # The undefined class logs this already for us
raise raise e
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 raise e
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") # noqa: TRY002 - simulates a generic parser failure raise Exception("parse failed for malformed date")
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 = list( results = []
self.process_parallel( for result in 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=sleep_values.append, side_effect=lambda s: sleep_values.append(s),
) )
# 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=sleep_values.append, side_effect=lambda s: sleep_values.append(s),
) )
for _ in range(50): for _ in range(50):
sleep_values.clear() sleep_values.clear()
@@ -1063,79 +1063,3 @@ 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"], "")
+2 -2
View File
@@ -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(
(c["id"] for c in response.data[field]), map(lambda c: c["id"], response.data[field]),
(c["id"] for c in Entity.objects.values("id")), map(lambda c: c["id"], Entity.objects.values("id")),
) )
def test_api_selection_data(self) -> None: def test_api_selection_data(self) -> None:
-27
View File
@@ -102,7 +102,6 @@ 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(
@@ -146,32 +145,6 @@ class TestApiObjects(DirectoriesMixin, APITestCase):
response.data["last_correspondence"], response.data["last_correspondence"],
) )
# A newer document owned by another user must not leak through the
# aggregate for a non-superuser who cannot view it
other = User.objects.create_user(username="other")
Document.objects.create(
mime_type="application/pdf",
correspondent=self.c1,
created=datetime.date(2023, 6, 1),
checksum="hidden",
owner=other,
)
user = User.objects.create_user(username="regular")
user.user_permissions.add(
Permission.objects.get(codename="view_correspondent"),
)
self.client.force_authenticate(user=user)
response = self.client.get("/api/correspondents/?last_correspondence=true")
self.assertEqual(response.status_code, status.HTTP_200_OK)
result = next(r for r in response.data["results"] if r["id"] == self.c1.id)
self.assertIn("2022-01-02", result["last_correspondence"])
response = self.client.get(f"/api/correspondents/{self.c1.id}/")
self.assertEqual(response.status_code, status.HTTP_200_OK)
self.assertIn("2022-01-02", response.data["last_correspondence"])
def test_paginated_objects_include_all_only_for_legacy_version(self) -> None: def test_paginated_objects_include_all_only_for_legacy_version(self) -> None:
response_v10 = self.client.get("/api/correspondents/") response_v10 = self.client.get("/api/correspondents/")
self.assertEqual(response_v10.status_code, status.HTTP_200_OK) self.assertEqual(response_v10.status_code, status.HTTP_200_OK)
+2 -2
View File
@@ -18,8 +18,8 @@ class MockOpenIDProvider:
def get_brands(self): def get_brands(self):
default_servers = [ default_servers = [
{"id": "yahoo", "name": "Yahoo", "openid_url": "http://me.yahoo.com"}, dict(id="yahoo", name="Yahoo", openid_url="http://me.yahoo.com"),
{"id": "hyves", "name": "Hyves", "openid_url": "http://hyves.nl"}, dict(id="hyves", name="Hyves", openid_url="http://hyves.nl"),
] ]
return default_servers return default_servers

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