mirror of
https://github.com/paperless-ngx/paperless-ngx.git
synced 2026-09-26 19:30:31 +00:00
Compare commits
48
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bd3fa68e14 | ||
|
|
cd12c34d8d | ||
|
|
d57895ec11 | ||
|
|
5d0dd4e9f7 | ||
|
|
a5c35d80fa | ||
|
|
19e352db71 | ||
|
|
548c2c643c | ||
|
|
a72c6c6a92 | ||
|
|
5e30ab5e7e | ||
|
|
f57427dde2 | ||
|
|
2a44d8b5ba | ||
|
|
abf5050ea7 | ||
|
|
091ddf7c45 | ||
|
|
c9f7f2cfbe | ||
|
|
b457610ffb | ||
|
|
969c2ea0e2 | ||
|
|
31b806a285 | ||
|
|
99851b418c | ||
|
|
e34eda07bb | ||
|
|
793459b416 | ||
|
|
04297fd02c | ||
|
|
1b277dd8e1 | ||
|
|
3c20abeb4c | ||
|
|
b11f1f8459 | ||
|
|
7424e7ce0b | ||
|
|
a53a3d3769 | ||
|
|
15b73b890c | ||
|
|
02d355061f | ||
|
|
d8b5b4d447 | ||
|
|
03ac4aed7e | ||
|
|
90d23bad9c | ||
|
|
e4367b5648 | ||
|
|
cb85441c2f | ||
|
|
cceaa559d4 | ||
|
|
a748d4c64f | ||
|
|
452ed005bd | ||
|
|
40058ff7d5 | ||
|
|
f502cd5e34 | ||
|
|
99ce6b5db3 | ||
|
|
2d955e9697 | ||
|
|
f440e8d33c | ||
|
|
4c264651e8 | ||
|
|
48d97b78bb | ||
|
|
659a0cb2ef | ||
|
|
d02d1e1711 | ||
|
|
3b41810e7b | ||
|
|
12314fcaa8 | ||
|
|
8705bd510a |
@@ -15,6 +15,8 @@
|
|||||||
# Test related
|
# Test related
|
||||||
**/.pytest_cache
|
**/.pytest_cache
|
||||||
**/tests
|
**/tests
|
||||||
|
src/paperless_testing
|
||||||
|
src/conftest.py
|
||||||
**/*.spec.ts
|
**/*.spec.ts
|
||||||
**/htmlcov
|
**/htmlcov
|
||||||
# Local folders
|
# Local folders
|
||||||
|
|||||||
@@ -50,12 +50,12 @@ repos:
|
|||||||
- 'prettier-plugin-organize-imports@4.3.0'
|
- 'prettier-plugin-organize-imports@4.3.0'
|
||||||
# Python hooks
|
# Python hooks
|
||||||
- repo: https://github.com/astral-sh/ruff-pre-commit
|
- repo: https://github.com/astral-sh/ruff-pre-commit
|
||||||
rev: v0.16.5
|
rev: v0.16.7
|
||||||
hooks:
|
hooks:
|
||||||
- id: ruff-check
|
- id: ruff-check
|
||||||
- id: ruff-format
|
- id: ruff-format
|
||||||
- repo: https://github.com/tox-dev/pyproject-fmt
|
- repo: https://github.com/tox-dev/pyproject-fmt
|
||||||
rev: "v2.28.1"
|
rev: "v2.29.4"
|
||||||
hooks:
|
hooks:
|
||||||
- id: pyproject-fmt
|
- id: pyproject-fmt
|
||||||
additional_dependencies: [tomli]
|
additional_dependencies: [tomli]
|
||||||
|
|||||||
+14
-9
@@ -136,13 +136,15 @@ for suggested generation and embedding models.
|
|||||||
### AI-assisted suggestions
|
### AI-assisted suggestions
|
||||||
|
|
||||||
With AI enabled, Paperless-ngx can suggest a title, tags, correspondent, document type,
|
With AI enabled, Paperless-ngx can suggest a title, tags, correspondent, document type,
|
||||||
storage path and dates by sending the document to the LLM. This is **opt-in per request**
|
storage path and dates by sending the document to the LLM using "Suggest" button on the document
|
||||||
and surfaces through the "Suggest" control on the document detail page, alongside the
|
detail page. You can choose which type of suggestions are requested by default under Settings >
|
||||||
classic classifier-based suggestions — it does not disable them. Suggestions are requested
|
Documents, either ML (classifier-based) suggestions, AI suggestions, or both. When both are requested
|
||||||
automatically when you open a document that carries an inbox tag unless "Automatically request
|
the results are combined.
|
||||||
suggestions for inbox documents" under Settings > Documents is disabled. Suggestion output
|
|
||||||
language can be steered with
|
Suggestions are requested automatically when you open a document that carries an inbox tag
|
||||||
[`PAPERLESS_AI_LLM_OUTPUT_LANGUAGE`](configuration.md#PAPERLESS_AI_LLM_OUTPUT_LANGUAGE)
|
unless "Automatically request suggestions for inbox documents" under Settings > Documents is disabled.
|
||||||
|
|
||||||
|
Suggestion output language can be steered with [`PAPERLESS_AI_LLM_OUTPUT_LANGUAGE`](configuration.md#PAPERLESS_AI_LLM_OUTPUT_LANGUAGE)
|
||||||
(otherwise it follows the user's UI language).
|
(otherwise it follows the user's UI language).
|
||||||
|
|
||||||
### The LLM index (RAG) and similar documents
|
### The LLM index (RAG) and similar documents
|
||||||
@@ -153,8 +155,11 @@ in similar existing documents, and the document chat can retrieve relevant conte
|
|||||||
|
|
||||||
Enable it by setting
|
Enable it by setting
|
||||||
[`PAPERLESS_AI_LLM_EMBEDDING_BACKEND`](configuration.md#PAPERLESS_AI_LLM_EMBEDDING_BACKEND)
|
[`PAPERLESS_AI_LLM_EMBEDDING_BACKEND`](configuration.md#PAPERLESS_AI_LLM_EMBEDDING_BACKEND)
|
||||||
(`huggingface` for fully-local embeddings, or `ollama` / `openai-like`). The index is only
|
(`huggingface` for fully-local embeddings, or `ollama` / `openai-like`). By default, the main
|
||||||
built when AI is enabled **and** an embedding backend is set.
|
LLM API key and endpoint are used, but an optional embedding-specific[API key](configuration.md#PAPERLESS_AI_LLM_EMBEDDING_API_KEY)
|
||||||
|
and [endpoint](configuration.md#PAPERLESS_AI_LLM_EMBEDDING_ENDPOINT) can be configured.
|
||||||
|
|
||||||
|
The index is only built when AI is enabled **and** an embedding backend is set.
|
||||||
|
|
||||||
The index is updated automatically on a schedule controlled by
|
The index is updated automatically on a schedule controlled by
|
||||||
[`PAPERLESS_LLM_INDEX_TASK_CRON`](configuration.md#PAPERLESS_LLM_INDEX_TASK_CRON) (daily by
|
[`PAPERLESS_LLM_INDEX_TASK_CRON`](configuration.md#PAPERLESS_LLM_INDEX_TASK_CRON) (daily by
|
||||||
|
|||||||
@@ -1,5 +1,30 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
## paperless-ngx 3.2.1
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
- Fix: only pass --conf to flower when flowerconfig.py exists [@bitfoo1](https://github.com/bitfoo1) ([#14182](https://github.com/paperless-ngx/paperless-ngx/pull/14182))
|
||||||
|
- Fix: replace stale mail-fetch overlap check with a self-expiring lock [@stumpylog](https://github.com/stumpylog) ([#14189](https://github.com/paperless-ngx/paperless-ngx/pull/14189))
|
||||||
|
- Fix: bump ocrmypdf to 17.12 to pick up the ligature text-layer fix [@stumpylog](https://github.com/stumpylog) ([#14190](https://github.com/paperless-ngx/paperless-ngx/pull/14190))
|
||||||
|
- Fix: rebuild the search index automatically when it is missing Tantivy files [@stumpylog](https://github.com/stumpylog) ([#14180](https://github.com/paperless-ngx/paperless-ngx/pull/14180))
|
||||||
|
|
||||||
|
### Dependencies
|
||||||
|
|
||||||
|
- Chore(deps): Bump anyio from 4.12.1 to 4.14.2 in the uv group across 1 directory @[dependabot[bot]](https://github.com/apps/dependabot) ([#14175](https://github.com/paperless-ngx/paperless-ngx/pull/14175))
|
||||||
|
|
||||||
|
### All App Changes
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary>4 changes</summary>
|
||||||
|
|
||||||
|
- Chore(deps): Bump anyio from 4.12.1 to 4.14.2 in the uv group across 1 directory @[dependabot[bot]](https://github.com/apps/dependabot) ([#14175](https://github.com/paperless-ngx/paperless-ngx/pull/14175))
|
||||||
|
- Fix: replace stale mail-fetch overlap check with a self-expiring lock [@stumpylog](https://github.com/stumpylog) ([#14189](https://github.com/paperless-ngx/paperless-ngx/pull/14189))
|
||||||
|
- Fix: bump ocrmypdf to 17.12 to pick up the ligature text-layer fix [@stumpylog](https://github.com/stumpylog) ([#14190](https://github.com/paperless-ngx/paperless-ngx/pull/14190))
|
||||||
|
- Fix: rebuild the search index automatically when it is missing Tantivy files [@stumpylog](https://github.com/stumpylog) ([#14180](https://github.com/paperless-ngx/paperless-ngx/pull/14180))
|
||||||
|
|
||||||
|
</details>
|
||||||
|
|
||||||
## paperless-ngx 3.2.0
|
## paperless-ngx 3.2.0
|
||||||
|
|
||||||
### Features / Enhancements
|
### Features / Enhancements
|
||||||
|
|||||||
@@ -2133,6 +2133,13 @@ for language and resource considerations.
|
|||||||
|
|
||||||
Defaults to None.
|
Defaults to None.
|
||||||
|
|
||||||
|
#### [`PAPERLESS_AI_LLM_EMBEDDING_API_KEY=<str>`](#PAPERLESS_AI_LLM_EMBEDDING_API_KEY) {#PAPERLESS_AI_LLM_EMBEDDING_API_KEY}
|
||||||
|
|
||||||
|
: The API key to use for the embedding backend. If not supplied, embeddings use
|
||||||
|
`PAPERLESS_AI_LLM_API_KEY`.
|
||||||
|
|
||||||
|
Defaults to None.
|
||||||
|
|
||||||
#### [`PAPERLESS_AI_LLM_EMBEDDING_ENDPOINT=<str>`](#PAPERLESS_AI_LLM_EMBEDDING_ENDPOINT) {#PAPERLESS_AI_LLM_EMBEDDING_ENDPOINT}
|
#### [`PAPERLESS_AI_LLM_EMBEDDING_ENDPOINT=<str>`](#PAPERLESS_AI_LLM_EMBEDDING_ENDPOINT) {#PAPERLESS_AI_LLM_EMBEDDING_ENDPOINT}
|
||||||
|
|
||||||
: The endpoint / url to use for the embedding backend. If not supplied, embeddings use
|
: The endpoint / url to use for the embedding backend. If not supplied, embeddings use
|
||||||
@@ -2217,6 +2224,19 @@ used with the OpenAI-compatible backend to target a custom provider or local gat
|
|||||||
|
|
||||||
Defaults to true, which allows internal endpoints.
|
Defaults to true, which allows internal endpoints.
|
||||||
|
|
||||||
|
#### [`PAPERLESS_AI_LLM_EXTRA_PARAMS=<json>`](#PAPERLESS_AI_LLM_EXTRA_PARAMS) {#PAPERLESS_AI_LLM_EXTRA_PARAMS}
|
||||||
|
|
||||||
|
: A JSON object of extra parameters sent with every LLM request, for providers that require a parameter Paperless does not
|
||||||
|
set itself. Values here override Paperless' own, and no validation is performed. Whatever you put here is passed to the
|
||||||
|
backend as-is, so an invalid parameter will simply be rejected by your provider. For example, current OpenAI reasoning
|
||||||
|
models refuse tool calls on the chat completions API unless reasoning is off:
|
||||||
|
|
||||||
|
```
|
||||||
|
PAPERLESS_AI_LLM_EXTRA_PARAMS={"reasoning_effort": "none"}
|
||||||
|
```
|
||||||
|
|
||||||
|
Defaults to empty, which adds nothing to requests.
|
||||||
|
|
||||||
#### [`PAPERLESS_LLM_INDEX_TASK_CRON=<cron expression>`](#PAPERLESS_LLM_INDEX_TASK_CRON) {#PAPERLESS_LLM_INDEX_TASK_CRON}
|
#### [`PAPERLESS_LLM_INDEX_TASK_CRON=<cron expression>`](#PAPERLESS_LLM_INDEX_TASK_CRON) {#PAPERLESS_LLM_INDEX_TASK_CRON}
|
||||||
|
|
||||||
: Configures the schedule to update the AI embeddings of text content and metadata for all documents. Only performed if
|
: Configures the schedule to update the AI embeddings of text content and metadata for all documents. Only performed if
|
||||||
|
|||||||
@@ -150,6 +150,7 @@ pnpm ng build --configuration production
|
|||||||
is loaded as well. However, the tests rely on the default
|
is loaded as well. However, the tests rely on the default
|
||||||
configuration. This is not ideal. But for now, make sure no settings
|
configuration. This is not ideal. But for now, make sure no settings
|
||||||
except for DEBUG are overridden when testing.
|
except for DEBUG are overridden when testing.
|
||||||
|
- Tests run in a random order each session, so that one test cannot quietly depend on another having run first. The seed is printed at the top of the run; pass `--randomly-seed=<seed>` to replay that exact order, or `--randomly-seed=last` to repeat the previous run.
|
||||||
|
|
||||||
!!! note
|
!!! note
|
||||||
|
|
||||||
|
|||||||
+16
-5
@@ -1,7 +1,9 @@
|
|||||||
[project]
|
[project]
|
||||||
name = "paperless-ngx"
|
name = "paperless-ngx"
|
||||||
version = "3.2.1"
|
version = "3.2.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"
|
||||||
classifiers = [
|
classifiers = [
|
||||||
@@ -10,6 +12,7 @@ classifiers = [
|
|||||||
"Programming Language :: Python :: 3.12",
|
"Programming Language :: Python :: 3.12",
|
||||||
"Programming Language :: Python :: 3.13",
|
"Programming Language :: Python :: 3.13",
|
||||||
"Programming Language :: Python :: 3.14",
|
"Programming Language :: Python :: 3.14",
|
||||||
|
"Programming Language :: Python :: 3.15",
|
||||||
]
|
]
|
||||||
# TODO: Move certain things to groups and then utilize that further
|
# TODO: Move certain things to groups and then utilize that further
|
||||||
# This will allow testing to not install a webserver, mysql, etc
|
# This will allow testing to not install a webserver, mysql, etc
|
||||||
@@ -117,7 +120,7 @@ testing = [
|
|||||||
"pytest-env~=1.7.0",
|
"pytest-env~=1.7.0",
|
||||||
"pytest-httpx",
|
"pytest-httpx",
|
||||||
"pytest-mock~=3.15.1",
|
"pytest-mock~=3.15.1",
|
||||||
# "pytest-randomly~=4.0.1",
|
"pytest-randomly~=5.0.0",
|
||||||
"pytest-rerunfailures~=16.4",
|
"pytest-rerunfailures~=16.4",
|
||||||
"pytest-sugar",
|
"pytest-sugar",
|
||||||
"pytest-xdist~=3.8.0",
|
"pytest-xdist~=3.8.0",
|
||||||
@@ -244,6 +247,10 @@ per-file-ignores."docker/wait-for-redis.py" = [
|
|||||||
per-file-ignores."src/documents/models.py" = [
|
per-file-ignores."src/documents/models.py" = [
|
||||||
"SIM115",
|
"SIM115",
|
||||||
]
|
]
|
||||||
|
per-file-ignores."src/documents/tests/*.py" = [
|
||||||
|
"TID251",
|
||||||
|
]
|
||||||
|
flake8-tidy-imports.banned-api."documents.tests".msg = "Shared test infrastructure lives in src/paperless_testing/."
|
||||||
isort.force-single-line = true
|
isort.force-single-line = true
|
||||||
|
|
||||||
[tool.codespell]
|
[tool.codespell]
|
||||||
@@ -271,9 +278,9 @@ plugins = [
|
|||||||
]
|
]
|
||||||
|
|
||||||
[tool.pyrefly]
|
[tool.pyrefly]
|
||||||
baseline = ".pyrefly-baseline.json"
|
|
||||||
python-platform = "linux"
|
python-platform = "linux"
|
||||||
search-path = [ "src" ]
|
search-path = [ "src" ]
|
||||||
|
baseline = ".pyrefly-baseline.json"
|
||||||
|
|
||||||
[tool.django-stubs]
|
[tool.django-stubs]
|
||||||
django_settings_module = "paperless.settings"
|
django_settings_module = "paperless.settings"
|
||||||
@@ -326,6 +333,8 @@ PAPERLESS_CACHE_BACKEND = "django.core.cache.backends.locmem.LocMemCache"
|
|||||||
PAPERLESS_CHANNELS_BACKEND = "channels.layers.InMemoryChannelLayer"
|
PAPERLESS_CHANNELS_BACKEND = "channels.layers.InMemoryChannelLayer"
|
||||||
# I don't think anything hits this, but just in case, basically infinite
|
# I don't think anything hits this, but just in case, basically infinite
|
||||||
PAPERLESS_TOKEN_THROTTLE_RATE = "1000/min"
|
PAPERLESS_TOKEN_THROTTLE_RATE = "1000/min"
|
||||||
|
# The 0.1s production default trips on a stalled CI runner, the date parsing tests then find no dates
|
||||||
|
PAPERLESS_MATCH_REGEX_TIMEOUT_SECONDS = "5"
|
||||||
|
|
||||||
[tool.coverage.run]
|
[tool.coverage.run]
|
||||||
source = [
|
source = [
|
||||||
@@ -334,13 +343,15 @@ source = [
|
|||||||
omit = [
|
omit = [
|
||||||
"*/tests/*",
|
"*/tests/*",
|
||||||
"manage.py",
|
"manage.py",
|
||||||
"paperless/wsgi.py",
|
|
||||||
"paperless/auth.py",
|
"paperless/auth.py",
|
||||||
|
"paperless/wsgi.py",
|
||||||
|
"src/conftest.py",
|
||||||
|
"src/paperless_testing/*",
|
||||||
]
|
]
|
||||||
[tool.coverage.report]
|
[tool.coverage.report]
|
||||||
exclude_also = [
|
exclude_also = [
|
||||||
"if settings.AUDIT_LOG_ENABLED:",
|
|
||||||
"if AUDIT_LOG_ENABLED:",
|
"if AUDIT_LOG_ENABLED:",
|
||||||
|
"if settings.AUDIT_LOG_ENABLED:",
|
||||||
"if TYPE_CHECKING:",
|
"if TYPE_CHECKING:",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|||||||
+35
-21
@@ -9745,7 +9745,7 @@
|
|||||||
</context-group>
|
</context-group>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/document-list/document-list.component.ts</context>
|
<context context-type="sourcefile">src/app/components/document-list/document-list.component.ts</context>
|
||||||
<context context-type="linenumber">348</context>
|
<context context-type="linenumber">351</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/manage/document-attributes/document-attributes.component.html</context>
|
<context context-type="sourcefile">src/app/components/manage/document-attributes/document-attributes.component.html</context>
|
||||||
@@ -9760,7 +9760,7 @@
|
|||||||
</context-group>
|
</context-group>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/document-list/document-list.component.ts</context>
|
<context context-type="sourcefile">src/app/components/document-list/document-list.component.ts</context>
|
||||||
<context context-type="linenumber">341</context>
|
<context context-type="linenumber">344</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/manage/document-attributes/document-attributes.component.html</context>
|
<context context-type="sourcefile">src/app/components/manage/document-attributes/document-attributes.component.html</context>
|
||||||
@@ -10016,56 +10016,56 @@
|
|||||||
<source>Reset filters / selection</source>
|
<source>Reset filters / selection</source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/document-list/document-list.component.ts</context>
|
<context context-type="sourcefile">src/app/components/document-list/document-list.component.ts</context>
|
||||||
<context context-type="linenumber">329</context>
|
<context context-type="linenumber">332</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="4135055128446167640" datatype="html">
|
<trans-unit id="4135055128446167640" datatype="html">
|
||||||
<source>Open first [selected] document</source>
|
<source>Open first [selected] document</source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/document-list/document-list.component.ts</context>
|
<context context-type="sourcefile">src/app/components/document-list/document-list.component.ts</context>
|
||||||
<context context-type="linenumber">357</context>
|
<context context-type="linenumber">360</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="3629960544875360046" datatype="html">
|
<trans-unit id="3629960544875360046" datatype="html">
|
||||||
<source>Previous page</source>
|
<source>Previous page</source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/document-list/document-list.component.ts</context>
|
<context context-type="sourcefile">src/app/components/document-list/document-list.component.ts</context>
|
||||||
<context context-type="linenumber">373</context>
|
<context context-type="linenumber">376</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="3337301694210287595" datatype="html">
|
<trans-unit id="3337301694210287595" datatype="html">
|
||||||
<source>Next page</source>
|
<source>Next page</source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/document-list/document-list.component.ts</context>
|
<context context-type="sourcefile">src/app/components/document-list/document-list.component.ts</context>
|
||||||
<context context-type="linenumber">385</context>
|
<context context-type="linenumber">388</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="2155249406916744630" datatype="html">
|
<trans-unit id="2155249406916744630" datatype="html">
|
||||||
<source>View "<x id="PH" equiv-text="this.list.activeSavedViewTitle"/>" saved successfully.</source>
|
<source>View "<x id="PH" equiv-text="this.list.activeSavedViewTitle"/>" saved successfully.</source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/document-list/document-list.component.ts</context>
|
<context context-type="sourcefile">src/app/components/document-list/document-list.component.ts</context>
|
||||||
<context context-type="linenumber">419</context>
|
<context context-type="linenumber">422</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="4646273665293421938" datatype="html">
|
<trans-unit id="4646273665293421938" datatype="html">
|
||||||
<source>Failed to save view "<x id="PH" equiv-text="this.list.activeSavedViewTitle"/>".</source>
|
<source>Failed to save view "<x id="PH" equiv-text="this.list.activeSavedViewTitle"/>".</source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/document-list/document-list.component.ts</context>
|
<context context-type="sourcefile">src/app/components/document-list/document-list.component.ts</context>
|
||||||
<context context-type="linenumber">425</context>
|
<context context-type="linenumber">428</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="6837554170707123455" datatype="html">
|
<trans-unit id="6837554170707123455" datatype="html">
|
||||||
<source>View "<x id="PH" equiv-text="savedView.name"/>" created successfully.</source>
|
<source>View "<x id="PH" equiv-text="savedView.name"/>" created successfully.</source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/document-list/document-list.component.ts</context>
|
<context context-type="sourcefile">src/app/components/document-list/document-list.component.ts</context>
|
||||||
<context context-type="linenumber">494</context>
|
<context context-type="linenumber">497</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="6028096992841030074" datatype="html">
|
<trans-unit id="6028096992841030074" datatype="html">
|
||||||
<source>View "<x id="PH" equiv-text="savedView.name"/>" created successfully, but could not update visibility settings.</source>
|
<source>View "<x id="PH" equiv-text="savedView.name"/>" created successfully, but could not update visibility settings.</source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/document-list/document-list.component.ts</context>
|
<context context-type="sourcefile">src/app/components/document-list/document-list.component.ts</context>
|
||||||
<context context-type="linenumber">500</context>
|
<context context-type="linenumber">503</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="739880801667335279" datatype="html">
|
<trans-unit id="739880801667335279" datatype="html">
|
||||||
@@ -12018,81 +12018,95 @@
|
|||||||
<context context-type="linenumber">351</context>
|
<context context-type="linenumber">351</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
|
<trans-unit id="861068592166833023" datatype="html">
|
||||||
|
<source>LLM Embedding API Key</source>
|
||||||
|
<context-group purpose="location">
|
||||||
|
<context context-type="sourcefile">src/app/data/paperless-config.ts</context>
|
||||||
|
<context context-type="linenumber">358</context>
|
||||||
|
</context-group>
|
||||||
|
</trans-unit>
|
||||||
|
<trans-unit id="2929108042259892948" datatype="html">
|
||||||
|
<source>Used for embeddings when set, otherwise LLM API key is used.</source>
|
||||||
|
<context-group purpose="location">
|
||||||
|
<context context-type="sourcefile">src/app/data/paperless-config.ts</context>
|
||||||
|
<context context-type="linenumber">360</context>
|
||||||
|
</context-group>
|
||||||
|
</trans-unit>
|
||||||
<trans-unit id="3554114880473286122" datatype="html">
|
<trans-unit id="3554114880473286122" datatype="html">
|
||||||
<source>LLM Embedding Endpoint</source>
|
<source>LLM Embedding Endpoint</source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/data/paperless-config.ts</context>
|
<context context-type="sourcefile">src/app/data/paperless-config.ts</context>
|
||||||
<context context-type="linenumber">358</context>
|
<context context-type="linenumber">366</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="1044242175651289991" datatype="html">
|
<trans-unit id="1044242175651289991" datatype="html">
|
||||||
<source>LLM Embedding Chunk Size</source>
|
<source>LLM Embedding Chunk Size</source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/data/paperless-config.ts</context>
|
<context context-type="sourcefile">src/app/data/paperless-config.ts</context>
|
||||||
<context context-type="linenumber">365</context>
|
<context context-type="linenumber">373</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="7218245223139363113" datatype="html">
|
<trans-unit id="7218245223139363113" datatype="html">
|
||||||
<source>LLM Context Size</source>
|
<source>LLM Context Size</source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/data/paperless-config.ts</context>
|
<context context-type="sourcefile">src/app/data/paperless-config.ts</context>
|
||||||
<context context-type="linenumber">372</context>
|
<context context-type="linenumber">380</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="4234495692726214397" datatype="html">
|
<trans-unit id="4234495692726214397" datatype="html">
|
||||||
<source>LLM Backend</source>
|
<source>LLM Backend</source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/data/paperless-config.ts</context>
|
<context context-type="sourcefile">src/app/data/paperless-config.ts</context>
|
||||||
<context context-type="linenumber">379</context>
|
<context context-type="linenumber">387</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="7935234833834000002" datatype="html">
|
<trans-unit id="7935234833834000002" datatype="html">
|
||||||
<source>LLM Model</source>
|
<source>LLM Model</source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/data/paperless-config.ts</context>
|
<context context-type="sourcefile">src/app/data/paperless-config.ts</context>
|
||||||
<context context-type="linenumber">387</context>
|
<context context-type="linenumber">395</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="1980550530387803165" datatype="html">
|
<trans-unit id="1980550530387803165" datatype="html">
|
||||||
<source>LLM API Key</source>
|
<source>LLM API Key</source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/data/paperless-config.ts</context>
|
<context context-type="sourcefile">src/app/data/paperless-config.ts</context>
|
||||||
<context context-type="linenumber">394</context>
|
<context context-type="linenumber">402</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="6126617860376156501" datatype="html">
|
<trans-unit id="6126617860376156501" datatype="html">
|
||||||
<source>LLM Endpoint</source>
|
<source>LLM Endpoint</source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/data/paperless-config.ts</context>
|
<context context-type="sourcefile">src/app/data/paperless-config.ts</context>
|
||||||
<context context-type="linenumber">401</context>
|
<context context-type="linenumber">409</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="6572826277249350975" datatype="html">
|
<trans-unit id="6572826277249350975" datatype="html">
|
||||||
<source>LLM Output Language</source>
|
<source>LLM Output Language</source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/data/paperless-config.ts</context>
|
<context context-type="sourcefile">src/app/data/paperless-config.ts</context>
|
||||||
<context context-type="linenumber">408</context>
|
<context context-type="linenumber">416</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="3284403507172415792" datatype="html">
|
<trans-unit id="3284403507172415792" datatype="html">
|
||||||
<source>Language to use for generated AI suggestions. When unset, AI suggestions use the user's display language if explicitly set.</source>
|
<source>Language to use for generated AI suggestions. When unset, AI suggestions use the user's display language if explicitly set.</source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/data/paperless-config.ts</context>
|
<context context-type="sourcefile">src/app/data/paperless-config.ts</context>
|
||||||
<context context-type="linenumber">412</context>
|
<context context-type="linenumber">420</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="4493921125434706859" datatype="html">
|
<trans-unit id="4493921125434706859" datatype="html">
|
||||||
<source>LLM Request Timeout</source>
|
<source>LLM Request Timeout</source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/data/paperless-config.ts</context>
|
<context context-type="sourcefile">src/app/data/paperless-config.ts</context>
|
||||||
<context context-type="linenumber">416</context>
|
<context context-type="linenumber">424</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="483994032066441287" datatype="html">
|
<trans-unit id="483994032066441287" datatype="html">
|
||||||
<source>Timeout in seconds for LLM requests.</source>
|
<source>Timeout in seconds for LLM requests.</source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/data/paperless-config.ts</context>
|
<context context-type="sourcefile">src/app/data/paperless-config.ts</context>
|
||||||
<context context-type="linenumber">420</context>
|
<context context-type="linenumber">428</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="1055686627716339120" datatype="html">
|
<trans-unit id="1055686627716339120" datatype="html">
|
||||||
|
|||||||
@@ -253,6 +253,24 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
@if (aiEnabled) {
|
||||||
|
<div class="row mb-3">
|
||||||
|
<div class="col-md-3 col-form-label pt-0">
|
||||||
|
<span i18n>Suggestions default to</span>
|
||||||
|
</div>
|
||||||
|
<div class="col">
|
||||||
|
<fieldset class="btn-group btn-group-sm">
|
||||||
|
<input type="radio" class="btn-check" id="suggestionSourceBoth" [value]="SuggestionSource.Both" formControlName="documentEditingSuggestionSource">
|
||||||
|
<label class="btn btn-outline-primary" for="suggestionSourceBoth"><ng-container i18n>Both</ng-container></label>
|
||||||
|
<input type="radio" class="btn-check" id="suggestionSourceML" [value]="SuggestionSource.ML" formControlName="documentEditingSuggestionSource">
|
||||||
|
<label class="btn btn-outline-primary" for="suggestionSourceML"><i-bs class="me-1" name="cpu"></i-bs><ng-container i18n>ML only</ng-container></label>
|
||||||
|
<input type="radio" class="btn-check" id="suggestionSourceAI" [value]="SuggestionSource.AI" formControlName="documentEditingSuggestionSource">
|
||||||
|
<label class="btn btn-outline-primary" for="suggestionSourceAI"><i-bs class="me-1" name="stars"></i-bs><ng-container i18n>AI only</ng-container></label>
|
||||||
|
</fieldset>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<div class="col">
|
<div class="col">
|
||||||
<pngx-input-check i18n-title title="Automatically request suggestions for inbox documents" i18n-hint hint="If un-checked, suggestions must be requested via the Suggest button." formControlName="documentEditingAutoSuggest"></pngx-input-check>
|
<pngx-input-check i18n-title title="Automatically request suggestions for inbox documents" i18n-hint hint="If un-checked, suggestions must be requested via the Suggest button." formControlName="documentEditingAutoSuggest"></pngx-input-check>
|
||||||
|
|||||||
@@ -307,7 +307,7 @@ describe('SettingsComponent', () => {
|
|||||||
expect(toastErrorSpy).toHaveBeenCalled()
|
expect(toastErrorSpy).toHaveBeenCalled()
|
||||||
expect(storeSpy).toHaveBeenCalled()
|
expect(storeSpy).toHaveBeenCalled()
|
||||||
expect(appearanceSettingsSpy).not.toHaveBeenCalled()
|
expect(appearanceSettingsSpy).not.toHaveBeenCalled()
|
||||||
expect(setSpy).toHaveBeenCalledTimes(34)
|
expect(setSpy).toHaveBeenCalledTimes(35)
|
||||||
expect(setSpy).toHaveBeenCalledWith(SETTINGS_KEYS.SIDEBAR_HIDDEN_ITEMS, [
|
expect(setSpy).toHaveBeenCalledWith(SETTINGS_KEYS.SIDEBAR_HIDDEN_ITEMS, [
|
||||||
HideableSidebarItemID.Workflows,
|
HideableSidebarItemID.Workflows,
|
||||||
])
|
])
|
||||||
|
|||||||
@@ -44,6 +44,7 @@ import {
|
|||||||
HIDEABLE_SIDEBAR_ITEM_IDS,
|
HIDEABLE_SIDEBAR_ITEM_IDS,
|
||||||
HideableSidebarItemID,
|
HideableSidebarItemID,
|
||||||
SETTINGS_KEYS,
|
SETTINGS_KEYS,
|
||||||
|
SuggestionSource,
|
||||||
} from 'src/app/data/ui-settings'
|
} from 'src/app/data/ui-settings'
|
||||||
import { User } from 'src/app/data/user'
|
import { User } from 'src/app/data/user'
|
||||||
import { IfPermissionsDirective } from 'src/app/directives/if-permissions.directive'
|
import { IfPermissionsDirective } from 'src/app/directives/if-permissions.directive'
|
||||||
@@ -184,6 +185,7 @@ export class SettingsComponent
|
|||||||
documentEditingRemoveInboxTags: new FormControl(null),
|
documentEditingRemoveInboxTags: new FormControl(null),
|
||||||
documentEditingOverlayThumbnail: new FormControl(null),
|
documentEditingOverlayThumbnail: new FormControl(null),
|
||||||
documentEditingAutoSuggest: new FormControl(null),
|
documentEditingAutoSuggest: new FormControl(null),
|
||||||
|
documentEditingSuggestionSource: new FormControl(null),
|
||||||
documentDetailsHiddenFields: new FormControl([]),
|
documentDetailsHiddenFields: new FormControl([]),
|
||||||
searchDbOnly: new FormControl(null),
|
searchDbOnly: new FormControl(null),
|
||||||
searchLink: new FormControl(null),
|
searchLink: new FormControl(null),
|
||||||
@@ -217,6 +219,11 @@ export class SettingsComponent
|
|||||||
public readonly PdfZoomScale = PdfZoomScale
|
public readonly PdfZoomScale = PdfZoomScale
|
||||||
|
|
||||||
public readonly PdfEditorEditMode = PdfEditorEditMode
|
public readonly PdfEditorEditMode = PdfEditorEditMode
|
||||||
|
public readonly SuggestionSource = SuggestionSource
|
||||||
|
|
||||||
|
get aiEnabled(): boolean {
|
||||||
|
return this.settings.get(SETTINGS_KEYS.AI_ENABLED)
|
||||||
|
}
|
||||||
|
|
||||||
public readonly documentDetailFieldOptions = documentDetailFieldOptions
|
public readonly documentDetailFieldOptions = documentDetailFieldOptions
|
||||||
public readonly sidebarItemOptions = HIDEABLE_SIDEBAR_ITEM_IDS.map((id) => ({
|
public readonly sidebarItemOptions = HIDEABLE_SIDEBAR_ITEM_IDS.map((id) => ({
|
||||||
@@ -404,6 +411,9 @@ export class SettingsComponent
|
|||||||
documentEditingAutoSuggest: this.settings.get(
|
documentEditingAutoSuggest: this.settings.get(
|
||||||
SETTINGS_KEYS.DOCUMENT_EDITING_AUTO_SUGGEST
|
SETTINGS_KEYS.DOCUMENT_EDITING_AUTO_SUGGEST
|
||||||
),
|
),
|
||||||
|
documentEditingSuggestionSource: this.settings.get(
|
||||||
|
SETTINGS_KEYS.DOCUMENT_EDITING_SUGGESTION_SOURCE
|
||||||
|
),
|
||||||
documentDetailsHiddenFields: this.settings.get(
|
documentDetailsHiddenFields: this.settings.get(
|
||||||
SETTINGS_KEYS.DOCUMENT_DETAILS_HIDDEN_FIELDS
|
SETTINGS_KEYS.DOCUMENT_DETAILS_HIDDEN_FIELDS
|
||||||
),
|
),
|
||||||
@@ -625,6 +635,10 @@ export class SettingsComponent
|
|||||||
SETTINGS_KEYS.DOCUMENT_EDITING_AUTO_SUGGEST,
|
SETTINGS_KEYS.DOCUMENT_EDITING_AUTO_SUGGEST,
|
||||||
this.settingsForm.value.documentEditingAutoSuggest
|
this.settingsForm.value.documentEditingAutoSuggest
|
||||||
)
|
)
|
||||||
|
this.settings.set(
|
||||||
|
SETTINGS_KEYS.DOCUMENT_EDITING_SUGGESTION_SOURCE,
|
||||||
|
this.settingsForm.value.documentEditingSuggestionSource
|
||||||
|
)
|
||||||
this.settings.set(
|
this.settings.set(
|
||||||
SETTINGS_KEYS.DOCUMENT_DETAILS_HIDDEN_FIELDS,
|
SETTINGS_KEYS.DOCUMENT_DETAILS_HIDDEN_FIELDS,
|
||||||
this.settingsForm.value.documentDetailsHiddenFields
|
this.settingsForm.value.documentDetailsHiddenFields
|
||||||
|
|||||||
+77
-51
@@ -1,58 +1,84 @@
|
|||||||
<div class="btn-group">
|
<div class="d-flex align-items-center">
|
||||||
<button type="button" class="btn btn-sm btn-outline-primary" (click)="clickSuggest()" [disabled]="disabled() || loading() || (suggestions() && !aiEnabled())" [aria-label]="noSuggestions ? 'No suggestions' : 'Suggest'" i18n-aria-label>
|
<div class="btn-group">
|
||||||
@if (loading()) {
|
<button type="button" class="btn btn-sm btn-outline-primary" (click)="clickSuggest()" [disabled]="disabled() || loading() || (suggestions() && !aiEnabled())" [aria-label]="noSuggestions ? 'No suggestions' : 'Suggest'" i18n-aria-label>
|
||||||
<div class="spinner-border spinner-border-sm" role="status"></div>
|
@if (loading()) {
|
||||||
} @else if (noSuggestions) {
|
<div class="spinner-border spinner-border-sm" role="status"></div>
|
||||||
<i-bs width="1.2em" height="1.2em" name="check-circle"></i-bs>
|
} @else if (noSuggestions) {
|
||||||
} @else {
|
<i-bs width="1.2em" height="1.2em" name="check-circle"></i-bs>
|
||||||
<i-bs width="1.2em" height="1.2em" name="stars"></i-bs>
|
} @else {
|
||||||
|
<i-bs width="1.2em" height="1.2em" name="lightbulb"></i-bs>
|
||||||
|
}
|
||||||
|
@if (noSuggestions) {
|
||||||
|
<span class="d-none d-lg-inline ps-1" i18n>No suggestions</span>
|
||||||
|
} @else {
|
||||||
|
<span class="d-none d-lg-inline ps-1" i18n>Suggest</span>
|
||||||
|
}
|
||||||
|
@if (totalSuggestions > 0) {
|
||||||
|
<span class="badge bg-primary ms-2">{{ totalSuggestions }}</span>
|
||||||
|
}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
@if (aiEnabled()) {
|
||||||
|
<div class="btn-group" ngbDropdown #dropdown="ngbDropdown" [popperOptions]="popperOptions">
|
||||||
|
<button type="button" class="btn btn-sm btn-outline-primary" ngbDropdownToggle [disabled]="disabled() || loading() || !suggestions()" aria-expanded="false" aria-controls="suggestionsDropdown" aria-label="Suggestions dropdown">
|
||||||
|
<span class="visually-hidden" i18n>Show suggestions</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div ngbDropdownMenu aria-labelledby="suggestionsDropdown" class="shadow suggestions-dropdown">
|
||||||
|
<div class="list-group list-group-flush small pb-0">
|
||||||
|
@if (novelSuggestions === 0 && reusableSuggestions === 0) {
|
||||||
|
<div class="list-group-item text-muted fst-italic">
|
||||||
|
<small class="text-muted small fst-italic" i18n>No novel suggestions</small>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
@if (suggestions()?.suggested_tags?.length > 0) {
|
||||||
|
<small class="list-group-item text-uppercase text-muted small"><i-bs class="me-2" name="tags"></i-bs><ng-container i18n>Tags</ng-container></small>
|
||||||
|
@for (tag of suggestions().suggested_tags; track tag) {
|
||||||
|
<button type="button" class="list-group-item list-group-item-action bg-light" (click)="addTag.emit(tag)">{{ tag }}</button>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@if (suggestions()?.suggested_document_types?.length > 0) {
|
||||||
|
<div class="list-group-item text-uppercase text-muted small"><i-bs class="me-2" name="hash"></i-bs><ng-container i18n>Document Types</ng-container></div>
|
||||||
|
@for (type of suggestions().suggested_document_types; track type) {
|
||||||
|
<button type="button" class="list-group-item list-group-item-action bg-light" (click)="addDocumentType.emit(type)">{{ type }}</button>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@if (suggestions()?.suggested_correspondents?.length > 0) {
|
||||||
|
<div class="list-group-item text-uppercase text-muted small"><i-bs class="me-2" name="person"></i-bs><ng-container i18n>Correspondents</ng-container></div>
|
||||||
|
@for (correspondent of suggestions().suggested_correspondents; track correspondent) {
|
||||||
|
<button type="button" class="list-group-item list-group-item-action bg-light" (click)="addCorrespondent.emit(correspondent)">{{ correspondent }}</button>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@if (reusableSuggestions > 0) {
|
||||||
|
<div class="list-group-item text-muted fst-italic">
|
||||||
|
<small class="text-muted small fst-italic" i18n>{reusableSuggestions, plural, =1 {1 existing value suggested below} other {{{reusableSuggestions}} existing values suggested below}}</small>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
}
|
}
|
||||||
@if (noSuggestions) {
|
</div>
|
||||||
<span class="d-none d-lg-inline ps-1" i18n>No suggestions</span>
|
|
||||||
} @else {
|
|
||||||
<span class="d-none d-lg-inline ps-1" i18n>Suggest</span>
|
|
||||||
}
|
|
||||||
@if (totalSuggestions > 0) {
|
|
||||||
<span class="badge bg-primary ms-2">{{ totalSuggestions }}</span>
|
|
||||||
}
|
|
||||||
</button>
|
|
||||||
|
|
||||||
@if (aiEnabled()) {
|
@if (aiEnabled()) {
|
||||||
<div class="btn-group" ngbDropdown #dropdown="ngbDropdown" [popperOptions]="popperOptions">
|
<div ngbDropdown autoClose="outside" placement="bottom-end" [popperOptions]="popperOptions">
|
||||||
<button type="button" class="btn btn-sm btn-outline-primary" ngbDropdownToggle [disabled]="disabled() || loading() || !suggestions()" aria-expanded="false" aria-controls="suggestionsDropdown" aria-label="Suggestions dropdown">
|
<button type="button" class="btn btn-sm btn-link position-relative" ngbDropdownToggle [disabled]="disabled() || loading()" i18n-title title="Suggestion options">
|
||||||
<span class="visually-hidden" i18n>Show suggestions</span>
|
<i-bs name="three-dots"></i-bs>
|
||||||
|
@if (source() !== defaultSource()) {
|
||||||
|
<span class="position-absolute top-0 start-100 translate-middle p-1 bg-primary border border-light rounded-circle">
|
||||||
|
<span class="visually-hidden" i18n>Not using default</span>
|
||||||
|
</span>
|
||||||
|
}
|
||||||
</button>
|
</button>
|
||||||
|
<div ngbDropdownMenu class="shadow p-3">
|
||||||
<div ngbDropdownMenu aria-labelledby="suggestionsDropdown" class="shadow suggestions-dropdown">
|
<div class="small text-muted mb-2" i18n>Suggest using:</div>
|
||||||
<div class="list-group list-group-flush small pb-0">
|
<div class="form-check small">
|
||||||
@if (novelSuggestions === 0 && reusableSuggestions === 0) {
|
<input class="form-check-input" type="checkbox" id="suggestionSourceML" [checked]="useML" [disabled]="useML && !useAI" (change)="setSources($event.target.checked, useAI)">
|
||||||
<div class="list-group-item text-muted fst-italic">
|
<label class="form-check-label d-inline-flex align-items-center gap-1" for="suggestionSourceML"><i-bs name="cpu"></i-bs><ng-container i18n>ML</ng-container></label>
|
||||||
<small class="text-muted small fst-italic" i18n>No novel suggestions</small>
|
</div>
|
||||||
</div>
|
<div class="form-check small">
|
||||||
}
|
<input class="form-check-input" type="checkbox" id="suggestionSourceAI" [checked]="useAI" [disabled]="useAI && !useML" (change)="setSources(useML, $event.target.checked)">
|
||||||
@if (suggestions()?.suggested_tags?.length > 0) {
|
<label class="form-check-label d-inline-flex align-items-center gap-1" for="suggestionSourceAI"><i-bs name="stars"></i-bs><ng-container i18n>AI</ng-container></label>
|
||||||
<small class="list-group-item text-uppercase text-muted small"><i-bs class="me-2" name="tags"></i-bs><ng-container i18n>Tags</ng-container></small>
|
|
||||||
@for (tag of suggestions().suggested_tags; track tag) {
|
|
||||||
<button type="button" class="list-group-item list-group-item-action bg-light" (click)="addTag.emit(tag)">{{ tag }}</button>
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@if (suggestions()?.suggested_document_types?.length > 0) {
|
|
||||||
<div class="list-group-item text-uppercase text-muted small"><i-bs class="me-2" name="hash"></i-bs><ng-container i18n>Document Types</ng-container></div>
|
|
||||||
@for (type of suggestions().suggested_document_types; track type) {
|
|
||||||
<button type="button" class="list-group-item list-group-item-action bg-light" (click)="addDocumentType.emit(type)">{{ type }}</button>
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@if (suggestions()?.suggested_correspondents?.length > 0) {
|
|
||||||
<div class="list-group-item text-uppercase text-muted small"><i-bs class="me-2" name="person"></i-bs><ng-container i18n>Correspondents</ng-container></div>
|
|
||||||
@for (correspondent of suggestions().suggested_correspondents; track correspondent) {
|
|
||||||
<button type="button" class="list-group-item list-group-item-action bg-light" (click)="addCorrespondent.emit(correspondent)">{{ correspondent }}</button>
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@if (reusableSuggestions > 0) {
|
|
||||||
<div class="list-group-item text-muted fst-italic">
|
|
||||||
<small class="text-muted small fst-italic" i18n>{reusableSuggestions, plural, =1 {1 existing value suggested below} other {{{reusableSuggestions}} existing values suggested below}}</small>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+4
@@ -1,3 +1,7 @@
|
|||||||
.suggestions-dropdown {
|
.suggestions-dropdown {
|
||||||
min-width: 250px;
|
min-width: 250px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.btn-link.dropdown-toggle::after {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|||||||
+59
-1
@@ -1,6 +1,7 @@
|
|||||||
import { ComponentFixture, TestBed } from '@angular/core/testing'
|
import { ComponentFixture, TestBed } from '@angular/core/testing'
|
||||||
import { NgbDropdownModule } from '@ng-bootstrap/ng-bootstrap'
|
import { NgbDropdownModule } from '@ng-bootstrap/ng-bootstrap'
|
||||||
import { NgxBootstrapIconsModule, allIcons } from 'ngx-bootstrap-icons'
|
import { NgxBootstrapIconsModule, allIcons } from 'ngx-bootstrap-icons'
|
||||||
|
import { SuggestionSource } from 'src/app/data/ui-settings'
|
||||||
import { SuggestionsDropdownComponent } from './suggestions-dropdown.component'
|
import { SuggestionsDropdownComponent } from './suggestions-dropdown.component'
|
||||||
|
|
||||||
describe('SuggestionsDropdownComponent', () => {
|
describe('SuggestionsDropdownComponent', () => {
|
||||||
@@ -179,14 +180,71 @@ describe('SuggestionsDropdownComponent', () => {
|
|||||||
|
|
||||||
it('should toggle dropdown when clickSuggest is called and suggestions are not null', () => {
|
it('should toggle dropdown when clickSuggest is called and suggestions are not null', () => {
|
||||||
fixture.componentRef.setInput('aiEnabled', true)
|
fixture.componentRef.setInput('aiEnabled', true)
|
||||||
|
fixture.componentRef.setInput('fetchedSources', [SuggestionSource.ML])
|
||||||
fixture.detectChanges()
|
fixture.detectChanges()
|
||||||
fixture.componentRef.setInput('suggestions', {
|
fixture.componentRef.setInput('suggestions', {
|
||||||
suggested_correspondents: [],
|
suggested_correspondents: [],
|
||||||
suggested_tags: [],
|
suggested_tags: [],
|
||||||
suggested_document_types: [],
|
suggested_document_types: [],
|
||||||
})
|
})
|
||||||
|
fixture.detectChanges()
|
||||||
component.clickSuggest()
|
component.clickSuggest()
|
||||||
expect(component.dropdown.open).toBeTruthy()
|
expect(component.dropdown.isOpen()).toBeTruthy()
|
||||||
expect(fixture.nativeElement.textContent).toContain('No novel suggestions')
|
expect(fixture.nativeElement.textContent).toContain('No novel suggestions')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('should fetch unfetched sources and show existing suggestions', () => {
|
||||||
|
jest.spyOn(component.getSuggestions, 'emit')
|
||||||
|
fixture.componentRef.setInput('aiEnabled', true)
|
||||||
|
fixture.componentRef.setInput('source', SuggestionSource.Both)
|
||||||
|
fixture.componentRef.setInput('fetchedSources', [SuggestionSource.ML])
|
||||||
|
fixture.componentRef.setInput('suggestions', { tags: [1] })
|
||||||
|
fixture.detectChanges()
|
||||||
|
component.clickSuggest()
|
||||||
|
expect(component.getSuggestions.emit).toHaveBeenCalledWith(
|
||||||
|
SuggestionSource.Both
|
||||||
|
)
|
||||||
|
expect(component.dropdown.isOpen()).toBeTruthy()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should only show source options when AI is enabled', () => {
|
||||||
|
expect(
|
||||||
|
fixture.nativeElement.querySelector('#suggestionSourceML')
|
||||||
|
).toBeNull()
|
||||||
|
fixture.componentRef.setInput('aiEnabled', true)
|
||||||
|
fixture.detectChanges()
|
||||||
|
fixture.nativeElement
|
||||||
|
.querySelector('button[title="Suggestion options"]')
|
||||||
|
.click()
|
||||||
|
fixture.detectChanges()
|
||||||
|
expect(
|
||||||
|
fixture.nativeElement.querySelector('#suggestionSourceML')
|
||||||
|
).not.toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should emit source changes and never allow no source', () => {
|
||||||
|
const emitSpy = jest.spyOn(component.sourceChange, 'emit')
|
||||||
|
component.setSources(true, true)
|
||||||
|
expect(emitSpy).toHaveBeenCalledWith(SuggestionSource.Both)
|
||||||
|
component.setSources(true, false)
|
||||||
|
expect(emitSpy).toHaveBeenCalledWith(SuggestionSource.ML)
|
||||||
|
component.setSources(false, true)
|
||||||
|
expect(emitSpy).toHaveBeenCalledWith(SuggestionSource.AI)
|
||||||
|
|
||||||
|
emitSpy.mockClear()
|
||||||
|
component.setSources(false, false)
|
||||||
|
expect(emitSpy).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should indicate a non-default source', () => {
|
||||||
|
fixture.componentRef.setInput('aiEnabled', true)
|
||||||
|
fixture.componentRef.setInput('source', SuggestionSource.AI)
|
||||||
|
fixture.componentRef.setInput('defaultSource', SuggestionSource.AI)
|
||||||
|
fixture.detectChanges()
|
||||||
|
expect(fixture.nativeElement.textContent).not.toContain('Not using default')
|
||||||
|
|
||||||
|
fixture.componentRef.setInput('source', SuggestionSource.Both)
|
||||||
|
fixture.detectChanges()
|
||||||
|
expect(fixture.nativeElement.textContent).toContain('Not using default')
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
+40
-3
@@ -8,6 +8,7 @@ import {
|
|||||||
import { NgbDropdown, NgbDropdownModule } from '@ng-bootstrap/ng-bootstrap'
|
import { NgbDropdown, NgbDropdownModule } from '@ng-bootstrap/ng-bootstrap'
|
||||||
import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons'
|
import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons'
|
||||||
import { DocumentSuggestions } from 'src/app/data/document-suggestions'
|
import { DocumentSuggestions } from 'src/app/data/document-suggestions'
|
||||||
|
import { SuggestionSource } from 'src/app/data/ui-settings'
|
||||||
import { pngxPopperOptions } from 'src/app/utils/popper-options'
|
import { pngxPopperOptions } from 'src/app/utils/popper-options'
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
@@ -18,12 +19,16 @@ import { pngxPopperOptions } from 'src/app/utils/popper-options'
|
|||||||
})
|
})
|
||||||
export class SuggestionsDropdownComponent {
|
export class SuggestionsDropdownComponent {
|
||||||
public popperOptions = pngxPopperOptions
|
public popperOptions = pngxPopperOptions
|
||||||
|
public readonly SuggestionSource = SuggestionSource
|
||||||
|
|
||||||
@ViewChild('dropdown') dropdown: NgbDropdown
|
@ViewChild('dropdown') dropdown: NgbDropdown
|
||||||
readonly suggestions = input<DocumentSuggestions>(null)
|
readonly suggestions = input<DocumentSuggestions>(null)
|
||||||
readonly aiEnabled = input(false)
|
readonly aiEnabled = input(false)
|
||||||
readonly loading = input(false)
|
readonly loading = input(false)
|
||||||
readonly disabled = input(false)
|
readonly disabled = input(false)
|
||||||
|
readonly source = input<SuggestionSource>(SuggestionSource.ML)
|
||||||
|
readonly defaultSource = input<SuggestionSource>(SuggestionSource.ML)
|
||||||
|
readonly fetchedSources = input<SuggestionSource[]>([])
|
||||||
|
|
||||||
readonly appliedTags = input<number[]>([])
|
readonly appliedTags = input<number[]>([])
|
||||||
readonly appliedCorrespondent = input<number>(null)
|
readonly appliedCorrespondent = input<number>(null)
|
||||||
@@ -31,8 +36,10 @@ export class SuggestionsDropdownComponent {
|
|||||||
readonly appliedStoragePath = input<number>(null)
|
readonly appliedStoragePath = input<number>(null)
|
||||||
|
|
||||||
@Output()
|
@Output()
|
||||||
getSuggestions: EventEmitter<SuggestionsDropdownComponent> =
|
getSuggestions: EventEmitter<SuggestionSource> = new EventEmitter()
|
||||||
new EventEmitter()
|
|
||||||
|
@Output()
|
||||||
|
sourceChange: EventEmitter<SuggestionSource> = new EventEmitter()
|
||||||
|
|
||||||
@Output()
|
@Output()
|
||||||
addTag: EventEmitter<string> = new EventEmitter()
|
addTag: EventEmitter<string> = new EventEmitter()
|
||||||
@@ -53,12 +60,42 @@ export class SuggestionsDropdownComponent {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (!this.suggestions()) {
|
if (!this.suggestions()) {
|
||||||
this.getSuggestions.emit(this)
|
this.getSuggestions.emit(this.source())
|
||||||
|
} else if (this.hasUnfetchedSources) {
|
||||||
|
// sources changed, fetch the rest and show what we have meanwhile
|
||||||
|
this.getSuggestions.emit(this.source())
|
||||||
|
this.dropdown?.open()
|
||||||
} else {
|
} else {
|
||||||
this.dropdown?.toggle()
|
this.dropdown?.toggle()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
get useML(): boolean {
|
||||||
|
return this.source() !== SuggestionSource.AI
|
||||||
|
}
|
||||||
|
|
||||||
|
get useAI(): boolean {
|
||||||
|
return this.source() !== SuggestionSource.ML
|
||||||
|
}
|
||||||
|
|
||||||
|
get hasUnfetchedSources(): boolean {
|
||||||
|
const fetched = this.fetchedSources()
|
||||||
|
return (
|
||||||
|
(this.useML && !fetched.includes(SuggestionSource.ML)) ||
|
||||||
|
(this.useAI && !fetched.includes(SuggestionSource.AI))
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
public setSources(ml: boolean, ai: boolean) {
|
||||||
|
if (ml && ai) {
|
||||||
|
this.sourceChange.emit(SuggestionSource.Both)
|
||||||
|
} else if (ml) {
|
||||||
|
this.sourceChange.emit(SuggestionSource.ML)
|
||||||
|
} else if (ai) {
|
||||||
|
this.sourceChange.emit(SuggestionSource.AI)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
get novelSuggestions(): number {
|
get novelSuggestions(): number {
|
||||||
return (
|
return (
|
||||||
(this.suggestions()?.suggested_correspondents?.length ?? 0) +
|
(this.suggestions()?.suggested_correspondents?.length ?? 0) +
|
||||||
|
|||||||
@@ -134,11 +134,15 @@
|
|||||||
[loading]="suggestionsLoading()"
|
[loading]="suggestionsLoading()"
|
||||||
[suggestions]="suggestions()"
|
[suggestions]="suggestions()"
|
||||||
[aiEnabled]="aiEnabled"
|
[aiEnabled]="aiEnabled"
|
||||||
|
[source]="suggestionSource"
|
||||||
|
[defaultSource]="defaultSuggestionSource"
|
||||||
|
[fetchedSources]="fetchedSuggestionSources()"
|
||||||
[appliedTags]="documentForm.value.tags"
|
[appliedTags]="documentForm.value.tags"
|
||||||
[appliedCorrespondent]="documentForm.value.correspondent"
|
[appliedCorrespondent]="documentForm.value.correspondent"
|
||||||
[appliedDocumentType]="documentForm.value.document_type"
|
[appliedDocumentType]="documentForm.value.document_type"
|
||||||
[appliedStoragePath]="documentForm.value.storage_path"
|
[appliedStoragePath]="documentForm.value.storage_path"
|
||||||
(getSuggestions)="getSuggestions()"
|
(getSuggestions)="getSuggestions($event)"
|
||||||
|
(sourceChange)="suggestionSourceOverride.set($event)"
|
||||||
(addTag)="createTag($event)"
|
(addTag)="createTag($event)"
|
||||||
(addDocumentType)="createDocumentType($event)"
|
(addDocumentType)="createDocumentType($event)"
|
||||||
(addCorrespondent)="createCorrespondent($event)">
|
(addCorrespondent)="createCorrespondent($event)">
|
||||||
|
|||||||
@@ -43,7 +43,7 @@ import {
|
|||||||
} from 'src/app/data/filter-rule-type'
|
} from 'src/app/data/filter-rule-type'
|
||||||
import { StoragePath } from 'src/app/data/storage-path'
|
import { StoragePath } from 'src/app/data/storage-path'
|
||||||
import { Tag } from 'src/app/data/tag'
|
import { Tag } from 'src/app/data/tag'
|
||||||
import { SETTINGS_KEYS } from 'src/app/data/ui-settings'
|
import { SETTINGS_KEYS, SuggestionSource } from 'src/app/data/ui-settings'
|
||||||
import { PermissionsGuard } from 'src/app/guards/permissions.guard'
|
import { PermissionsGuard } from 'src/app/guards/permissions.guard'
|
||||||
import { CustomDatePipe } from 'src/app/pipes/custom-date.pipe'
|
import { CustomDatePipe } from 'src/app/pipes/custom-date.pipe'
|
||||||
import { DocumentTitlePipe } from 'src/app/pipes/document-title.pipe'
|
import { DocumentTitlePipe } from 'src/app/pipes/document-title.pipe'
|
||||||
@@ -1528,6 +1528,113 @@ describe('DocumentDetailComponent', () => {
|
|||||||
expect(component.suggestionsLoading()).toBeFalsy()
|
expect(component.suggestionsLoading()).toBeFalsy()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('should get and merge ML and AI suggestions when source is both', () => {
|
||||||
|
settingsService.set(
|
||||||
|
SETTINGS_KEYS.DOCUMENT_EDITING_SUGGESTION_SOURCE,
|
||||||
|
SuggestionSource.Both
|
||||||
|
)
|
||||||
|
const getSetting = settingsService.get.bind(settingsService)
|
||||||
|
jest
|
||||||
|
.spyOn(settingsService, 'get')
|
||||||
|
.mockImplementation((key) =>
|
||||||
|
key === SETTINGS_KEYS.AI_ENABLED ? true : getSetting(key)
|
||||||
|
)
|
||||||
|
const suggestionsSpy = jest
|
||||||
|
.spyOn(documentService, 'getSuggestions')
|
||||||
|
.mockReturnValue(of({ tags: [42], dates: ['2024-01-01'] }))
|
||||||
|
const aiSuggestionsSpy = jest
|
||||||
|
.spyOn(documentService, 'getAiSuggestions')
|
||||||
|
.mockReturnValue(
|
||||||
|
of({ title: 'AI title', tags: [42, 43], suggested_tags: ['New'] })
|
||||||
|
)
|
||||||
|
initNormally()
|
||||||
|
expect(suggestionsSpy).toHaveBeenCalled()
|
||||||
|
expect(aiSuggestionsSpy).toHaveBeenCalled()
|
||||||
|
expect(component.suggestions().title).toEqual('AI title')
|
||||||
|
expect(component.suggestions().tags).toEqual([42, 43])
|
||||||
|
expect(component.suggestions().suggested_tags).toEqual(['New'])
|
||||||
|
expect(component.suggestions().dates).toEqual(['2024-01-01'])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should only fetch sources not yet fetched for the document', () => {
|
||||||
|
settingsService.set(SETTINGS_KEYS.DOCUMENT_EDITING_AUTO_SUGGEST, false)
|
||||||
|
settingsService.set(
|
||||||
|
SETTINGS_KEYS.DOCUMENT_EDITING_SUGGESTION_SOURCE,
|
||||||
|
SuggestionSource.ML
|
||||||
|
)
|
||||||
|
const getSetting = settingsService.get.bind(settingsService)
|
||||||
|
jest
|
||||||
|
.spyOn(settingsService, 'get')
|
||||||
|
.mockImplementation((key) =>
|
||||||
|
key === SETTINGS_KEYS.AI_ENABLED ? true : getSetting(key)
|
||||||
|
)
|
||||||
|
const suggestionsSpy = jest
|
||||||
|
.spyOn(documentService, 'getSuggestions')
|
||||||
|
.mockReturnValue(of({ tags: [42] }))
|
||||||
|
const aiSuggestionsSpy = jest
|
||||||
|
.spyOn(documentService, 'getAiSuggestions')
|
||||||
|
.mockReturnValue(of({ tags: [43] }))
|
||||||
|
initNormally()
|
||||||
|
|
||||||
|
component.getSuggestions()
|
||||||
|
expect(suggestionsSpy).toHaveBeenCalledTimes(1)
|
||||||
|
expect(aiSuggestionsSpy).not.toHaveBeenCalled()
|
||||||
|
|
||||||
|
component.getSuggestions(SuggestionSource.Both)
|
||||||
|
expect(suggestionsSpy).toHaveBeenCalledTimes(1)
|
||||||
|
expect(aiSuggestionsSpy).toHaveBeenCalledTimes(1)
|
||||||
|
expect(component.suggestions().tags).toEqual([42, 43])
|
||||||
|
|
||||||
|
component.getSuggestions(SuggestionSource.Both)
|
||||||
|
expect(suggestionsSpy).toHaveBeenCalledTimes(1)
|
||||||
|
expect(aiSuggestionsSpy).toHaveBeenCalledTimes(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should use the per-document source override and reset it on document change', () => {
|
||||||
|
settingsService.set(SETTINGS_KEYS.DOCUMENT_EDITING_AUTO_SUGGEST, false)
|
||||||
|
const getSetting = settingsService.get.bind(settingsService)
|
||||||
|
jest
|
||||||
|
.spyOn(settingsService, 'get')
|
||||||
|
.mockImplementation((key) =>
|
||||||
|
key === SETTINGS_KEYS.AI_ENABLED ? true : getSetting(key)
|
||||||
|
)
|
||||||
|
initNormally()
|
||||||
|
expect(component.suggestionSource).toEqual(SuggestionSource.AI)
|
||||||
|
component.suggestionSourceOverride.set(SuggestionSource.ML)
|
||||||
|
expect(component.suggestionSource).toEqual(SuggestionSource.ML)
|
||||||
|
|
||||||
|
jest
|
||||||
|
.spyOn(documentService, 'get')
|
||||||
|
.mockReturnValueOnce(of(Object.assign({}, doc)))
|
||||||
|
;(component as any).loadDocument(doc.id, true)
|
||||||
|
expect(component.suggestionSourceOverride()).toBeNull()
|
||||||
|
expect(component.fetchedSuggestionSources()).toEqual([])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should keep suggestions from one source if the other fails', () => {
|
||||||
|
settingsService.set(
|
||||||
|
SETTINGS_KEYS.DOCUMENT_EDITING_SUGGESTION_SOURCE,
|
||||||
|
SuggestionSource.Both
|
||||||
|
)
|
||||||
|
const getSetting = settingsService.get.bind(settingsService)
|
||||||
|
jest
|
||||||
|
.spyOn(settingsService, 'get')
|
||||||
|
.mockImplementation((key) =>
|
||||||
|
key === SETTINGS_KEYS.AI_ENABLED ? true : getSetting(key)
|
||||||
|
)
|
||||||
|
const errorSpy = jest.spyOn(toastService, 'showError')
|
||||||
|
jest
|
||||||
|
.spyOn(documentService, 'getSuggestions')
|
||||||
|
.mockReturnValue(of({ tags: [42] }))
|
||||||
|
jest
|
||||||
|
.spyOn(documentService, 'getAiSuggestions')
|
||||||
|
.mockReturnValue(throwError(() => new Error('failed')))
|
||||||
|
initNormally()
|
||||||
|
expect(errorSpy).toHaveBeenCalled()
|
||||||
|
expect(component.suggestions().tags).toEqual([42])
|
||||||
|
expect(component.fetchedSuggestionSources()).toEqual([SuggestionSource.ML])
|
||||||
|
})
|
||||||
|
|
||||||
it('should show error if needed for get suggestions', () => {
|
it('should show error if needed for get suggestions', () => {
|
||||||
const suggestionsSpy = jest.spyOn(documentService, 'getSuggestions')
|
const suggestionsSpy = jest.spyOn(documentService, 'getSuggestions')
|
||||||
const errorSpy = jest.spyOn(toastService, 'showError')
|
const errorSpy = jest.spyOn(toastService, 'showError')
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ import {
|
|||||||
import { dirtyCheck, DirtyComponent } from '@ngneat/dirty-check-forms'
|
import { dirtyCheck, DirtyComponent } from '@ngneat/dirty-check-forms'
|
||||||
import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons'
|
import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons'
|
||||||
import { DeviceDetectorService } from 'ngx-device-detector'
|
import { DeviceDetectorService } from 'ngx-device-detector'
|
||||||
import { BehaviorSubject, Observable, of, Subject, timer } from 'rxjs'
|
import { BehaviorSubject, merge, Observable, of, Subject, timer } from 'rxjs'
|
||||||
import {
|
import {
|
||||||
catchError,
|
catchError,
|
||||||
debounceTime,
|
debounceTime,
|
||||||
@@ -48,7 +48,10 @@ import { DataType } from 'src/app/data/datatype'
|
|||||||
import { Document, DocumentVersionInfo } from 'src/app/data/document'
|
import { Document, DocumentVersionInfo } from 'src/app/data/document'
|
||||||
import { DocumentMetadata } from 'src/app/data/document-metadata'
|
import { DocumentMetadata } from 'src/app/data/document-metadata'
|
||||||
import { DocumentNote } from 'src/app/data/document-note'
|
import { DocumentNote } from 'src/app/data/document-note'
|
||||||
import { DocumentSuggestions } from 'src/app/data/document-suggestions'
|
import {
|
||||||
|
DocumentSuggestions,
|
||||||
|
mergeSuggestions,
|
||||||
|
} from 'src/app/data/document-suggestions'
|
||||||
import { DocumentType } from 'src/app/data/document-type'
|
import { DocumentType } from 'src/app/data/document-type'
|
||||||
import { FilterRule } from 'src/app/data/filter-rule'
|
import { FilterRule } from 'src/app/data/filter-rule'
|
||||||
import {
|
import {
|
||||||
@@ -63,7 +66,7 @@ import {
|
|||||||
import { ObjectWithId } from 'src/app/data/object-with-id'
|
import { ObjectWithId } from 'src/app/data/object-with-id'
|
||||||
import { StoragePath } from 'src/app/data/storage-path'
|
import { StoragePath } from 'src/app/data/storage-path'
|
||||||
import { Tag } from 'src/app/data/tag'
|
import { Tag } from 'src/app/data/tag'
|
||||||
import { SETTINGS_KEYS } from 'src/app/data/ui-settings'
|
import { SETTINGS_KEYS, SuggestionSource } from 'src/app/data/ui-settings'
|
||||||
import { User } from 'src/app/data/user'
|
import { User } from 'src/app/data/user'
|
||||||
import { IfPermissionsDirective } from 'src/app/directives/if-permissions.directive'
|
import { IfPermissionsDirective } from 'src/app/directives/if-permissions.directive'
|
||||||
import { CustomDatePipe } from 'src/app/pipes/custom-date.pipe'
|
import { CustomDatePipe } from 'src/app/pipes/custom-date.pipe'
|
||||||
@@ -240,6 +243,10 @@ export class DocumentDetailComponent
|
|||||||
private readonly autoSuggestSetting = this.settings.getSignal<boolean>(
|
private readonly autoSuggestSetting = this.settings.getSignal<boolean>(
|
||||||
SETTINGS_KEYS.DOCUMENT_EDITING_AUTO_SUGGEST
|
SETTINGS_KEYS.DOCUMENT_EDITING_AUTO_SUGGEST
|
||||||
)
|
)
|
||||||
|
private readonly suggestionSourceSetting =
|
||||||
|
this.settings.getSignal<SuggestionSource>(
|
||||||
|
SETTINGS_KEYS.DOCUMENT_EDITING_SUGGESTION_SOURCE
|
||||||
|
)
|
||||||
private readonly hiddenFieldsSetting = this.settings.getSignal<
|
private readonly hiddenFieldsSetting = this.settings.getSignal<
|
||||||
DocumentDetailFieldID[]
|
DocumentDetailFieldID[]
|
||||||
>(SETTINGS_KEYS.DOCUMENT_DETAILS_HIDDEN_FIELDS)
|
>(SETTINGS_KEYS.DOCUMENT_DETAILS_HIDDEN_FIELDS)
|
||||||
@@ -261,6 +268,9 @@ export class DocumentDetailComponent
|
|||||||
readonly metadata = signal<DocumentMetadata>(undefined)
|
readonly metadata = signal<DocumentMetadata>(undefined)
|
||||||
readonly suggestions = signal<DocumentSuggestions>(undefined)
|
readonly suggestions = signal<DocumentSuggestions>(undefined)
|
||||||
readonly suggestionsLoading = signal(false)
|
readonly suggestionsLoading = signal(false)
|
||||||
|
// per-document, resets on navigation
|
||||||
|
readonly suggestionSourceOverride = signal<SuggestionSource>(null)
|
||||||
|
readonly fetchedSuggestionSources = signal<SuggestionSource[]>([])
|
||||||
readonly users = signal<User[]>(undefined)
|
readonly users = signal<User[]>(undefined)
|
||||||
|
|
||||||
readonly title = signal<string>(undefined)
|
readonly title = signal<string>(undefined)
|
||||||
@@ -365,6 +375,15 @@ export class DocumentDetailComponent
|
|||||||
return this.autoSuggestSetting()
|
return this.autoSuggestSetting()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
get defaultSuggestionSource(): SuggestionSource {
|
||||||
|
return this.aiEnabled ? this.suggestionSourceSetting() : SuggestionSource.ML
|
||||||
|
}
|
||||||
|
|
||||||
|
get suggestionSource(): SuggestionSource {
|
||||||
|
if (!this.aiEnabled) return SuggestionSource.ML
|
||||||
|
return this.suggestionSourceOverride() ?? this.defaultSuggestionSource
|
||||||
|
}
|
||||||
|
|
||||||
get archiveContentRenderType(): ContentRenderType {
|
get archiveContentRenderType(): ContentRenderType {
|
||||||
const hasArchiveVersion =
|
const hasArchiveVersion =
|
||||||
this.metadata()?.has_archive_version ??
|
this.metadata()?.has_archive_version ??
|
||||||
@@ -590,6 +609,8 @@ export class DocumentDetailComponent
|
|||||||
}
|
}
|
||||||
this.documentId.set(doc.id)
|
this.documentId.set(doc.id)
|
||||||
this.suggestions.set(null)
|
this.suggestions.set(null)
|
||||||
|
this.suggestionSourceOverride.set(null)
|
||||||
|
this.fetchedSuggestionSources.set([])
|
||||||
const openDocument = this.openDocumentService.getOpenDocument(
|
const openDocument = this.openDocumentService.getOpenDocument(
|
||||||
this.documentId()
|
this.documentId()
|
||||||
)
|
)
|
||||||
@@ -1077,29 +1098,44 @@ export class DocumentDetailComponent
|
|||||||
return this.documentForm.get('custom_fields') as FormArray
|
return this.documentForm.get('custom_fields') as FormArray
|
||||||
}
|
}
|
||||||
|
|
||||||
getSuggestions() {
|
getSuggestions(source: SuggestionSource = this.suggestionSource) {
|
||||||
|
const sources = (
|
||||||
|
source === SuggestionSource.Both
|
||||||
|
? [SuggestionSource.ML, SuggestionSource.AI]
|
||||||
|
: [source]
|
||||||
|
).filter((s) => !this.fetchedSuggestionSources().includes(s))
|
||||||
|
if (!sources.length) return
|
||||||
|
|
||||||
this.suggestionsLoading.set(true)
|
this.suggestionsLoading.set(true)
|
||||||
const suggestionsObservable = this.aiEnabled
|
merge(
|
||||||
? this.documentsService.getAiSuggestions(this.documentId())
|
...sources.map((s) =>
|
||||||
: this.documentsService.getSuggestions(this.documentId())
|
(s === SuggestionSource.AI
|
||||||
suggestionsObservable
|
? this.documentsService.getAiSuggestions(this.documentId())
|
||||||
|
: this.documentsService.getSuggestions(this.documentId())
|
||||||
|
).pipe(
|
||||||
|
first(),
|
||||||
|
map((result) => ({ source: s, result })),
|
||||||
|
catchError((error) => {
|
||||||
|
this.toastService.showError(
|
||||||
|
$localize`Error retrieving suggestions.`,
|
||||||
|
error
|
||||||
|
)
|
||||||
|
return of(null)
|
||||||
|
})
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
.pipe(
|
.pipe(
|
||||||
first(),
|
|
||||||
takeUntil(this.unsubscribeNotifier),
|
takeUntil(this.unsubscribeNotifier),
|
||||||
takeUntil(this.docChangeNotifier),
|
takeUntil(this.docChangeNotifier),
|
||||||
finalize(() => this.suggestionsLoading.set(false))
|
finalize(() => this.suggestionsLoading.set(false))
|
||||||
)
|
)
|
||||||
.subscribe({
|
.subscribe((response) => {
|
||||||
next: (result) => {
|
if (!response) return
|
||||||
this.suggestions.set(result)
|
this.fetchedSuggestionSources.update((f) => [...f, response.source])
|
||||||
},
|
this.suggestions.set(
|
||||||
error: (error) => {
|
mergeSuggestions(this.suggestions(), response.result)
|
||||||
this.suggestions.set(null)
|
)
|
||||||
this.toastService.showError(
|
|
||||||
$localize`Error retrieving suggestions.`,
|
|
||||||
error
|
|
||||||
)
|
|
||||||
},
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -146,6 +146,19 @@ describe('DocumentListComponent', () => {
|
|||||||
expect(reloadSpy).toHaveBeenCalled()
|
expect(reloadSpy).toHaveBeenCalled()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('should stop reloading on document deleted after destroy', () => {
|
||||||
|
const reloadSpy = jest.spyOn(documentListService, 'reload')
|
||||||
|
const documentDeletedSubject = new Subject<boolean>()
|
||||||
|
jest
|
||||||
|
.spyOn(websocketStatusService, 'onDocumentDeleted')
|
||||||
|
.mockReturnValue(documentDeletedSubject)
|
||||||
|
fixture.detectChanges()
|
||||||
|
fixture.destroy()
|
||||||
|
reloadSpy.mockClear()
|
||||||
|
documentDeletedSubject.next(true)
|
||||||
|
expect(reloadSpy).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
it('should show score sort fields on fulltext queries', () => {
|
it('should show score sort fields on fulltext queries', () => {
|
||||||
documentListService.setFilterRules([
|
documentListService.setFilterRules([
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -270,9 +270,12 @@ export class DocumentListComponent
|
|||||||
this.list.reload()
|
this.list.reload()
|
||||||
})
|
})
|
||||||
|
|
||||||
this.websocketStatusService.onDocumentDeleted().subscribe(() => {
|
this.websocketStatusService
|
||||||
this.list.reload()
|
.onDocumentDeleted()
|
||||||
})
|
.pipe(takeUntil(this.unsubscribeNotifier))
|
||||||
|
.subscribe(() => {
|
||||||
|
this.list.reload()
|
||||||
|
})
|
||||||
|
|
||||||
this.route.paramMap
|
this.route.paramMap
|
||||||
.pipe(
|
.pipe(
|
||||||
|
|||||||
@@ -15,3 +15,33 @@ export interface DocumentSuggestions {
|
|||||||
|
|
||||||
dates?: string[] // ISO-formatted date string e.g. 2022-11-03
|
dates?: string[] // ISO-formatted date string e.g. 2022-11-03
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const union = <T>(a: T[] = [], b: T[] = []): T[] => [...new Set([...a, ...b])]
|
||||||
|
|
||||||
|
export function mergeSuggestions(
|
||||||
|
a: DocumentSuggestions,
|
||||||
|
b: DocumentSuggestions
|
||||||
|
): DocumentSuggestions {
|
||||||
|
if (!a) return b
|
||||||
|
return {
|
||||||
|
title: a.title || b.title,
|
||||||
|
tags: union(a.tags, b.tags),
|
||||||
|
suggested_tags: union(a.suggested_tags, b.suggested_tags),
|
||||||
|
correspondents: union(a.correspondents, b.correspondents),
|
||||||
|
suggested_correspondents: union(
|
||||||
|
a.suggested_correspondents,
|
||||||
|
b.suggested_correspondents
|
||||||
|
),
|
||||||
|
document_types: union(a.document_types, b.document_types),
|
||||||
|
suggested_document_types: union(
|
||||||
|
a.suggested_document_types,
|
||||||
|
b.suggested_document_types
|
||||||
|
),
|
||||||
|
storage_paths: union(a.storage_paths, b.storage_paths),
|
||||||
|
suggested_storage_paths: union(
|
||||||
|
a.suggested_storage_paths,
|
||||||
|
b.suggested_storage_paths
|
||||||
|
),
|
||||||
|
dates: union(a.dates, b.dates),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -353,6 +353,14 @@ export const PaperlessConfigOptions: ConfigOption[] = [
|
|||||||
config_key: 'PAPERLESS_AI_LLM_EMBEDDING_MODEL',
|
config_key: 'PAPERLESS_AI_LLM_EMBEDDING_MODEL',
|
||||||
category: ConfigCategory.AI,
|
category: ConfigCategory.AI,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
key: 'llm_embedding_api_key',
|
||||||
|
title: $localize`LLM Embedding API Key`,
|
||||||
|
type: ConfigOptionType.Password,
|
||||||
|
note: $localize`Used for embeddings when set, otherwise LLM API key is used.`,
|
||||||
|
config_key: 'PAPERLESS_AI_LLM_EMBEDDING_API_KEY',
|
||||||
|
category: ConfigCategory.AI,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
key: 'llm_embedding_endpoint',
|
key: 'llm_embedding_endpoint',
|
||||||
title: $localize`LLM Embedding Endpoint`,
|
title: $localize`LLM Embedding Endpoint`,
|
||||||
@@ -457,6 +465,7 @@ export interface PaperlessConfig extends ObjectWithId {
|
|||||||
ai_enabled: boolean
|
ai_enabled: boolean
|
||||||
llm_embedding_backend: string
|
llm_embedding_backend: string
|
||||||
llm_embedding_model: string
|
llm_embedding_model: string
|
||||||
|
llm_embedding_api_key: string
|
||||||
llm_embedding_endpoint: string
|
llm_embedding_endpoint: string
|
||||||
llm_embedding_chunk_size: number
|
llm_embedding_chunk_size: number
|
||||||
llm_context_size: number
|
llm_context_size: number
|
||||||
|
|||||||
@@ -20,6 +20,12 @@ export enum GlobalSearchType {
|
|||||||
TITLE_CONTENT = 'title-content',
|
TITLE_CONTENT = 'title-content',
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export enum SuggestionSource {
|
||||||
|
ML = 'ml',
|
||||||
|
AI = 'ai',
|
||||||
|
Both = 'both',
|
||||||
|
}
|
||||||
|
|
||||||
export enum CollapsibleSection {
|
export enum CollapsibleSection {
|
||||||
ATTRIBUTES = 'attributes',
|
ATTRIBUTES = 'attributes',
|
||||||
}
|
}
|
||||||
@@ -98,6 +104,8 @@ export const SETTINGS_KEYS = {
|
|||||||
'general-settings:document-editing:overlay-thumbnail',
|
'general-settings:document-editing:overlay-thumbnail',
|
||||||
DOCUMENT_EDITING_AUTO_SUGGEST:
|
DOCUMENT_EDITING_AUTO_SUGGEST:
|
||||||
'general-settings:document-editing:auto-suggest',
|
'general-settings:document-editing:auto-suggest',
|
||||||
|
DOCUMENT_EDITING_SUGGESTION_SOURCE:
|
||||||
|
'general-settings:document-editing:suggestion-source',
|
||||||
DOCUMENT_DETAILS_HIDDEN_FIELDS:
|
DOCUMENT_DETAILS_HIDDEN_FIELDS:
|
||||||
'general-settings:document-details:hidden-fields',
|
'general-settings:document-details:hidden-fields',
|
||||||
SEARCH_DB_ONLY: 'general-settings:search:db-only',
|
SEARCH_DB_ONLY: 'general-settings:search:db-only',
|
||||||
@@ -326,6 +334,11 @@ export const SETTINGS: UiSetting[] = [
|
|||||||
type: 'boolean',
|
type: 'boolean',
|
||||||
default: true,
|
default: true,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
key: SETTINGS_KEYS.DOCUMENT_EDITING_SUGGESTION_SOURCE,
|
||||||
|
type: 'string',
|
||||||
|
default: SuggestionSource.AI,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
key: SETTINGS_KEYS.DOCUMENT_DETAILS_HIDDEN_FIELDS,
|
key: SETTINGS_KEYS.DOCUMENT_DETAILS_HIDDEN_FIELDS,
|
||||||
type: 'array',
|
type: 'array',
|
||||||
|
|||||||
@@ -74,6 +74,7 @@ import {
|
|||||||
clipboardCheckFill,
|
clipboardCheckFill,
|
||||||
clipboardFill,
|
clipboardFill,
|
||||||
clockHistory,
|
clockHistory,
|
||||||
|
cpu,
|
||||||
creditCard,
|
creditCard,
|
||||||
dash,
|
dash,
|
||||||
dashCircle,
|
dashCircle,
|
||||||
@@ -118,6 +119,7 @@ import {
|
|||||||
infoCircle,
|
infoCircle,
|
||||||
journalBookmarkFill,
|
journalBookmarkFill,
|
||||||
journals,
|
journals,
|
||||||
|
lightbulb,
|
||||||
link,
|
link,
|
||||||
list,
|
list,
|
||||||
listNested,
|
listNested,
|
||||||
@@ -322,6 +324,7 @@ const icons = {
|
|||||||
clipboardCheckFill,
|
clipboardCheckFill,
|
||||||
clipboardFill,
|
clipboardFill,
|
||||||
clockHistory,
|
clockHistory,
|
||||||
|
cpu,
|
||||||
cash,
|
cash,
|
||||||
creditCard,
|
creditCard,
|
||||||
dash,
|
dash,
|
||||||
@@ -367,6 +370,7 @@ const icons = {
|
|||||||
infoCircle,
|
infoCircle,
|
||||||
journalBookmarkFill,
|
journalBookmarkFill,
|
||||||
journals,
|
journals,
|
||||||
|
lightbulb,
|
||||||
link,
|
link,
|
||||||
list,
|
list,
|
||||||
listNested,
|
listNested,
|
||||||
|
|||||||
@@ -292,6 +292,7 @@ a.btn-link:active,
|
|||||||
a.btn-link:focus-visible,
|
a.btn-link:focus-visible,
|
||||||
.btn-link:hover,
|
.btn-link:hover,
|
||||||
.btn-link:active,
|
.btn-link:active,
|
||||||
|
.btn-link.show,
|
||||||
.btn-link:focus-visible {
|
.btn-link:focus-visible {
|
||||||
color: var(--pngx-primary-lighten-10) !important;
|
color: var(--pngx-primary-lighten-10) !important;
|
||||||
.primary-light & {
|
.primary-light & {
|
||||||
|
|||||||
+151
@@ -0,0 +1,151 @@
|
|||||||
|
"""Fixtures available to every Paperless-ngx app.
|
||||||
|
|
||||||
|
Loaded automatically for every test path. Keep module-scope imports minimal:
|
||||||
|
this file is imported for every session, so anything heavy belongs inside
|
||||||
|
the fixture body that needs it.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from collections.abc import Generator
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from django.contrib.auth.models import User
|
||||||
|
from pytest_django.fixtures import Settings
|
||||||
|
from rest_framework.test import APIClient
|
||||||
|
|
||||||
|
from paperless_testing.dirs import PaperlessDirs
|
||||||
|
from paperless_testing.fakes.progress import FakeProgressManager
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(scope="session", autouse=True)
|
||||||
|
def faker_session_locale() -> str:
|
||||||
|
"""Pin Faker's locale so generated data does not follow the host locale.
|
||||||
|
|
||||||
|
The seed itself is left to pytest-randomly, which derives one per run.
|
||||||
|
"""
|
||||||
|
return "en_US"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def _fast_password_hasher(settings: Settings) -> None:
|
||||||
|
"""Hash test passwords with MD5 instead of Django's default PBKDF2.
|
||||||
|
|
||||||
|
PBKDF2 is deliberately slow, and every ``admin_user`` or
|
||||||
|
``create_superuser`` call pays for it: about 600 ms each. No test depends
|
||||||
|
on the hash format, only on ``check_password`` and on the stored value
|
||||||
|
changing when the password does.
|
||||||
|
"""
|
||||||
|
settings.PASSWORD_HASHERS = ["django.contrib.auth.hashers.MD5PasswordHasher"]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def _clear_content_type_caches() -> None:
|
||||||
|
"""Clear Django's ContentType cache and guardian's lru_cache before each test.
|
||||||
|
|
||||||
|
Tests that delete and reinsert ContentType/Permission rows (e.g. the
|
||||||
|
importer) corrupt both caches. Without this fixture a subsequent test on
|
||||||
|
the same xdist worker sees stale ContentType objects and guardian raises
|
||||||
|
MixedContentTypeError.
|
||||||
|
"""
|
||||||
|
from django.contrib.contenttypes.models import ContentType
|
||||||
|
from guardian.shortcuts import clear_ct_cache
|
||||||
|
|
||||||
|
ContentType.objects.clear_cache()
|
||||||
|
clear_ct_cache()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def _clear_django_caches() -> None:
|
||||||
|
"""Clear every configured cache before each test.
|
||||||
|
|
||||||
|
Cached values outlive the test that wrote them: the classifier keys its
|
||||||
|
vectorized content on a hash of the content itself, so a second test
|
||||||
|
generating the same fixture data takes the cache-hit path and never calls
|
||||||
|
the code it is asserting against.
|
||||||
|
"""
|
||||||
|
from django.core.cache import caches
|
||||||
|
|
||||||
|
for cache in caches.all(initialized_only=False):
|
||||||
|
cache.clear()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def paperless_dirs(
|
||||||
|
tmp_path: Path,
|
||||||
|
settings: Settings,
|
||||||
|
) -> Generator[PaperlessDirs, None, None]:
|
||||||
|
"""The standard temp directory layout, applied to Django settings."""
|
||||||
|
from documents.search import reset_backend
|
||||||
|
from paperless_testing.dirs import build_paperless_dirs
|
||||||
|
from paperless_testing.dirs import dirs_settings
|
||||||
|
|
||||||
|
dirs = build_paperless_dirs(tmp_path)
|
||||||
|
for name, value in dirs_settings(dirs).items():
|
||||||
|
setattr(settings, name, value)
|
||||||
|
|
||||||
|
# Not directory settings, but they are needed alongside the layout by the
|
||||||
|
# sanity checker tests.
|
||||||
|
settings.IGNORABLE_FILES = {".DS_Store", "Thumbs.db", "desktop.ini"}
|
||||||
|
settings.APP_LOGO = ""
|
||||||
|
|
||||||
|
reset_backend()
|
||||||
|
yield dirs
|
||||||
|
reset_backend()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def rest_api_client() -> APIClient:
|
||||||
|
"""The basic DRF APIClient, unauthenticated."""
|
||||||
|
from rest_framework.test import APIClient
|
||||||
|
|
||||||
|
return APIClient()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def regular_user(db: None) -> User:
|
||||||
|
"""Unprivileged user for permission boundary tests."""
|
||||||
|
from paperless_testing.factories import UserFactory
|
||||||
|
|
||||||
|
return UserFactory(username="regular")
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def admin_client(rest_api_client: APIClient, admin_user: User) -> APIClient:
|
||||||
|
"""Admin client pre-authenticated and sending the v10 Accept header."""
|
||||||
|
rest_api_client.force_authenticate(user=admin_user)
|
||||||
|
rest_api_client.credentials(HTTP_ACCEPT="application/json; version=10")
|
||||||
|
return rest_api_client
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def v9_client(rest_api_client: APIClient, admin_user: User) -> APIClient:
|
||||||
|
"""Admin client pre-authenticated and sending the v9 Accept header."""
|
||||||
|
rest_api_client.force_authenticate(user=admin_user)
|
||||||
|
rest_api_client.credentials(HTTP_ACCEPT="application/json; version=9")
|
||||||
|
return rest_api_client
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def user_client(rest_api_client: APIClient, regular_user: User) -> APIClient:
|
||||||
|
"""Regular-user client pre-authenticated and sending the v10 Accept header."""
|
||||||
|
rest_api_client.force_authenticate(user=regular_user)
|
||||||
|
rest_api_client.credentials(HTTP_ACCEPT="application/json; version=10")
|
||||||
|
return rest_api_client
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def fake_progress_manager(
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
) -> type[FakeProgressManager]:
|
||||||
|
"""Replace documents.tasks.ProgressManager with the fake, so consuming a file
|
||||||
|
in a test never tries to reach a broker."""
|
||||||
|
from paperless_testing.fakes.progress import FakeProgressManager
|
||||||
|
|
||||||
|
monkeypatch.setattr("documents.tasks.ProgressManager", FakeProgressManager)
|
||||||
|
return FakeProgressManager
|
||||||
@@ -26,7 +26,6 @@ class DocumentsConfig(AppConfig):
|
|||||||
document_consumption_finished.connect(set_document_type)
|
document_consumption_finished.connect(set_document_type)
|
||||||
document_consumption_finished.connect(set_tags)
|
document_consumption_finished.connect(set_tags)
|
||||||
document_consumption_finished.connect(set_storage_path)
|
document_consumption_finished.connect(set_storage_path)
|
||||||
document_consumption_finished.connect(add_to_index)
|
|
||||||
document_consumption_finished.connect(run_workflows_added)
|
document_consumption_finished.connect(run_workflows_added)
|
||||||
document_consumption_finished.connect(add_to_index)
|
document_consumption_finished.connect(add_to_index)
|
||||||
document_consumption_finished.connect(add_or_update_document_in_llm_index)
|
document_consumption_finished.connect(add_or_update_document_in_llm_index)
|
||||||
|
|||||||
@@ -857,8 +857,9 @@ class ConsumerPlugin(
|
|||||||
self.log.debug(f"Creation date from parse_date: {create_date}")
|
self.log.debug(f"Creation date from parse_date: {create_date}")
|
||||||
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 = datetime.datetime.fromtimestamp(
|
||||||
datetime.datetime.fromtimestamp(stats.st_mtime),
|
stats.st_mtime,
|
||||||
|
tz=timezone.get_current_timezone(),
|
||||||
)
|
)
|
||||||
self.log.debug(f"Creation date from st_mtime: {create_date}")
|
self.log.debug(f"Creation date from st_mtime: {create_date}")
|
||||||
|
|
||||||
|
|||||||
@@ -196,52 +196,49 @@ class WriteBatch:
|
|||||||
return self._raw_writer
|
return self._raw_writer
|
||||||
|
|
||||||
def __enter__(self) -> Self:
|
def __enter__(self) -> Self:
|
||||||
if self._backend._path is not None:
|
lock_path = self._backend._path / ".tantivy.lock"
|
||||||
lock_path = self._backend._path / ".tantivy.lock"
|
self._lock = filelock.FileLock(str(lock_path))
|
||||||
self._lock = filelock.FileLock(str(lock_path))
|
for attempt in range(_LOCK_RETRY_ATTEMPTS):
|
||||||
for attempt in range(_LOCK_RETRY_ATTEMPTS):
|
try:
|
||||||
try:
|
self._lock.acquire(timeout=self._lock_timeout)
|
||||||
self._lock.acquire(timeout=self._lock_timeout)
|
break
|
||||||
break
|
except filelock.Timeout:
|
||||||
except filelock.Timeout:
|
if attempt == _LOCK_RETRY_ATTEMPTS - 1:
|
||||||
if attempt == _LOCK_RETRY_ATTEMPTS - 1:
|
raise SearchIndexLockError(
|
||||||
raise SearchIndexLockError(
|
f"Could not acquire index lock after {_LOCK_RETRY_ATTEMPTS} "
|
||||||
f"Could not acquire index lock after {_LOCK_RETRY_ATTEMPTS} "
|
f"attempts (timeout={self._lock_timeout}s each)",
|
||||||
f"attempts (timeout={self._lock_timeout}s each)",
|
|
||||||
)
|
|
||||||
sleep_s = random.uniform(
|
|
||||||
0,
|
|
||||||
min(_LOCK_BACKOFF_CAP, _LOCK_BACKOFF_BASE * (2**attempt)),
|
|
||||||
)
|
)
|
||||||
logger.debug(
|
sleep_s = random.uniform(
|
||||||
"Index lock contention; retrying in %.2fs (attempt %d/%d)",
|
0,
|
||||||
sleep_s,
|
min(_LOCK_BACKOFF_CAP, _LOCK_BACKOFF_BASE * (2**attempt)),
|
||||||
attempt + 1,
|
)
|
||||||
_LOCK_RETRY_ATTEMPTS,
|
logger.debug(
|
||||||
)
|
"Index lock contention; retrying in %.2fs (attempt %d/%d)",
|
||||||
time.sleep(sleep_s)
|
sleep_s,
|
||||||
|
attempt + 1,
|
||||||
|
_LOCK_RETRY_ATTEMPTS,
|
||||||
|
)
|
||||||
|
time.sleep(sleep_s)
|
||||||
|
|
||||||
# Open a fresh Index (and thus a fresh Tantivy ManagedDirectory)
|
# Open a fresh Index (and thus a fresh Tantivy ManagedDirectory)
|
||||||
# for the write, rather than reusing the process-local cached
|
# for the write, rather than reusing the process-local cached
|
||||||
# index. ManagedDirectory loads its GC bookkeeping (.managed.json)
|
# index. ManagedDirectory loads its GC bookkeeping (.managed.json)
|
||||||
# once, at construction, and never re-reads it; paperless runs
|
# once, at construction, and never re-reads it; paperless runs
|
||||||
# several long-lived processes (Granian workers, Celery workers)
|
# several long-lived processes (Granian workers, Celery workers)
|
||||||
# that take turns writing under the file lock above. A cached,
|
# that take turns writing under the file lock above. A cached,
|
||||||
# long-lived writer index would carry a stale managed-files view
|
# long-lived writer index would carry a stale managed-files view
|
||||||
# and, on commit, overwrite .managed.json with that stale view -
|
# and, on commit, overwrite .managed.json with that stale view -
|
||||||
# permanently losing track of segment files other processes
|
# permanently losing track of segment files other processes
|
||||||
# registered in the meantime, so they can never be garbage
|
# registered in the meantime, so they can never be garbage
|
||||||
# collected. Reopening fresh here always picks up the current
|
# collected. Reopening fresh here always picks up the current
|
||||||
# on-disk state. The long-lived self._backend._index is used for
|
# on-disk state. The long-lived self._backend._index is used for
|
||||||
# reads only and is reloaded (not reopened) after commit below.
|
# reads only and is reloaded (not reopened) after commit below.
|
||||||
write_index = tantivy.Index(
|
write_index = tantivy.Index(
|
||||||
build_schema(),
|
build_schema(),
|
||||||
path=str(self._backend._path),
|
path=str(self._backend._path),
|
||||||
)
|
)
|
||||||
register_tokenizers(write_index, settings.SEARCH_LANGUAGE)
|
register_tokenizers(write_index, settings.SEARCH_LANGUAGE)
|
||||||
self._raw_writer = write_index.writer()
|
self._raw_writer = write_index.writer()
|
||||||
else:
|
|
||||||
self._raw_writer = self._backend._index.writer()
|
|
||||||
return self
|
return self
|
||||||
|
|
||||||
def __exit__(self, exc_type, exc_val, exc_tb):
|
def __exit__(self, exc_type, exc_val, exc_tb):
|
||||||
@@ -372,9 +369,8 @@ class TantivyBackend:
|
|||||||
Tantivy search backend with explicit lifecycle management.
|
Tantivy search backend with explicit lifecycle management.
|
||||||
|
|
||||||
Provides full-text search capabilities using the Tantivy search engine.
|
Provides full-text search capabilities using the Tantivy search engine.
|
||||||
Supports in-memory indexes (for testing) and persistent on-disk indexes
|
Keeps a persistent on-disk index. Handles document indexing, search queries,
|
||||||
(for production use). Handles document indexing, search queries, autocompletion,
|
autocompletion, and "more like this" functionality.
|
||||||
and "more like this" functionality.
|
|
||||||
|
|
||||||
The backend manages its own connection lifecycle and can be reset when
|
The backend manages its own connection lifecycle and can be reset when
|
||||||
the underlying index directory changes (e.g., during test isolation).
|
the underlying index directory changes (e.g., during test isolation).
|
||||||
@@ -408,9 +404,7 @@ class TantivyBackend:
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
def __init__(self, path: Path | None = None):
|
def __init__(self, path: Path):
|
||||||
# path=None → in-memory index (for tests)
|
|
||||||
# path=some_dir → on-disk index (for production)
|
|
||||||
self._path = path
|
self._path = path
|
||||||
self._raw_index: tantivy.Index | None = None
|
self._raw_index: tantivy.Index | None = None
|
||||||
self._raw_schema: tantivy.Schema | None = None
|
self._raw_schema: tantivy.Schema | None = None
|
||||||
@@ -429,16 +423,13 @@ class TantivyBackend:
|
|||||||
"""
|
"""
|
||||||
Open or rebuild the index as needed.
|
Open or rebuild the index as needed.
|
||||||
|
|
||||||
For disk-based indexes, checks if rebuilding is needed due to schema
|
Checks if rebuilding is needed due to schema version or language
|
||||||
version or language changes. Registers custom tokenizers after opening.
|
changes. Registers custom tokenizers after opening.
|
||||||
Safe to call multiple times - subsequent calls are no-ops.
|
Safe to call multiple times - subsequent calls are no-ops.
|
||||||
"""
|
"""
|
||||||
if self._raw_index is not None:
|
if self._raw_index is not None:
|
||||||
return # pragma: no cover
|
return # pragma: no cover
|
||||||
if self._path is not None:
|
self._raw_index = open_or_rebuild_index(self._path)
|
||||||
self._raw_index = open_or_rebuild_index(self._path)
|
|
||||||
else:
|
|
||||||
self._raw_index = tantivy.Index(build_schema())
|
|
||||||
register_tokenizers(self._raw_index, settings.SEARCH_LANGUAGE)
|
register_tokenizers(self._raw_index, settings.SEARCH_LANGUAGE)
|
||||||
self._raw_schema = self._raw_index.schema
|
self._raw_schema = self._raw_index.schema
|
||||||
|
|
||||||
@@ -1102,13 +1093,9 @@ class TantivyBackend:
|
|||||||
writer's threads). Larger values buffer more docs in RAM before
|
writer's threads). Larger values buffer more docs in RAM before
|
||||||
flushing a segment, deferring merge work; they do not avoid it.
|
flushing a segment, deferring merge work; they do not avoid it.
|
||||||
"""
|
"""
|
||||||
# Create new index (on-disk or in-memory)
|
wipe_index(self._path)
|
||||||
if self._path is not None:
|
new_index = tantivy.Index(build_schema(), path=str(self._path))
|
||||||
wipe_index(self._path)
|
_write_sentinels(self._path)
|
||||||
new_index = tantivy.Index(build_schema(), path=str(self._path))
|
|
||||||
_write_sentinels(self._path)
|
|
||||||
else:
|
|
||||||
new_index = tantivy.Index(build_schema())
|
|
||||||
register_tokenizers(new_index, settings.SEARCH_LANGUAGE)
|
register_tokenizers(new_index, settings.SEARCH_LANGUAGE)
|
||||||
|
|
||||||
# Point instance at the new index so _build_tantivy_doc uses it
|
# Point instance at the new index so _build_tantivy_doc uses it
|
||||||
|
|||||||
@@ -2098,6 +2098,8 @@ class BulkEditSerializer(
|
|||||||
if not isinstance(parameters["pages"], str):
|
if not isinstance(parameters["pages"], str):
|
||||||
raise serializers.ValidationError("invalid pages specified")
|
raise serializers.ValidationError("invalid pages specified")
|
||||||
page_count = Document.objects.get(id=document_id).page_count
|
page_count = Document.objects.get(id=document_id).page_count
|
||||||
|
if not page_count:
|
||||||
|
raise serializers.ValidationError("document page count is unknown")
|
||||||
pages = []
|
pages = []
|
||||||
for group in parameters["pages"].split(","):
|
for group in parameters["pages"].split(","):
|
||||||
start, is_range, end = group.partition("-")
|
start, is_range, end = group.partition("-")
|
||||||
@@ -2107,7 +2109,7 @@ class BulkEditSerializer(
|
|||||||
except ValueError as e:
|
except ValueError as e:
|
||||||
raise serializers.ValidationError("invalid pages specified") from e
|
raise serializers.ValidationError("invalid pages specified") from e
|
||||||
# Bound the range before building it, a huge one would exhaust memory
|
# Bound the range before building it, a huge one would exhaust memory
|
||||||
if not 1 <= first <= last or (page_count and last > page_count):
|
if not 1 <= first <= last <= page_count:
|
||||||
raise serializers.ValidationError("invalid pages specified")
|
raise serializers.ValidationError("invalid pages specified")
|
||||||
pages.append(list(range(first, last + 1)))
|
pages.append(list(range(first, last + 1)))
|
||||||
parameters["pages"] = pages
|
parameters["pages"] = pages
|
||||||
|
|||||||
@@ -56,6 +56,7 @@ from documents.permissions import get_objects_for_user_owner_aware
|
|||||||
from documents.plugins.helpers import DocumentsStatusManager
|
from documents.plugins.helpers import DocumentsStatusManager
|
||||||
from documents.templating.utils import convert_format_str_to_template_format
|
from documents.templating.utils import convert_format_str_to_template_format
|
||||||
from documents.utils import compute_checksum
|
from documents.utils import compute_checksum
|
||||||
|
from documents.utils import copy_file_with_basic_stats
|
||||||
from documents.workflows.actions import build_workflow_action_context
|
from documents.workflows.actions import build_workflow_action_context
|
||||||
from documents.workflows.actions import execute_email_action
|
from documents.workflows.actions import execute_email_action
|
||||||
from documents.workflows.actions import execute_move_to_trash_action
|
from documents.workflows.actions import execute_move_to_trash_action
|
||||||
@@ -363,7 +364,11 @@ def cleanup_document_deletion(sender, instance, **kwargs) -> None:
|
|||||||
|
|
||||||
logger.debug(f"Moving {instance.source_path} to trash at {new_file_path}")
|
logger.debug(f"Moving {instance.source_path} to trash at {new_file_path}")
|
||||||
try:
|
try:
|
||||||
shutil.move(instance.source_path, new_file_path)
|
shutil.move(
|
||||||
|
instance.source_path,
|
||||||
|
new_file_path,
|
||||||
|
copy_function=copy_file_with_basic_stats,
|
||||||
|
)
|
||||||
except OSError as e:
|
except OSError as e:
|
||||||
logger.error(
|
logger.error(
|
||||||
f"Failed to move {instance.source_path} to trash at "
|
f"Failed to move {instance.source_path} to trash at "
|
||||||
|
|||||||
+18
-143
@@ -1,88 +1,41 @@
|
|||||||
import shutil
|
import shutil
|
||||||
import zoneinfo
|
|
||||||
from collections.abc import Generator
|
|
||||||
from dataclasses import dataclass
|
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import TYPE_CHECKING
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
import filelock
|
import filelock
|
||||||
import pytest
|
import pytest
|
||||||
from django.contrib.auth import get_user_model
|
|
||||||
from django.contrib.contenttypes.models import ContentType
|
|
||||||
from guardian.shortcuts import clear_ct_cache
|
|
||||||
from pytest_django.fixtures import Settings
|
|
||||||
from rest_framework.test import APIClient
|
|
||||||
|
|
||||||
from documents.tests.factories import DocumentFactory
|
from paperless_testing.factories import DocumentFactory
|
||||||
|
|
||||||
UserModelT = get_user_model()
|
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from documents.models import Document
|
from documents.models import Document
|
||||||
|
from paperless_testing.dirs import PaperlessDirs
|
||||||
|
|
||||||
@dataclass(frozen=True, slots=True)
|
|
||||||
class PaperlessDirs:
|
|
||||||
"""Standard Paperless-ngx directory layout for tests."""
|
|
||||||
|
|
||||||
media: Path
|
|
||||||
originals: Path
|
|
||||||
archive: Path
|
|
||||||
thumbnails: Path
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture(scope="session")
|
@pytest.fixture(scope="session")
|
||||||
def samples_dir() -> Path:
|
def document_samples_dir() -> Path:
|
||||||
"""Path to the shared test sample documents."""
|
"""Path to the shared test sample documents."""
|
||||||
return Path(__file__).parent / "samples" / "documents"
|
return Path(__file__).parent / "samples" / "documents"
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture()
|
|
||||||
def paperless_dirs(tmp_path: Path) -> PaperlessDirs:
|
|
||||||
"""Create and return the directory structure for testing."""
|
|
||||||
media = tmp_path / "media"
|
|
||||||
dirs = PaperlessDirs(
|
|
||||||
media=media,
|
|
||||||
originals=media / "documents" / "originals",
|
|
||||||
archive=media / "documents" / "archive",
|
|
||||||
thumbnails=media / "documents" / "thumbnails",
|
|
||||||
)
|
|
||||||
for d in (dirs.originals, dirs.archive, dirs.thumbnails):
|
|
||||||
d.mkdir(parents=True)
|
|
||||||
return dirs
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture()
|
|
||||||
def _media_settings(paperless_dirs: PaperlessDirs, settings) -> None:
|
|
||||||
"""Configure Django settings to point at temp directories."""
|
|
||||||
settings.MEDIA_ROOT = paperless_dirs.media
|
|
||||||
settings.ORIGINALS_DIR = paperless_dirs.originals
|
|
||||||
settings.ARCHIVE_DIR = paperless_dirs.archive
|
|
||||||
settings.THUMBNAIL_DIR = paperless_dirs.thumbnails
|
|
||||||
settings.MEDIA_LOCK = paperless_dirs.media / "media.lock"
|
|
||||||
settings.IGNORABLE_FILES = {".DS_Store", "Thumbs.db", "desktop.ini"}
|
|
||||||
settings.APP_LOGO = ""
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture()
|
@pytest.fixture()
|
||||||
def sample_doc(
|
def sample_doc(
|
||||||
paperless_dirs: PaperlessDirs,
|
paperless_dirs: "PaperlessDirs",
|
||||||
_media_settings: None,
|
document_samples_dir: Path,
|
||||||
samples_dir: Path,
|
|
||||||
) -> "Document":
|
) -> "Document":
|
||||||
"""Create a document with valid files and matching checksums."""
|
"""Create a document with valid files and matching checksums."""
|
||||||
with filelock.FileLock(paperless_dirs.media / "media.lock"):
|
with filelock.FileLock(paperless_dirs.media_lock):
|
||||||
shutil.copy(
|
shutil.copy(
|
||||||
samples_dir / "originals" / "0000001.pdf",
|
document_samples_dir / "originals" / "0000001.pdf",
|
||||||
paperless_dirs.originals / "0000001.pdf",
|
paperless_dirs.originals_dir / "0000001.pdf",
|
||||||
)
|
)
|
||||||
shutil.copy(
|
shutil.copy(
|
||||||
samples_dir / "archive" / "0000001.pdf",
|
document_samples_dir / "archive" / "0000001.pdf",
|
||||||
paperless_dirs.archive / "0000001.pdf",
|
paperless_dirs.archive_dir / "0000001.pdf",
|
||||||
)
|
)
|
||||||
shutil.copy(
|
shutil.copy(
|
||||||
samples_dir / "thumbnails" / "0000001.webp",
|
document_samples_dir / "thumbnails" / "0000001.webp",
|
||||||
paperless_dirs.thumbnails / "0000001.webp",
|
paperless_dirs.thumbnail_dir / "0000001.webp",
|
||||||
)
|
)
|
||||||
|
|
||||||
return DocumentFactory(
|
return DocumentFactory(
|
||||||
@@ -97,95 +50,17 @@ def sample_doc(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture()
|
@pytest.fixture
|
||||||
def _search_index(
|
def _search_index(paperless_dirs: "PaperlessDirs") -> None:
|
||||||
tmp_path: Path,
|
"""Point the search backend at a fresh, empty index directory.
|
||||||
settings: Settings,
|
|
||||||
) -> Generator[None, None, None]:
|
|
||||||
"""Create a temp index directory and point INDEX_DIR at it.
|
|
||||||
|
|
||||||
Resets the backend singleton before and after so each test gets a clean
|
paperless_dirs owns INDEX_DIR and resets the backend singleton on both
|
||||||
index rather than reusing a stale singleton from another test.
|
sides of the test, so requesting it is all that is needed.
|
||||||
"""
|
"""
|
||||||
from documents.search import reset_backend
|
|
||||||
|
|
||||||
index_dir = tmp_path / "index"
|
|
||||||
index_dir.mkdir()
|
|
||||||
settings.INDEX_DIR = index_dir
|
|
||||||
reset_backend()
|
|
||||||
yield
|
|
||||||
reset_backend()
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture()
|
|
||||||
def settings_timezone(settings: Settings) -> zoneinfo.ZoneInfo:
|
|
||||||
return zoneinfo.ZoneInfo(settings.TIME_ZONE)
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
def rest_api_client():
|
def searchable_document(_search_index: None) -> "Document":
|
||||||
"""
|
|
||||||
The basic DRF ApiClient
|
|
||||||
"""
|
|
||||||
yield APIClient()
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture()
|
|
||||||
def regular_user(django_user_model: type[UserModelT]) -> UserModelT:
|
|
||||||
"""Unprivileged authenticated user for permission boundary tests."""
|
|
||||||
return django_user_model.objects.create_user(username="regular", password="regular")
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture()
|
|
||||||
def admin_client(rest_api_client: APIClient, admin_user: UserModelT) -> APIClient:
|
|
||||||
"""Admin client pre-authenticated and sending the v10 Accept header."""
|
|
||||||
rest_api_client.force_authenticate(user=admin_user)
|
|
||||||
rest_api_client.credentials(HTTP_ACCEPT="application/json; version=10")
|
|
||||||
return rest_api_client
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture()
|
|
||||||
def v9_client(rest_api_client: APIClient, admin_user: UserModelT) -> APIClient:
|
|
||||||
"""Admin client pre-authenticated and sending the v9 Accept header."""
|
|
||||||
rest_api_client.force_authenticate(user=admin_user)
|
|
||||||
rest_api_client.credentials(HTTP_ACCEPT="application/json; version=9")
|
|
||||||
return rest_api_client
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture()
|
|
||||||
def user_client(rest_api_client: APIClient, regular_user: UserModelT) -> APIClient:
|
|
||||||
"""Regular-user client pre-authenticated and sending the v10 Accept header."""
|
|
||||||
rest_api_client.force_authenticate(user=regular_user)
|
|
||||||
rest_api_client.credentials(HTTP_ACCEPT="application/json; version=10")
|
|
||||||
return rest_api_client
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture(autouse=True)
|
|
||||||
def _clear_content_type_caches() -> None:
|
|
||||||
"""Clear Django's ContentType cache and guardian's lru_cache before each test.
|
|
||||||
|
|
||||||
Tests that delete and reinsert ContentType/Permission rows (e.g. the
|
|
||||||
importer) corrupt both caches. Without this fixture a subsequent test on
|
|
||||||
the same xdist worker sees stale ContentType objects and guardian raises
|
|
||||||
MixedContentTypeError.
|
|
||||||
"""
|
|
||||||
ContentType.objects.clear_cache()
|
|
||||||
clear_ct_cache()
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture(scope="session", autouse=True)
|
|
||||||
def faker_session_locale():
|
|
||||||
"""Set Faker locale for reproducibility."""
|
|
||||||
return "en_US"
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture(scope="session", autouse=True)
|
|
||||||
def faker_seed():
|
|
||||||
return 12345
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
|
||||||
def indexed_document(_search_index: None) -> "Document":
|
|
||||||
"""One searchable document, for tests about what the search endpoint
|
"""One searchable document, for tests about what the search endpoint
|
||||||
returns rather than about what it finds.
|
returns rather than about what it finds.
|
||||||
"""
|
"""
|
||||||
|
|||||||
@@ -0,0 +1,10 @@
|
|||||||
|
import re
|
||||||
|
|
||||||
|
|
||||||
|
def dummy_preprocess(content: str) -> str:
|
||||||
|
"""
|
||||||
|
Simpler, faster pre-processing for testing purposes
|
||||||
|
"""
|
||||||
|
content = content.lower().strip()
|
||||||
|
content = re.sub(r"\s+", " ", content)
|
||||||
|
return content
|
||||||
@@ -15,11 +15,11 @@ from rich.console import Console
|
|||||||
|
|
||||||
from documents.management.commands.document_sanity_checker import Command
|
from documents.management.commands.document_sanity_checker import Command
|
||||||
from documents.sanity_checker import SanityCheckMessages
|
from documents.sanity_checker import SanityCheckMessages
|
||||||
from documents.tests.factories import DocumentFactory
|
from paperless_testing.factories import DocumentFactory
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from documents.models import Document
|
from documents.models import Document
|
||||||
from documents.tests.conftest import PaperlessDirs
|
from paperless_testing.dirs import PaperlessDirs
|
||||||
|
|
||||||
|
|
||||||
def _render_to_string(messages: SanityCheckMessages) -> str:
|
def _render_to_string(messages: SanityCheckMessages) -> str:
|
||||||
@@ -71,7 +71,7 @@ class TestRenderResultsWithIssues:
|
|||||||
assert "INFO" in output
|
assert "INFO" in output
|
||||||
assert "No OCR data" in output
|
assert "No OCR data" in output
|
||||||
|
|
||||||
@pytest.mark.usefixtures("_media_settings")
|
@pytest.mark.usefixtures("paperless_dirs")
|
||||||
def test_global_message(self) -> None:
|
def test_global_message(self) -> None:
|
||||||
msgs = SanityCheckMessages()
|
msgs = SanityCheckMessages()
|
||||||
msgs.warning(None, "Orphaned file: /tmp/stray.pdf")
|
msgs.warning(None, "Orphaned file: /tmp/stray.pdf")
|
||||||
@@ -87,7 +87,7 @@ class TestRenderResultsWithIssues:
|
|||||||
assert "Thumbnail missing" in output
|
assert "Thumbnail missing" in output
|
||||||
assert "Checksum mismatch" in output
|
assert "Checksum mismatch" in output
|
||||||
|
|
||||||
@pytest.mark.usefixtures("_media_settings")
|
@pytest.mark.usefixtures("paperless_dirs")
|
||||||
def test_unknown_doc_pk(self) -> None:
|
def test_unknown_doc_pk(self) -> None:
|
||||||
msgs = SanityCheckMessages()
|
msgs = SanityCheckMessages()
|
||||||
msgs.error(99999, "Ghost document")
|
msgs.error(99999, "Ghost document")
|
||||||
@@ -184,7 +184,6 @@ class TestDocumentSanityCheckerCommand:
|
|||||||
assert "ERROR" in output
|
assert "ERROR" in output
|
||||||
assert "Original of document does not exist" in output
|
assert "Original of document does not exist" in output
|
||||||
|
|
||||||
@pytest.mark.usefixtures("_media_settings")
|
|
||||||
def test_checksum_mismatch(self, paperless_dirs: PaperlessDirs) -> None:
|
def test_checksum_mismatch(self, paperless_dirs: PaperlessDirs) -> None:
|
||||||
"""Lightweight document with zero-byte files triggers checksum mismatch."""
|
"""Lightweight document with zero-byte files triggers checksum mismatch."""
|
||||||
doc = DocumentFactory(
|
doc = DocumentFactory(
|
||||||
|
|||||||
@@ -9,29 +9,21 @@ from documents.search._backend import TantivyBackend
|
|||||||
from documents.search._backend import reset_backend
|
from documents.search._backend import reset_backend
|
||||||
from documents.search._schema import build_schema
|
from documents.search._schema import build_schema
|
||||||
from documents.search._tokenizer import register_tokenizers
|
from documents.search._tokenizer import register_tokenizers
|
||||||
from documents.tests.factories import DocumentFactory
|
from paperless_testing.factories import DocumentFactory
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from collections.abc import Callable
|
from collections.abc import Callable
|
||||||
from collections.abc import Generator
|
from collections.abc import Generator
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
from pytest_django.fixtures import Settings
|
from pytest_django.fixtures import Settings
|
||||||
|
|
||||||
from documents.models import Document
|
from documents.models import Document
|
||||||
|
from paperless_testing.dirs import PaperlessDirs
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
def index_dir(tmp_path: Path, settings: Settings) -> Path:
|
def backend(paperless_dirs: PaperlessDirs) -> Generator[TantivyBackend, None, None]:
|
||||||
path = tmp_path / "index"
|
b = TantivyBackend(path=paperless_dirs.index_dir)
|
||||||
path.mkdir()
|
|
||||||
settings.INDEX_DIR = path
|
|
||||||
return path
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
|
||||||
def backend() -> Generator[TantivyBackend, None, None]:
|
|
||||||
b = TantivyBackend() # path=None → in-memory index
|
|
||||||
b.open()
|
b.open()
|
||||||
try:
|
try:
|
||||||
yield b
|
yield b
|
||||||
|
|||||||
@@ -14,7 +14,6 @@ from typing import TYPE_CHECKING
|
|||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
import time_machine
|
import time_machine
|
||||||
from django.contrib.auth.models import User
|
|
||||||
|
|
||||||
from documents.models import CustomField
|
from documents.models import CustomField
|
||||||
from documents.models import CustomFieldInstance
|
from documents.models import CustomFieldInstance
|
||||||
@@ -22,7 +21,8 @@ from documents.models import DocumentType
|
|||||||
from documents.models import Note
|
from documents.models import Note
|
||||||
from documents.models import StoragePath
|
from documents.models import StoragePath
|
||||||
from documents.search._query import parse_user_query
|
from documents.search._query import parse_user_query
|
||||||
from documents.tests.factories import DocumentFactory
|
from paperless_testing.factories import DocumentFactory
|
||||||
|
from paperless_testing.factories import UserFactory
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from collections.abc import Callable
|
from collections.abc import Callable
|
||||||
@@ -148,7 +148,7 @@ class TestJsonSubpaths:
|
|||||||
THEN:
|
THEN:
|
||||||
- Only the document with alice's note matches
|
- Only the document with alice's note matches
|
||||||
"""
|
"""
|
||||||
alice = User.objects.create_user(username="alice")
|
alice = UserFactory(username="alice")
|
||||||
doc_with_note = DocumentFactory(
|
doc_with_note = DocumentFactory(
|
||||||
title="Has note",
|
title="Has note",
|
||||||
content="x",
|
content="x",
|
||||||
|
|||||||
@@ -3,10 +3,8 @@ from pathlib import Path
|
|||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
from django.contrib.auth.models import Group
|
from django.contrib.auth.models import Group
|
||||||
from django.contrib.auth.models import User
|
|
||||||
from django.db import connection
|
from django.db import connection
|
||||||
from django.test.utils import CaptureQueriesContext
|
from django.test.utils import CaptureQueriesContext
|
||||||
from guardian.shortcuts import assign_perm
|
|
||||||
from pytest_mock import MockerFixture
|
from pytest_mock import MockerFixture
|
||||||
|
|
||||||
from documents.models import CustomField
|
from documents.models import CustomField
|
||||||
@@ -19,11 +17,12 @@ from documents.search._backend import WriteBatch
|
|||||||
from documents.search._backend import get_backend
|
from documents.search._backend import get_backend
|
||||||
from documents.search._backend import reset_backend
|
from documents.search._backend import reset_backend
|
||||||
from documents.signals.handlers import add_to_index
|
from documents.signals.handlers import add_to_index
|
||||||
from documents.tests.factories import CorrespondentFactory
|
from paperless_testing.factories import CorrespondentFactory
|
||||||
from documents.tests.factories import DocumentFactory
|
from paperless_testing.factories import DocumentFactory
|
||||||
from documents.tests.factories import DocumentTypeFactory
|
from paperless_testing.factories import DocumentTypeFactory
|
||||||
from documents.tests.factories import TagFactory
|
from paperless_testing.factories import TagFactory
|
||||||
from documents.tests.factories import UserFactory
|
from paperless_testing.factories import UserFactory
|
||||||
|
from paperless_testing.permissions import grant_object
|
||||||
|
|
||||||
pytestmark = [pytest.mark.search, pytest.mark.django_db]
|
pytestmark = [pytest.mark.search, pytest.mark.django_db]
|
||||||
|
|
||||||
@@ -189,7 +188,7 @@ class TestAddOrUpdateIds:
|
|||||||
pk=1,
|
pk=1,
|
||||||
owner=owner,
|
owner=owner,
|
||||||
)
|
)
|
||||||
assign_perm("view_document", user, doc)
|
grant_object(user, doc, "view_document")
|
||||||
|
|
||||||
with backend.batch_update() as batch:
|
with backend.batch_update() as batch:
|
||||||
batch.add_or_update_ids([doc.pk])
|
batch.add_or_update_ids([doc.pk])
|
||||||
@@ -209,7 +208,7 @@ class TestAddOrUpdateIds:
|
|||||||
pk=1,
|
pk=1,
|
||||||
owner=owner,
|
owner=owner,
|
||||||
)
|
)
|
||||||
assign_perm("view_document", group, doc)
|
grant_object(group, doc, "view_document")
|
||||||
|
|
||||||
with backend.batch_update() as batch:
|
with backend.batch_update() as batch:
|
||||||
batch.add_or_update_ids([doc.pk])
|
batch.add_or_update_ids([doc.pk])
|
||||||
@@ -763,8 +762,8 @@ class TestSearchIds:
|
|||||||
|
|
||||||
def test_respects_permission_filter(self, backend: TantivyBackend) -> None:
|
def test_respects_permission_filter(self, backend: TantivyBackend) -> None:
|
||||||
"""search_ids must respect user permission filtering."""
|
"""search_ids must respect user permission filtering."""
|
||||||
owner = User.objects.create_user("ids_owner")
|
owner = UserFactory(username="ids_owner")
|
||||||
other = User.objects.create_user("ids_other")
|
other = UserFactory(username="ids_other")
|
||||||
doc = Document.objects.create(
|
doc = Document.objects.create(
|
||||||
title="private doc",
|
title="private doc",
|
||||||
content="secret keyword",
|
content="secret keyword",
|
||||||
@@ -843,7 +842,7 @@ class TestRebuild:
|
|||||||
content="group secret keyword",
|
content="group secret keyword",
|
||||||
owner=owner,
|
owner=owner,
|
||||||
)
|
)
|
||||||
assign_perm("view_document", group, doc)
|
grant_object(group, doc, "view_document")
|
||||||
|
|
||||||
backend.rebuild(Document.objects.all())
|
backend.rebuild(Document.objects.all())
|
||||||
|
|
||||||
@@ -948,7 +947,8 @@ class TestSingleton:
|
|||||||
yield
|
yield
|
||||||
reset_backend()
|
reset_backend()
|
||||||
|
|
||||||
def test_returns_same_instance_on_repeated_calls(self, index_dir) -> None:
|
@pytest.mark.usefixtures("paperless_dirs")
|
||||||
|
def test_returns_same_instance_on_repeated_calls(self) -> None:
|
||||||
"""Singleton pattern: repeated calls to get_backend() must return the same instance."""
|
"""Singleton pattern: repeated calls to get_backend() must return the same instance."""
|
||||||
assert get_backend() is get_backend()
|
assert get_backend() is get_backend()
|
||||||
|
|
||||||
@@ -965,7 +965,8 @@ class TestSingleton:
|
|||||||
assert b1 is not b2
|
assert b1 is not b2
|
||||||
assert b2._path == tmp_path / "b"
|
assert b2._path == tmp_path / "b"
|
||||||
|
|
||||||
def test_reset_forces_new_instance(self, index_dir) -> None:
|
@pytest.mark.usefixtures("paperless_dirs")
|
||||||
|
def test_reset_forces_new_instance(self) -> None:
|
||||||
"""reset_backend() must force creation of a new backend instance on next get_backend() call."""
|
"""reset_backend() must force creation of a new backend instance on next get_backend() call."""
|
||||||
b1 = get_backend()
|
b1 = get_backend()
|
||||||
reset_backend()
|
reset_backend()
|
||||||
@@ -1071,7 +1072,7 @@ class TestFieldHandling:
|
|||||||
|
|
||||||
def test_notes_include_user_information(self, backend: TantivyBackend) -> None:
|
def test_notes_include_user_information(self, backend: TantivyBackend) -> None:
|
||||||
"""Notes must be indexed with user information when available for structured queries."""
|
"""Notes must be indexed with user information when available for structured queries."""
|
||||||
user = User.objects.create_user("notewriter")
|
user = UserFactory(username="notewriter")
|
||||||
doc = Document.objects.create(
|
doc = Document.objects.create(
|
||||||
title="Doc with notes",
|
title="Doc with notes",
|
||||||
content="test",
|
content="test",
|
||||||
@@ -1173,7 +1174,7 @@ class TestHighlightHits:
|
|||||||
notes.note: prefix so the query targets notes content directly, but
|
notes.note: prefix so the query targets notes content directly, but
|
||||||
the snippet is generated from notes_text which stores the same text.
|
the snippet is generated from notes_text which stores the same text.
|
||||||
"""
|
"""
|
||||||
user = User.objects.create_user("hl_noteuser")
|
user = UserFactory(username="hl_noteuser")
|
||||||
doc = Document.objects.create(
|
doc = Document.objects.create(
|
||||||
title="Doc with matching note",
|
title="Doc with matching note",
|
||||||
content="unrelated content",
|
content="unrelated content",
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ import time_machine
|
|||||||
from documents.models import Note
|
from documents.models import Note
|
||||||
from documents.models import Tag
|
from documents.models import Tag
|
||||||
from documents.search._errors import InvalidDateQuery
|
from documents.search._errors import InvalidDateQuery
|
||||||
from documents.tests.factories import DocumentFactory
|
from paperless_testing.factories import DocumentFactory
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from collections.abc import Callable
|
from collections.abc import Callable
|
||||||
@@ -269,7 +269,7 @@ class TestDocumentedDateForms:
|
|||||||
yield
|
yield
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
def dated(self, index_document: Callable[..., Document]) -> dict[str, int]:
|
def dated(self, backend: TantivyBackend) -> dict[str, int]:
|
||||||
stamps = {
|
stamps = {
|
||||||
"today": datetime(2026, 6, 15, 9, 0, tzinfo=UTC),
|
"today": datetime(2026, 6, 15, 9, 0, tzinfo=UTC),
|
||||||
"yesterday": datetime(2026, 6, 14, 9, 0, tzinfo=UTC),
|
"yesterday": datetime(2026, 6, 14, 9, 0, tzinfo=UTC),
|
||||||
@@ -279,14 +279,14 @@ class TestDocumentedDateForms:
|
|||||||
"january": datetime(2026, 1, 10, 10, 0, tzinfo=UTC),
|
"january": datetime(2026, 1, 10, 10, 0, tzinfo=UTC),
|
||||||
"old": datetime(2005, 3, 4, 15, 30, tzinfo=UTC),
|
"old": datetime(2005, 3, 4, 15, 30, tzinfo=UTC),
|
||||||
}
|
}
|
||||||
return {
|
docs = {
|
||||||
label: index_document(
|
label: DocumentFactory(title=label, content="dated body", added=stamp)
|
||||||
title=label,
|
|
||||||
content="dated body",
|
|
||||||
added=stamp,
|
|
||||||
).pk
|
|
||||||
for label, stamp in stamps.items()
|
for label, stamp in stamps.items()
|
||||||
}
|
}
|
||||||
|
with backend.batch_update() as batch:
|
||||||
|
for doc in docs.values():
|
||||||
|
batch.add_or_update(doc)
|
||||||
|
return {label: doc.pk for label, doc in docs.items()}
|
||||||
|
|
||||||
@pytest.mark.parametrize(
|
@pytest.mark.parametrize(
|
||||||
("query", "label"),
|
("query", "label"),
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ from rest_framework import status
|
|||||||
|
|
||||||
from documents.search._backend import SearchMode
|
from documents.search._backend import SearchMode
|
||||||
from documents.search._query import parse_simple_text_highlight_query
|
from documents.search._query import parse_simple_text_highlight_query
|
||||||
from documents.tests.factories import DocumentFactory
|
from paperless_testing.factories import DocumentFactory
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from rest_framework.test import APIClient
|
from rest_framework.test import APIClient
|
||||||
|
|||||||
@@ -17,12 +17,12 @@ from __future__ import annotations
|
|||||||
from typing import TYPE_CHECKING
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
from django.contrib.auth.models import User
|
|
||||||
|
|
||||||
from documents.models import CustomField
|
from documents.models import CustomField
|
||||||
from documents.models import CustomFieldInstance
|
from documents.models import CustomFieldInstance
|
||||||
from documents.models import Note
|
from documents.models import Note
|
||||||
from documents.tests.factories import DocumentFactory
|
from paperless_testing.factories import DocumentFactory
|
||||||
|
from paperless_testing.factories import UserFactory
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from collections.abc import Callable
|
from collections.abc import Callable
|
||||||
@@ -53,7 +53,7 @@ class TestBareJsonFieldPrefixes:
|
|||||||
decoy's content match does not resurface through a demoted
|
decoy's content match does not resurface through a demoted
|
||||||
text search
|
text search
|
||||||
"""
|
"""
|
||||||
alice = User.objects.create_user(username="alice")
|
alice = UserFactory(username="alice")
|
||||||
with_note = DocumentFactory(title="Has note", content="x")
|
with_note = DocumentFactory(title="Has note", content="x")
|
||||||
Note.objects.create(document=with_note, user=alice, note="crocodile")
|
Note.objects.create(document=with_note, user=alice, note="crocodile")
|
||||||
backend.add_or_update(with_note)
|
backend.add_or_update(with_note)
|
||||||
@@ -116,7 +116,7 @@ class TestBareJsonFieldPrefixes:
|
|||||||
document; the default-subpath resolution for the bare
|
document; the default-subpath resolution for the bare
|
||||||
prefix does not interfere with explicit subpath addressing
|
prefix does not interfere with explicit subpath addressing
|
||||||
"""
|
"""
|
||||||
bob = User.objects.create_user(username="bob")
|
bob = UserFactory(username="bob")
|
||||||
doc = DocumentFactory(title="Bob note", content="x")
|
doc = DocumentFactory(title="Bob note", content="x")
|
||||||
Note.objects.create(document=doc, user=bob, note="remark")
|
Note.objects.create(document=doc, user=bob, note="remark")
|
||||||
backend.add_or_update(doc)
|
backend.add_or_update(doc)
|
||||||
|
|||||||
@@ -20,7 +20,6 @@ from typing import TYPE_CHECKING
|
|||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
import tantivy
|
import tantivy
|
||||||
from django.contrib.auth.models import User
|
|
||||||
from whoosh_compat import FieldKind
|
from whoosh_compat import FieldKind
|
||||||
|
|
||||||
from documents.models import CustomField
|
from documents.models import CustomField
|
||||||
@@ -28,6 +27,7 @@ from documents.models import CustomFieldInstance
|
|||||||
from documents.models import Document
|
from documents.models import Document
|
||||||
from documents.models import Note
|
from documents.models import Note
|
||||||
from documents.search._fields import PUBLIC_FIELDS
|
from documents.search._fields import PUBLIC_FIELDS
|
||||||
|
from paperless_testing.factories import UserFactory
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from documents.search._backend import TantivyBackend
|
from documents.search._backend import TantivyBackend
|
||||||
@@ -49,7 +49,7 @@ class TestJsonSubpathsAreWrittenAtIndexTime:
|
|||||||
- Every subpath PUBLIC_FIELDS declares for notes/custom_fields
|
- Every subpath PUBLIC_FIELDS declares for notes/custom_fields
|
||||||
is present as a key in the document's stored JSON payload
|
is present as a key in the document's stored JSON payload
|
||||||
"""
|
"""
|
||||||
user = User.objects.create_user(username="completeness-user")
|
user = UserFactory(username="completeness-user")
|
||||||
field = CustomField.objects.create(
|
field = CustomField.objects.create(
|
||||||
name="Completeness Field",
|
name="Completeness Field",
|
||||||
data_type=CustomField.FieldDataType.STRING,
|
data_type=CustomField.FieldDataType.STRING,
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ from documents.search._backend import SearchIndexLockError
|
|||||||
from documents.search._backend import TantivyBackend
|
from documents.search._backend import TantivyBackend
|
||||||
from documents.tasks import index_document
|
from documents.tasks import index_document
|
||||||
from documents.tasks import remove_document_from_index
|
from documents.tasks import remove_document_from_index
|
||||||
from documents.tests.factories import DocumentFactory
|
from paperless_testing.factories import DocumentFactory
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from collections.abc import Generator
|
from collections.abc import Generator
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from documents.tests.utils import TestMigrations
|
from paperless_testing.migrations import TestMigrations
|
||||||
|
|
||||||
pytestmark = pytest.mark.search
|
pytestmark = pytest.mark.search
|
||||||
|
|
||||||
|
|||||||
@@ -18,13 +18,14 @@ from typing import TYPE_CHECKING
|
|||||||
import pytest
|
import pytest
|
||||||
from django.contrib.auth.models import Group
|
from django.contrib.auth.models import Group
|
||||||
from django.contrib.auth.models import User
|
from django.contrib.auth.models import User
|
||||||
from guardian.shortcuts import assign_perm
|
|
||||||
|
|
||||||
from documents.models import Correspondent
|
from documents.models import Correspondent
|
||||||
from documents.models import Document
|
from documents.models import Document
|
||||||
from documents.models import DocumentType
|
from documents.models import DocumentType
|
||||||
from documents.models import StoragePath
|
from documents.models import StoragePath
|
||||||
from documents.models import Tag
|
from documents.models import Tag
|
||||||
|
from paperless_testing.factories import UserFactory
|
||||||
|
from paperless_testing.permissions import grant_object
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from documents.search._backend import TantivyBackend
|
from documents.search._backend import TantivyBackend
|
||||||
@@ -34,22 +35,22 @@ pytestmark = [pytest.mark.search, pytest.mark.django_db]
|
|||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
def owner() -> User:
|
def owner() -> User:
|
||||||
return User.objects.create_user(username="owner")
|
return UserFactory(username="owner")
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
def stranger() -> User:
|
def stranger() -> User:
|
||||||
return User.objects.create_user(username="stranger")
|
return UserFactory(username="stranger")
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
def viewer() -> User:
|
def viewer() -> User:
|
||||||
return User.objects.create_user(username="viewer")
|
return UserFactory(username="viewer")
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
def group_member() -> User:
|
def group_member() -> User:
|
||||||
user = User.objects.create_user(username="group_member")
|
user = UserFactory(username="group_member")
|
||||||
user.groups.add(Group.objects.create(name="accounting"))
|
user.groups.add(Group.objects.create(name="accounting"))
|
||||||
return user
|
return user
|
||||||
|
|
||||||
@@ -127,7 +128,7 @@ class TestPermissionFilteringOnIndexedDocuments:
|
|||||||
checksum="perm-shared-user",
|
checksum="perm-shared-user",
|
||||||
owner=owner,
|
owner=owner,
|
||||||
)
|
)
|
||||||
assign_perm("view_document", viewer, doc)
|
grant_object(viewer, doc, "view_document")
|
||||||
backend.add_or_update(doc)
|
backend.add_or_update(doc)
|
||||||
|
|
||||||
assert backend.search_ids("invoice", user=viewer) == [doc.pk]
|
assert backend.search_ids("invoice", user=viewer) == [doc.pk]
|
||||||
@@ -157,7 +158,7 @@ class TestPermissionFilteringOnIndexedDocuments:
|
|||||||
checksum="perm-shared-group",
|
checksum="perm-shared-group",
|
||||||
owner=owner,
|
owner=owner,
|
||||||
)
|
)
|
||||||
assign_perm("view_document", group_member.groups.first(), doc)
|
grant_object(group_member.groups.first(), doc, "view_document")
|
||||||
backend.add_or_update(doc)
|
backend.add_or_update(doc)
|
||||||
|
|
||||||
assert backend.search_ids("invoice", user=group_member) == [doc.pk]
|
assert backend.search_ids("invoice", user=group_member) == [doc.pk]
|
||||||
|
|||||||
@@ -13,11 +13,11 @@ from documents.search._schema import needs_rebuild
|
|||||||
from documents.search._schema import schema_fingerprint
|
from documents.search._schema import schema_fingerprint
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
import tantivy
|
import tantivy
|
||||||
from pytest_django.fixtures import Settings
|
from pytest_django.fixtures import Settings
|
||||||
|
|
||||||
|
from paperless_testing.dirs import PaperlessDirs
|
||||||
|
|
||||||
|
|
||||||
pytestmark = pytest.mark.search
|
pytestmark = pytest.mark.search
|
||||||
|
|
||||||
@@ -25,16 +25,19 @@ pytestmark = pytest.mark.search
|
|||||||
class TestNeedsRebuild:
|
class TestNeedsRebuild:
|
||||||
"""needs_rebuild covers all sentinel-file states that require a full reindex."""
|
"""needs_rebuild covers all sentinel-file states that require a full reindex."""
|
||||||
|
|
||||||
def test_returns_true_when_settings_file_missing(self, index_dir: Path) -> None:
|
def test_returns_true_when_settings_file_missing(
|
||||||
assert needs_rebuild(index_dir) is True
|
self,
|
||||||
|
paperless_dirs: PaperlessDirs,
|
||||||
|
) -> None:
|
||||||
|
assert needs_rebuild(paperless_dirs.index_dir) is True
|
||||||
|
|
||||||
def test_returns_false_when_version_and_language_match(
|
def test_returns_false_when_version_and_language_match(
|
||||||
self,
|
self,
|
||||||
index_dir: Path,
|
paperless_dirs: PaperlessDirs,
|
||||||
settings: Settings,
|
settings: Settings,
|
||||||
) -> None:
|
) -> None:
|
||||||
settings.SEARCH_LANGUAGE = "en"
|
settings.SEARCH_LANGUAGE = "en"
|
||||||
(index_dir / ".index_settings.json").write_text(
|
(paperless_dirs.index_dir / ".index_settings.json").write_text(
|
||||||
json.dumps(
|
json.dumps(
|
||||||
{
|
{
|
||||||
"schema_version": SCHEMA_VERSION,
|
"schema_version": SCHEMA_VERSION,
|
||||||
@@ -43,51 +46,51 @@ class TestNeedsRebuild:
|
|||||||
},
|
},
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
assert needs_rebuild(index_dir) is False
|
assert needs_rebuild(paperless_dirs.index_dir) is False
|
||||||
|
|
||||||
def test_returns_true_on_schema_version_mismatch(
|
def test_returns_true_on_schema_version_mismatch(
|
||||||
self,
|
self,
|
||||||
index_dir: Path,
|
paperless_dirs: PaperlessDirs,
|
||||||
settings: Settings,
|
settings: Settings,
|
||||||
) -> None:
|
) -> None:
|
||||||
settings.SEARCH_LANGUAGE = None
|
settings.SEARCH_LANGUAGE = None
|
||||||
(index_dir / ".index_settings.json").write_text(
|
(paperless_dirs.index_dir / ".index_settings.json").write_text(
|
||||||
json.dumps({"schema_version": SCHEMA_VERSION - 1, "language": None}),
|
json.dumps({"schema_version": SCHEMA_VERSION - 1, "language": None}),
|
||||||
)
|
)
|
||||||
assert needs_rebuild(index_dir) is True
|
assert needs_rebuild(paperless_dirs.index_dir) is True
|
||||||
|
|
||||||
def test_returns_true_when_version_is_not_an_integer(
|
def test_returns_true_when_version_is_not_an_integer(
|
||||||
self,
|
self,
|
||||||
index_dir: Path,
|
paperless_dirs: PaperlessDirs,
|
||||||
settings: Settings,
|
settings: Settings,
|
||||||
) -> None:
|
) -> None:
|
||||||
settings.SEARCH_LANGUAGE = None
|
settings.SEARCH_LANGUAGE = None
|
||||||
(index_dir / ".index_settings.json").write_text(
|
(paperless_dirs.index_dir / ".index_settings.json").write_text(
|
||||||
json.dumps({"schema_version": "not-a-number", "language": None}),
|
json.dumps({"schema_version": "not-a-number", "language": None}),
|
||||||
)
|
)
|
||||||
assert needs_rebuild(index_dir) is True
|
assert needs_rebuild(paperless_dirs.index_dir) is True
|
||||||
|
|
||||||
def test_returns_true_when_language_key_missing(
|
def test_returns_true_when_language_key_missing(
|
||||||
self,
|
self,
|
||||||
index_dir: Path,
|
paperless_dirs: PaperlessDirs,
|
||||||
settings: Settings,
|
settings: Settings,
|
||||||
) -> None:
|
) -> None:
|
||||||
settings.SEARCH_LANGUAGE = "en"
|
settings.SEARCH_LANGUAGE = "en"
|
||||||
(index_dir / ".index_settings.json").write_text(
|
(paperless_dirs.index_dir / ".index_settings.json").write_text(
|
||||||
json.dumps({"schema_version": SCHEMA_VERSION}),
|
json.dumps({"schema_version": SCHEMA_VERSION}),
|
||||||
)
|
)
|
||||||
assert needs_rebuild(index_dir) is True
|
assert needs_rebuild(paperless_dirs.index_dir) is True
|
||||||
|
|
||||||
def test_returns_true_when_language_differs(
|
def test_returns_true_when_language_differs(
|
||||||
self,
|
self,
|
||||||
index_dir: Path,
|
paperless_dirs: PaperlessDirs,
|
||||||
settings: Settings,
|
settings: Settings,
|
||||||
) -> None:
|
) -> None:
|
||||||
settings.SEARCH_LANGUAGE = "de"
|
settings.SEARCH_LANGUAGE = "de"
|
||||||
(index_dir / ".index_settings.json").write_text(
|
(paperless_dirs.index_dir / ".index_settings.json").write_text(
|
||||||
json.dumps({"schema_version": SCHEMA_VERSION, "language": "en"}),
|
json.dumps({"schema_version": SCHEMA_VERSION, "language": "en"}),
|
||||||
)
|
)
|
||||||
assert needs_rebuild(index_dir) is True
|
assert needs_rebuild(paperless_dirs.index_dir) is True
|
||||||
|
|
||||||
|
|
||||||
def _schema_fields(schema: tantivy.Schema) -> dict[str, dict]:
|
def _schema_fields(schema: tantivy.Schema) -> dict[str, dict]:
|
||||||
|
|||||||
@@ -35,6 +35,8 @@ if TYPE_CHECKING:
|
|||||||
|
|
||||||
from pytest_django.fixtures import SettingsWrapper
|
from pytest_django.fixtures import SettingsWrapper
|
||||||
|
|
||||||
|
from paperless_testing.dirs import PaperlessDirs
|
||||||
|
|
||||||
pytestmark = pytest.mark.search
|
pytestmark = pytest.mark.search
|
||||||
|
|
||||||
# The on-disk field layout of a v2 index, pinned as data. Any edit here is an
|
# The on-disk field layout of a v2 index, pinned as data. Any edit here is an
|
||||||
@@ -469,7 +471,7 @@ def _fingerprint_of(descriptors: list[FieldDescriptor]) -> str:
|
|||||||
class TestNeedsRebuildOnFingerprint:
|
class TestNeedsRebuildOnFingerprint:
|
||||||
def test_matching_fingerprint_does_not_rebuild(
|
def test_matching_fingerprint_does_not_rebuild(
|
||||||
self,
|
self,
|
||||||
index_dir: Path,
|
paperless_dirs: PaperlessDirs,
|
||||||
settings: SettingsWrapper,
|
settings: SettingsWrapper,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""
|
"""
|
||||||
@@ -482,13 +484,13 @@ class TestNeedsRebuildOnFingerprint:
|
|||||||
- It returns False
|
- It returns False
|
||||||
"""
|
"""
|
||||||
settings.SEARCH_LANGUAGE = None
|
settings.SEARCH_LANGUAGE = None
|
||||||
_sentinels(index_dir)
|
_sentinels(paperless_dirs.index_dir)
|
||||||
|
|
||||||
assert needs_rebuild(index_dir) is False
|
assert needs_rebuild(paperless_dirs.index_dir) is False
|
||||||
|
|
||||||
def test_stale_fingerprint_rebuilds_despite_a_matching_version(
|
def test_stale_fingerprint_rebuilds_despite_a_matching_version(
|
||||||
self,
|
self,
|
||||||
index_dir: Path,
|
paperless_dirs: PaperlessDirs,
|
||||||
settings: SettingsWrapper,
|
settings: SettingsWrapper,
|
||||||
monkeypatch: pytest.MonkeyPatch,
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
) -> None:
|
) -> None:
|
||||||
@@ -505,7 +507,7 @@ class TestNeedsRebuildOnFingerprint:
|
|||||||
every subsequent write would raise
|
every subsequent write would raise
|
||||||
"""
|
"""
|
||||||
settings.SEARCH_LANGUAGE = None
|
settings.SEARCH_LANGUAGE = None
|
||||||
_sentinels(index_dir)
|
_sentinels(paperless_dirs.index_dir)
|
||||||
extended = [
|
extended = [
|
||||||
*field_descriptors(),
|
*field_descriptors(),
|
||||||
FieldDescriptor(
|
FieldDescriptor(
|
||||||
@@ -519,11 +521,11 @@ class TestNeedsRebuildOnFingerprint:
|
|||||||
]
|
]
|
||||||
monkeypatch.setattr(_schema, "field_descriptors", lambda: extended)
|
monkeypatch.setattr(_schema, "field_descriptors", lambda: extended)
|
||||||
|
|
||||||
assert needs_rebuild(index_dir) is True
|
assert needs_rebuild(paperless_dirs.index_dir) is True
|
||||||
|
|
||||||
def test_reordered_schema_rebuilds(
|
def test_reordered_schema_rebuilds(
|
||||||
self,
|
self,
|
||||||
index_dir: Path,
|
paperless_dirs: PaperlessDirs,
|
||||||
settings: SettingsWrapper,
|
settings: SettingsWrapper,
|
||||||
monkeypatch: pytest.MonkeyPatch,
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
) -> None:
|
) -> None:
|
||||||
@@ -538,16 +540,16 @@ class TestNeedsRebuildOnFingerprint:
|
|||||||
- It returns True
|
- It returns True
|
||||||
"""
|
"""
|
||||||
settings.SEARCH_LANGUAGE = None
|
settings.SEARCH_LANGUAGE = None
|
||||||
_sentinels(index_dir)
|
_sentinels(paperless_dirs.index_dir)
|
||||||
reordered = field_descriptors()
|
reordered = field_descriptors()
|
||||||
reordered[1], reordered[2] = reordered[2], reordered[1]
|
reordered[1], reordered[2] = reordered[2], reordered[1]
|
||||||
monkeypatch.setattr(_schema, "field_descriptors", lambda: reordered)
|
monkeypatch.setattr(_schema, "field_descriptors", lambda: reordered)
|
||||||
|
|
||||||
assert needs_rebuild(index_dir) is True
|
assert needs_rebuild(paperless_dirs.index_dir) is True
|
||||||
|
|
||||||
def test_missing_fingerprint_rebuilds(
|
def test_missing_fingerprint_rebuilds(
|
||||||
self,
|
self,
|
||||||
index_dir: Path,
|
paperless_dirs: PaperlessDirs,
|
||||||
settings: SettingsWrapper,
|
settings: SettingsWrapper,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""
|
"""
|
||||||
@@ -561,15 +563,15 @@ class TestNeedsRebuildOnFingerprint:
|
|||||||
is rebuilt rather than trusted
|
is rebuilt rather than trusted
|
||||||
"""
|
"""
|
||||||
settings.SEARCH_LANGUAGE = None
|
settings.SEARCH_LANGUAGE = None
|
||||||
(index_dir / ".index_settings.json").write_text(
|
(paperless_dirs.index_dir / ".index_settings.json").write_text(
|
||||||
json.dumps({"schema_version": SCHEMA_VERSION, "language": None}),
|
json.dumps({"schema_version": SCHEMA_VERSION, "language": None}),
|
||||||
)
|
)
|
||||||
|
|
||||||
assert needs_rebuild(index_dir) is True
|
assert needs_rebuild(paperless_dirs.index_dir) is True
|
||||||
|
|
||||||
def test_written_sentinels_satisfy_the_check(
|
def test_written_sentinels_satisfy_the_check(
|
||||||
self,
|
self,
|
||||||
index_dir: Path,
|
paperless_dirs: PaperlessDirs,
|
||||||
settings: SettingsWrapper,
|
settings: SettingsWrapper,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""
|
"""
|
||||||
@@ -582,6 +584,6 @@ class TestNeedsRebuildOnFingerprint:
|
|||||||
- It returns False
|
- It returns False
|
||||||
"""
|
"""
|
||||||
settings.SEARCH_LANGUAGE = "en"
|
settings.SEARCH_LANGUAGE = "en"
|
||||||
_write_sentinels(index_dir)
|
_write_sentinels(paperless_dirs.index_dir)
|
||||||
|
|
||||||
assert needs_rebuild(index_dir) is False
|
assert needs_rebuild(paperless_dirs.index_dir) is False
|
||||||
|
|||||||
@@ -16,11 +16,11 @@ from documents.models import Document
|
|||||||
from documents.models import Tag
|
from documents.models import Tag
|
||||||
from documents.search import get_backend
|
from documents.search import get_backend
|
||||||
from documents.search import reset_backend
|
from documents.search import reset_backend
|
||||||
from documents.tests.factories import DocumentFactory
|
|
||||||
from documents.tests.factories import TagFactory
|
|
||||||
from documents.tests.factories import UserFactory
|
|
||||||
from documents.tests.utils import DirectoriesMixin
|
|
||||||
from paperless.admin import PaperlessUserAdmin
|
from paperless.admin import PaperlessUserAdmin
|
||||||
|
from paperless_testing.dirs import DirectoriesMixin
|
||||||
|
from paperless_testing.factories import DocumentFactory
|
||||||
|
from paperless_testing.factories import TagFactory
|
||||||
|
from paperless_testing.factories import UserFactory
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ from io import BytesIO
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from unittest.mock import patch
|
from unittest.mock import patch
|
||||||
|
|
||||||
from django.contrib.auth.models import User
|
|
||||||
from django.core.files.uploadedfile import SimpleUploadedFile
|
from django.core.files.uploadedfile import SimpleUploadedFile
|
||||||
from django.test import override_settings
|
from django.test import override_settings
|
||||||
from PIL import Image
|
from PIL import Image
|
||||||
@@ -11,10 +10,11 @@ from PIL.PngImagePlugin import PngInfo
|
|||||||
from rest_framework import status
|
from rest_framework import status
|
||||||
from rest_framework.test import APITestCase
|
from rest_framework.test import APITestCase
|
||||||
|
|
||||||
from documents.tests.utils import DirectoriesMixin
|
|
||||||
from documents.tests.utils import read_streaming_response
|
|
||||||
from paperless.models import ApplicationConfiguration
|
from paperless.models import ApplicationConfiguration
|
||||||
from paperless.models import ColorConvertChoices
|
from paperless.models import ColorConvertChoices
|
||||||
|
from paperless_testing.dirs import DirectoriesMixin
|
||||||
|
from paperless_testing.factories import UserFactory
|
||||||
|
from paperless_testing.http import read_streaming_response
|
||||||
|
|
||||||
|
|
||||||
class TestApiAppConfig(DirectoriesMixin, APITestCase):
|
class TestApiAppConfig(DirectoriesMixin, APITestCase):
|
||||||
@@ -23,7 +23,7 @@ class TestApiAppConfig(DirectoriesMixin, APITestCase):
|
|||||||
def setUp(self) -> None:
|
def setUp(self) -> None:
|
||||||
super().setUp()
|
super().setUp()
|
||||||
|
|
||||||
user = User.objects.create_superuser(username="temp_admin")
|
user = UserFactory(username="temp_admin", superuser=True)
|
||||||
self.client.force_authenticate(user=user)
|
self.client.force_authenticate(user=user)
|
||||||
|
|
||||||
def test_api_get_config(self) -> None:
|
def test_api_get_config(self) -> None:
|
||||||
@@ -81,6 +81,7 @@ class TestApiAppConfig(DirectoriesMixin, APITestCase):
|
|||||||
"ai_enabled": None,
|
"ai_enabled": None,
|
||||||
"llm_embedding_backend": None,
|
"llm_embedding_backend": None,
|
||||||
"llm_embedding_model": None,
|
"llm_embedding_model": None,
|
||||||
|
"llm_embedding_api_key": None,
|
||||||
"llm_embedding_endpoint": None,
|
"llm_embedding_endpoint": None,
|
||||||
"llm_embedding_chunk_size": None,
|
"llm_embedding_chunk_size": None,
|
||||||
"llm_context_size": None,
|
"llm_context_size": None,
|
||||||
@@ -267,7 +268,7 @@ class TestApiAppConfig(DirectoriesMixin, APITestCase):
|
|||||||
THEN:
|
THEN:
|
||||||
- old app_logo file is deleted
|
- old app_logo file is deleted
|
||||||
"""
|
"""
|
||||||
admin = User.objects.create_superuser(username="admin")
|
admin = UserFactory(username="admin", superuser=True)
|
||||||
self.client.force_login(user=admin)
|
self.client.force_login(user=admin)
|
||||||
response = self.client.get("/logo/")
|
response = self.client.get("/logo/")
|
||||||
self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND)
|
self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND)
|
||||||
@@ -922,6 +923,49 @@ class TestApiAppConfig(DirectoriesMixin, APITestCase):
|
|||||||
self.assertEqual(response.status_code, status.HTTP_405_METHOD_NOT_ALLOWED)
|
self.assertEqual(response.status_code, status.HTTP_405_METHOD_NOT_ALLOWED)
|
||||||
self.assertEqual(ApplicationConfiguration.objects.count(), 1)
|
self.assertEqual(ApplicationConfiguration.objects.count(), 1)
|
||||||
|
|
||||||
|
def test_update_llm_embedding_api_key(self) -> None:
|
||||||
|
"""
|
||||||
|
GIVEN:
|
||||||
|
- Existing config with llm_embedding_api_key specified
|
||||||
|
WHEN:
|
||||||
|
- API to update llm_embedding_api_key is called with all *s
|
||||||
|
- API to update llm_embedding_api_key is called with empty string
|
||||||
|
THEN:
|
||||||
|
- llm_embedding_api_key is unchanged
|
||||||
|
- llm_embedding_api_key is set to None
|
||||||
|
"""
|
||||||
|
config = ApplicationConfiguration.objects.first()
|
||||||
|
assert config is not None
|
||||||
|
config.llm_embedding_api_key = "1234567890"
|
||||||
|
config.save()
|
||||||
|
|
||||||
|
# Test with all *
|
||||||
|
response = self.client.patch(
|
||||||
|
f"{self.ENDPOINT}1/",
|
||||||
|
json.dumps(
|
||||||
|
{
|
||||||
|
"llm_embedding_api_key": "*" * 32,
|
||||||
|
},
|
||||||
|
),
|
||||||
|
content_type="application/json",
|
||||||
|
)
|
||||||
|
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||||
|
config.refresh_from_db()
|
||||||
|
self.assertEqual(config.llm_embedding_api_key, "1234567890")
|
||||||
|
# Test with empty string
|
||||||
|
response = self.client.patch(
|
||||||
|
f"{self.ENDPOINT}1/",
|
||||||
|
json.dumps(
|
||||||
|
{
|
||||||
|
"llm_embedding_api_key": "",
|
||||||
|
},
|
||||||
|
),
|
||||||
|
content_type="application/json",
|
||||||
|
)
|
||||||
|
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||||
|
config.refresh_from_db()
|
||||||
|
self.assertEqual(config.llm_embedding_api_key, None)
|
||||||
|
|
||||||
def test_update_llm_api_key(self) -> None:
|
def test_update_llm_api_key(self) -> None:
|
||||||
"""
|
"""
|
||||||
GIVEN:
|
GIVEN:
|
||||||
|
|||||||
@@ -4,8 +4,6 @@ import json
|
|||||||
import shutil
|
import shutil
|
||||||
import zipfile
|
import zipfile
|
||||||
|
|
||||||
from django.contrib.auth.models import Permission
|
|
||||||
from django.contrib.auth.models import User
|
|
||||||
from django.test import override_settings
|
from django.test import override_settings
|
||||||
from django.utils import timezone
|
from django.utils import timezone
|
||||||
from rest_framework import status
|
from rest_framework import status
|
||||||
@@ -14,9 +12,11 @@ from rest_framework.test import APITestCase
|
|||||||
from documents.models import Correspondent
|
from documents.models import Correspondent
|
||||||
from documents.models import Document
|
from documents.models import Document
|
||||||
from documents.models import DocumentType
|
from documents.models import DocumentType
|
||||||
from documents.tests.utils import DirectoriesMixin
|
|
||||||
from documents.tests.utils import SampleDirMixin
|
from documents.tests.utils import SampleDirMixin
|
||||||
from documents.tests.utils import read_streaming_response
|
from paperless_testing.dirs import DirectoriesMixin
|
||||||
|
from paperless_testing.factories import UserFactory
|
||||||
|
from paperless_testing.http import read_streaming_response
|
||||||
|
from paperless_testing.permissions import grant_global
|
||||||
|
|
||||||
|
|
||||||
class TestBulkDownload(DirectoriesMixin, SampleDirMixin, APITestCase):
|
class TestBulkDownload(DirectoriesMixin, SampleDirMixin, APITestCase):
|
||||||
@@ -25,7 +25,7 @@ class TestBulkDownload(DirectoriesMixin, SampleDirMixin, APITestCase):
|
|||||||
def setUp(self) -> None:
|
def setUp(self) -> None:
|
||||||
super().setUp()
|
super().setUp()
|
||||||
|
|
||||||
self.user = User.objects.create_superuser(username="temp_admin")
|
self.user = UserFactory(username="temp_admin", superuser=True)
|
||||||
self.client.force_authenticate(user=self.user)
|
self.client.force_authenticate(user=self.user)
|
||||||
|
|
||||||
self.doc1 = Document.objects.create(title="unrelated", checksum="A")
|
self.doc1 = Document.objects.create(title="unrelated", checksum="A")
|
||||||
@@ -166,7 +166,15 @@ class TestBulkDownload(DirectoriesMixin, SampleDirMixin, APITestCase):
|
|||||||
),
|
),
|
||||||
content_type="application/json",
|
content_type="application/json",
|
||||||
)
|
)
|
||||||
response.close()
|
|
||||||
|
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||||
|
self.assertEqual(response["Content-Type"], "application/zip")
|
||||||
|
|
||||||
|
with zipfile.ZipFile(io.BytesIO(read_streaming_response(response))) as zipf:
|
||||||
|
self.assertEqual(zipf.infolist()[0].compress_type, zipfile.ZIP_LZMA)
|
||||||
|
|
||||||
|
with self.doc2.source_file as f:
|
||||||
|
self.assertEqual(f.read(), zipf.read("2021-01-01 document A.pdf"))
|
||||||
|
|
||||||
@override_settings(FILENAME_FORMAT="{correspondent}/{title}")
|
@override_settings(FILENAME_FORMAT="{correspondent}/{title}")
|
||||||
def test_formatted_download_originals(self) -> None:
|
def test_formatted_download_originals(self) -> None:
|
||||||
@@ -326,10 +334,8 @@ class TestBulkDownload(DirectoriesMixin, SampleDirMixin, APITestCase):
|
|||||||
)
|
)
|
||||||
|
|
||||||
def test_download_insufficient_permissions(self) -> None:
|
def test_download_insufficient_permissions(self) -> None:
|
||||||
user = User.objects.create_user(username="temp_user")
|
user = UserFactory(username="temp_user")
|
||||||
user.user_permissions.add(
|
grant_global(user, "view_document")
|
||||||
Permission.objects.get(codename="view_document"),
|
|
||||||
)
|
|
||||||
self.client.force_authenticate(user=user)
|
self.client.force_authenticate(user=user)
|
||||||
|
|
||||||
self.doc2.owner = self.user
|
self.doc2.owner = self.user
|
||||||
|
|||||||
@@ -2,27 +2,30 @@ import json
|
|||||||
from unittest import mock
|
from unittest import mock
|
||||||
|
|
||||||
from auditlog.models import LogEntry
|
from auditlog.models import LogEntry
|
||||||
from django.contrib.auth.models import Permission
|
|
||||||
from django.contrib.auth.models import User
|
from django.contrib.auth.models import User
|
||||||
from django.test import override_settings
|
from django.test import override_settings
|
||||||
from guardian.shortcuts import assign_perm
|
|
||||||
from rest_framework import status
|
from rest_framework import status
|
||||||
from rest_framework.test import APITestCase
|
from rest_framework.test import APITestCase
|
||||||
|
|
||||||
from documents.models import Correspondent
|
from documents.models import Correspondent
|
||||||
from documents.models import CustomField
|
from documents.models import CustomField
|
||||||
|
from documents.models import CustomFieldInstance
|
||||||
from documents.models import Document
|
from documents.models import Document
|
||||||
from documents.models import DocumentType
|
from documents.models import DocumentType
|
||||||
from documents.models import StoragePath
|
from documents.models import StoragePath
|
||||||
from documents.models import Tag
|
from documents.models import Tag
|
||||||
from documents.tests.utils import DirectoriesMixin
|
from paperless_testing.dirs import DirectoriesMixin
|
||||||
|
from paperless_testing.factories import UserFactory
|
||||||
|
from paperless_testing.permissions import grant_all_global
|
||||||
|
from paperless_testing.permissions import grant_global
|
||||||
|
from paperless_testing.permissions import grant_object
|
||||||
|
|
||||||
|
|
||||||
class TestBulkEditAPI(DirectoriesMixin, APITestCase):
|
class TestBulkEditAPI(DirectoriesMixin, APITestCase):
|
||||||
def setUp(self) -> None:
|
def setUp(self) -> None:
|
||||||
super().setUp()
|
super().setUp()
|
||||||
|
|
||||||
user = User.objects.create_superuser(username="temp_admin")
|
user = UserFactory(username="temp_admin", superuser=True)
|
||||||
self.user = user
|
self.user = user
|
||||||
self.client.force_authenticate(user=user)
|
self.client.force_authenticate(user=user)
|
||||||
|
|
||||||
@@ -284,9 +287,9 @@ class TestBulkEditAPI(DirectoriesMixin, APITestCase):
|
|||||||
m,
|
m,
|
||||||
) -> None:
|
) -> None:
|
||||||
self.setup_mock(m, "modify_custom_fields")
|
self.setup_mock(m, "modify_custom_fields")
|
||||||
user = User.objects.create_user(username="doc-owner")
|
user = UserFactory(username="doc-owner")
|
||||||
user.user_permissions.add(Permission.objects.get(codename="change_document"))
|
grant_global(user, "change_document")
|
||||||
other_user = User.objects.create_user(username="other-user")
|
other_user = UserFactory(username="other-user")
|
||||||
source_doc = Document.objects.create(
|
source_doc = Document.objects.create(
|
||||||
checksum="source",
|
checksum="source",
|
||||||
title="Source",
|
title="Source",
|
||||||
@@ -787,10 +790,8 @@ class TestBulkEditAPI(DirectoriesMixin, APITestCase):
|
|||||||
@mock.patch("documents.serialisers.bulk_edit.set_storage_path")
|
@mock.patch("documents.serialisers.bulk_edit.set_storage_path")
|
||||||
def test_api_bulk_edit_with_all_true_resolves_owned_duplicates(self, m) -> None:
|
def test_api_bulk_edit_with_all_true_resolves_owned_duplicates(self, m) -> None:
|
||||||
self.setup_mock(m, "set_storage_path")
|
self.setup_mock(m, "set_storage_path")
|
||||||
user = User.objects.create_user(username="duplicate-owner")
|
user = UserFactory(username="duplicate-owner")
|
||||||
user.user_permissions.add(
|
grant_global(user, "change_document")
|
||||||
Permission.objects.get(codename="change_document"),
|
|
||||||
)
|
|
||||||
first_duplicate = Document.objects.create(
|
first_duplicate = Document.objects.create(
|
||||||
checksum="owned-duplicate",
|
checksum="owned-duplicate",
|
||||||
title="First duplicate",
|
title="First duplicate",
|
||||||
@@ -1178,7 +1179,7 @@ class TestBulkEditAPI(DirectoriesMixin, APITestCase):
|
|||||||
user1 = User.objects.create(username="user1")
|
user1 = User.objects.create(username="user1")
|
||||||
self.client.force_authenticate(user=user1)
|
self.client.force_authenticate(user=user1)
|
||||||
|
|
||||||
assign_perm("view_document", user1, self.doc2)
|
grant_object(user1, self.doc2, "view_document")
|
||||||
|
|
||||||
response = self.client.post(
|
response = self.client.post(
|
||||||
"/api/documents/selection_data/",
|
"/api/documents/selection_data/",
|
||||||
@@ -1188,9 +1189,7 @@ class TestBulkEditAPI(DirectoriesMixin, APITestCase):
|
|||||||
|
|
||||||
self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN)
|
self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN)
|
||||||
|
|
||||||
user1.user_permissions.add(
|
grant_global(user1, "view_document")
|
||||||
Permission.objects.get(codename="view_document"),
|
|
||||||
)
|
|
||||||
user1 = User.objects.get(pk=user1.pk)
|
user1 = User.objects.get(pk=user1.pk)
|
||||||
self.client.force_authenticate(user=user1)
|
self.client.force_authenticate(user=user1)
|
||||||
response = self.client.post(
|
response = self.client.post(
|
||||||
@@ -1533,7 +1532,7 @@ class TestBulkEditAPI(DirectoriesMixin, APITestCase):
|
|||||||
self.doc1.owner = User.objects.get(username="temp_admin")
|
self.doc1.owner = User.objects.get(username="temp_admin")
|
||||||
self.doc1.save()
|
self.doc1.save()
|
||||||
user1 = User.objects.create(username="user1")
|
user1 = User.objects.create(username="user1")
|
||||||
user1.user_permissions.add(*Permission.objects.all())
|
grant_all_global(user1)
|
||||||
user1.save()
|
user1.save()
|
||||||
self.client.force_authenticate(user=user1)
|
self.client.force_authenticate(user=user1)
|
||||||
|
|
||||||
@@ -1587,8 +1586,8 @@ class TestBulkEditAPI(DirectoriesMixin, APITestCase):
|
|||||||
self.doc1.owner = User.objects.get(username="temp_admin")
|
self.doc1.owner = User.objects.get(username="temp_admin")
|
||||||
self.doc1.save()
|
self.doc1.save()
|
||||||
user1 = User.objects.create(username="user1")
|
user1 = User.objects.create(username="user1")
|
||||||
assign_perm("view_document", user1, self.doc1)
|
grant_object(user1, self.doc1, "view_document")
|
||||||
user1.user_permissions.add(*Permission.objects.all())
|
grant_all_global(user1)
|
||||||
user1.save()
|
user1.save()
|
||||||
self.client.force_authenticate(user=user1)
|
self.client.force_authenticate(user=user1)
|
||||||
|
|
||||||
@@ -1609,7 +1608,7 @@ class TestBulkEditAPI(DirectoriesMixin, APITestCase):
|
|||||||
m.assert_not_called()
|
m.assert_not_called()
|
||||||
self.assertEqual(response.content, b"Insufficient permissions")
|
self.assertEqual(response.content, b"Insufficient permissions")
|
||||||
|
|
||||||
assign_perm("change_document", user1, self.doc1)
|
grant_object(user1, self.doc1, "change_document")
|
||||||
|
|
||||||
response = self.client.post(
|
response = self.client.post(
|
||||||
"/api/documents/bulk_edit/",
|
"/api/documents/bulk_edit/",
|
||||||
@@ -1786,6 +1785,36 @@ class TestBulkEditAPI(DirectoriesMixin, APITestCase):
|
|||||||
self.assertIn(b"invalid pages specified", response.content)
|
self.assertIn(b"invalid pages specified", response.content)
|
||||||
m.assert_not_called()
|
m.assert_not_called()
|
||||||
|
|
||||||
|
@mock.patch("documents.serialisers.bulk_edit.split")
|
||||||
|
def test_bulk_edit_split_rejects_unknown_page_count(self, m) -> None:
|
||||||
|
"""
|
||||||
|
GIVEN:
|
||||||
|
- A legacy split bulk edit of a document without a page count
|
||||||
|
WHEN:
|
||||||
|
- API to bulk edit is called
|
||||||
|
THEN:
|
||||||
|
- API returns HTTP 400
|
||||||
|
- split is not called
|
||||||
|
"""
|
||||||
|
self.setup_mock(m, "split")
|
||||||
|
|
||||||
|
for pages in ("1", "1-5000000"):
|
||||||
|
with self.subTest(pages=pages):
|
||||||
|
response = self.client.post(
|
||||||
|
"/api/documents/bulk_edit/",
|
||||||
|
json.dumps(
|
||||||
|
{
|
||||||
|
"documents": [self.doc1.id],
|
||||||
|
"method": "split",
|
||||||
|
"parameters": {"pages": pages},
|
||||||
|
},
|
||||||
|
),
|
||||||
|
content_type="application/json",
|
||||||
|
)
|
||||||
|
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
|
||||||
|
self.assertIn(b"document page count is unknown", response.content)
|
||||||
|
m.assert_not_called()
|
||||||
|
|
||||||
@mock.patch("documents.serialisers.bulk_edit.split")
|
@mock.patch("documents.serialisers.bulk_edit.split")
|
||||||
def test_bulk_edit_split_parses_pages(self, m) -> None:
|
def test_bulk_edit_split_parses_pages(self, m) -> None:
|
||||||
"""
|
"""
|
||||||
@@ -1819,7 +1848,7 @@ class TestBulkEditAPI(DirectoriesMixin, APITestCase):
|
|||||||
self.doc1.owner = User.objects.get(username="temp_admin")
|
self.doc1.owner = User.objects.get(username="temp_admin")
|
||||||
self.doc1.save()
|
self.doc1.save()
|
||||||
user1 = User.objects.create(username="user1")
|
user1 = User.objects.create(username="user1")
|
||||||
user1.user_permissions.add(*Permission.objects.all())
|
grant_all_global(user1)
|
||||||
user1.save()
|
user1.save()
|
||||||
self.client.force_authenticate(user=user1)
|
self.client.force_authenticate(user=user1)
|
||||||
|
|
||||||
@@ -1880,7 +1909,7 @@ class TestBulkEditAPI(DirectoriesMixin, APITestCase):
|
|||||||
self.doc1.owner = User.objects.get(username="temp_admin")
|
self.doc1.owner = User.objects.get(username="temp_admin")
|
||||||
self.doc1.save()
|
self.doc1.save()
|
||||||
user1 = User.objects.create(username="user1")
|
user1 = User.objects.create(username="user1")
|
||||||
user1.user_permissions.add(*Permission.objects.all())
|
grant_all_global(user1)
|
||||||
user1.save()
|
user1.save()
|
||||||
self.client.force_authenticate(user=user1)
|
self.client.force_authenticate(user=user1)
|
||||||
|
|
||||||
@@ -1919,11 +1948,8 @@ class TestBulkEditAPI(DirectoriesMixin, APITestCase):
|
|||||||
@mock.patch("documents.views.bulk_edit.merge")
|
@mock.patch("documents.views.bulk_edit.merge")
|
||||||
def test_merge_and_delete_requires_change_permission(self, m) -> None:
|
def test_merge_and_delete_requires_change_permission(self, m) -> None:
|
||||||
self.setup_mock(m, "merge")
|
self.setup_mock(m, "merge")
|
||||||
user = User.objects.create_user(username="no-change")
|
user = UserFactory(username="no-change")
|
||||||
user.user_permissions.add(
|
grant_global(user, "add_document", "delete_document")
|
||||||
Permission.objects.get(codename="add_document"),
|
|
||||||
Permission.objects.get(codename="delete_document"),
|
|
||||||
)
|
|
||||||
self.client.force_authenticate(user=user)
|
self.client.force_authenticate(user=user)
|
||||||
|
|
||||||
response = self.client.post(
|
response = self.client.post(
|
||||||
@@ -2310,7 +2336,7 @@ class TestBulkEditAPI(DirectoriesMixin, APITestCase):
|
|||||||
self.doc1.owner = User.objects.get(username="temp_admin")
|
self.doc1.owner = User.objects.get(username="temp_admin")
|
||||||
self.doc1.save()
|
self.doc1.save()
|
||||||
user1 = User.objects.create(username="user1")
|
user1 = User.objects.create(username="user1")
|
||||||
user1.user_permissions.add(*Permission.objects.all())
|
grant_all_global(user1)
|
||||||
user1.save()
|
user1.save()
|
||||||
self.client.force_authenticate(user=user1)
|
self.client.force_authenticate(user=user1)
|
||||||
|
|
||||||
@@ -2345,7 +2371,7 @@ class TestBulkEditAPI(DirectoriesMixin, APITestCase):
|
|||||||
@mock.patch("documents.views.bulk_edit.edit_pdf")
|
@mock.patch("documents.views.bulk_edit.edit_pdf")
|
||||||
def test_edit_pdf_update_requires_change_permission(self, m) -> None:
|
def test_edit_pdf_update_requires_change_permission(self, m) -> None:
|
||||||
self.setup_mock(m, "edit_pdf")
|
self.setup_mock(m, "edit_pdf")
|
||||||
user = User.objects.create_user(username="no-change")
|
user = UserFactory(username="no-change")
|
||||||
self.client.force_authenticate(user=user)
|
self.client.force_authenticate(user=user)
|
||||||
|
|
||||||
response = self.client.post(
|
response = self.client.post(
|
||||||
@@ -2372,11 +2398,8 @@ class TestBulkEditAPI(DirectoriesMixin, APITestCase):
|
|||||||
) -> None:
|
) -> None:
|
||||||
self.setup_mock(edit_pdf_mock, "edit_pdf")
|
self.setup_mock(edit_pdf_mock, "edit_pdf")
|
||||||
self.setup_mock(remove_password_mock, "remove_password")
|
self.setup_mock(remove_password_mock, "remove_password")
|
||||||
user = User.objects.create_user(username="no-delete")
|
user = UserFactory(username="no-delete")
|
||||||
user.user_permissions.add(
|
grant_global(user, "add_document", "change_document")
|
||||||
Permission.objects.get(codename="add_document"),
|
|
||||||
Permission.objects.get(codename="change_document"),
|
|
||||||
)
|
|
||||||
self.client.force_authenticate(user=user)
|
self.client.force_authenticate(user=user)
|
||||||
|
|
||||||
cases = [
|
cases = [
|
||||||
@@ -2463,7 +2486,7 @@ class TestBulkEditAPI(DirectoriesMixin, APITestCase):
|
|||||||
self.doc1.owner = User.objects.get(username="temp_admin")
|
self.doc1.owner = User.objects.get(username="temp_admin")
|
||||||
self.doc1.save()
|
self.doc1.save()
|
||||||
user1 = User.objects.create(username="user1")
|
user1 = User.objects.create(username="user1")
|
||||||
user1.user_permissions.add(*Permission.objects.all())
|
grant_all_global(user1)
|
||||||
user1.save()
|
user1.save()
|
||||||
self.client.force_authenticate(user=user1)
|
self.client.force_authenticate(user=user1)
|
||||||
|
|
||||||
@@ -2503,7 +2526,7 @@ class TestBulkEditAPI(DirectoriesMixin, APITestCase):
|
|||||||
WHEN:
|
WHEN:
|
||||||
- API to bulk edit documents is called
|
- API to bulk edit documents is called
|
||||||
THEN:
|
THEN:
|
||||||
- Audit log is created
|
- Audit log is created with the old and new correspondent
|
||||||
"""
|
"""
|
||||||
LogEntry.objects.all().delete()
|
LogEntry.objects.all().delete()
|
||||||
response = self.client.post(
|
response = self.client.post(
|
||||||
@@ -2519,7 +2542,8 @@ class TestBulkEditAPI(DirectoriesMixin, APITestCase):
|
|||||||
)
|
)
|
||||||
|
|
||||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||||
self.assertEqual(LogEntry.objects.filter(object_pk=self.doc1.id).count(), 1)
|
entry = LogEntry.objects.get_for_object(self.doc1).get()
|
||||||
|
self.assertEqual(entry.changes, {"correspondent": [None, self.c2.id]})
|
||||||
|
|
||||||
@override_settings(AUDIT_LOG_ENABLED=True)
|
@override_settings(AUDIT_LOG_ENABLED=True)
|
||||||
def test_bulk_edit_audit_log_enabled_tags(self) -> None:
|
def test_bulk_edit_audit_log_enabled_tags(self) -> None:
|
||||||
@@ -2527,16 +2551,18 @@ class TestBulkEditAPI(DirectoriesMixin, APITestCase):
|
|||||||
GIVEN:
|
GIVEN:
|
||||||
- Audit log is enabled
|
- Audit log is enabled
|
||||||
WHEN:
|
WHEN:
|
||||||
- API to bulk edit tags is called
|
- API to bulk edit tags is called on an untagged document and a
|
||||||
|
document with several tags
|
||||||
THEN:
|
THEN:
|
||||||
- Audit log is created
|
- Audit log is created for each document with its full tag list
|
||||||
|
before and after the edit
|
||||||
"""
|
"""
|
||||||
LogEntry.objects.all().delete()
|
LogEntry.objects.all().delete()
|
||||||
response = self.client.post(
|
response = self.client.post(
|
||||||
"/api/documents/bulk_edit/",
|
"/api/documents/bulk_edit/",
|
||||||
json.dumps(
|
json.dumps(
|
||||||
{
|
{
|
||||||
"documents": [self.doc1.id],
|
"documents": [self.doc1.id, self.doc4.id],
|
||||||
"method": "modify_tags",
|
"method": "modify_tags",
|
||||||
"parameters": {
|
"parameters": {
|
||||||
"add_tags": [self.t1.id],
|
"add_tags": [self.t1.id],
|
||||||
@@ -2548,18 +2574,32 @@ class TestBulkEditAPI(DirectoriesMixin, APITestCase):
|
|||||||
)
|
)
|
||||||
|
|
||||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||||
self.assertEqual(LogEntry.objects.filter(object_pk=self.doc1.id).count(), 1)
|
entry = LogEntry.objects.get_for_object(self.doc1).get()
|
||||||
|
self.assertEqual(entry.changes, {"tags": [[], [self.t1.id]]})
|
||||||
|
entry = LogEntry.objects.get_for_object(self.doc4).get()
|
||||||
|
self.assertEqual(
|
||||||
|
entry.changes,
|
||||||
|
{"tags": [[self.t1.id, self.t2.id], [self.t1.id]]},
|
||||||
|
)
|
||||||
|
|
||||||
@override_settings(AUDIT_LOG_ENABLED=True)
|
@override_settings(AUDIT_LOG_ENABLED=True)
|
||||||
def test_bulk_edit_audit_log_enabled_custom_fields(self) -> None:
|
def test_bulk_edit_audit_log_enabled_custom_fields(self) -> None:
|
||||||
"""
|
"""
|
||||||
GIVEN:
|
GIVEN:
|
||||||
- Audit log is enabled
|
- Audit log is enabled
|
||||||
|
- A document with two custom fields
|
||||||
WHEN:
|
WHEN:
|
||||||
- API to bulk edit custom fields is called
|
- API to bulk edit custom fields is called to add a third
|
||||||
THEN:
|
THEN:
|
||||||
- Audit log is created
|
- Audit log is created with every custom field instance before and
|
||||||
|
after the edit
|
||||||
|
- Audit log is created for the new custom field instance
|
||||||
"""
|
"""
|
||||||
|
cf3 = CustomField.objects.create(name="cf3", data_type="string")
|
||||||
|
existing = [
|
||||||
|
CustomFieldInstance.objects.create(document=self.doc1, field=field)
|
||||||
|
for field in (self.cf2, cf3)
|
||||||
|
]
|
||||||
LogEntry.objects.all().delete()
|
LogEntry.objects.all().delete()
|
||||||
response = self.client.post(
|
response = self.client.post(
|
||||||
"/api/documents/bulk_edit/",
|
"/api/documents/bulk_edit/",
|
||||||
@@ -2577,7 +2617,14 @@ class TestBulkEditAPI(DirectoriesMixin, APITestCase):
|
|||||||
)
|
)
|
||||||
|
|
||||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||||
self.assertEqual(LogEntry.objects.filter(object_pk=self.doc1.id).count(), 2)
|
added = CustomFieldInstance.objects.get(document=self.doc1, field=self.cf1)
|
||||||
|
existing_ids = [instance.id for instance in existing]
|
||||||
|
entry = LogEntry.objects.get_for_object(self.doc1).get()
|
||||||
|
self.assertEqual(
|
||||||
|
entry.changes,
|
||||||
|
{"custom_fields": [existing_ids, [*existing_ids, added.id]]},
|
||||||
|
)
|
||||||
|
self.assertEqual(LogEntry.objects.get_for_object(added).count(), 1)
|
||||||
|
|
||||||
def test_api_bulk_edit_with_bad_search_query_returns_400(self) -> None:
|
def test_api_bulk_edit_with_bad_search_query_returns_400(self) -> None:
|
||||||
"""
|
"""
|
||||||
|
|||||||
@@ -4,20 +4,22 @@ from typing import TYPE_CHECKING
|
|||||||
from unittest import mock
|
from unittest import mock
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
from django.contrib.auth.models import Permission
|
|
||||||
from django.contrib.auth.models import User
|
|
||||||
from rest_framework import status
|
from rest_framework import status
|
||||||
from rest_framework.test import APIClient
|
from rest_framework.test import APIClient
|
||||||
from rest_framework.test import APITestCase
|
from rest_framework.test import APITestCase
|
||||||
|
|
||||||
|
from paperless_testing.factories import UserFactory
|
||||||
|
from paperless_testing.permissions import grant_global
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
|
from django.contrib.auth.models import User
|
||||||
from pytest_mock import MockerFixture
|
from pytest_mock import MockerFixture
|
||||||
|
|
||||||
|
|
||||||
class TestChatStreamingViewInputValidation(APITestCase):
|
class TestChatStreamingViewInputValidation(APITestCase):
|
||||||
def setUp(self) -> None:
|
def setUp(self) -> None:
|
||||||
super().setUp()
|
super().setUp()
|
||||||
self.user = User.objects.create_superuser(username="temp_admin")
|
self.user = UserFactory(username="temp_admin", superuser=True)
|
||||||
self.client.force_authenticate(user=self.user)
|
self.client.force_authenticate(user=self.user)
|
||||||
|
|
||||||
def _mock_ai_enabled(self) -> mock.MagicMock:
|
def _mock_ai_enabled(self) -> mock.MagicMock:
|
||||||
@@ -113,9 +115,7 @@ class TestChatStreamingViewUnrestrictedFlag:
|
|||||||
needs to reach the view at all. Model-level only: says nothing
|
needs to reach the view at all. Model-level only: says nothing
|
||||||
about which documents (if any) this user can actually see.
|
about which documents (if any) this user can actually see.
|
||||||
"""
|
"""
|
||||||
regular_user.user_permissions.add(
|
grant_global(regular_user, "view_document")
|
||||||
*Permission.objects.filter(codename="view_document"),
|
|
||||||
)
|
|
||||||
return user_client
|
return user_client
|
||||||
|
|
||||||
@pytest.mark.parametrize(
|
@pytest.mark.parametrize(
|
||||||
|
|||||||
@@ -4,23 +4,24 @@ from unittest import mock
|
|||||||
from unittest.mock import ANY
|
from unittest.mock import ANY
|
||||||
|
|
||||||
from django.contrib.auth.models import Permission
|
from django.contrib.auth.models import Permission
|
||||||
from django.contrib.auth.models import User
|
|
||||||
from django.test import override_settings
|
from django.test import override_settings
|
||||||
from guardian.shortcuts import assign_perm
|
|
||||||
from rest_framework import status
|
from rest_framework import status
|
||||||
from rest_framework.test import APITestCase
|
from rest_framework.test import APITestCase
|
||||||
|
|
||||||
from documents.models import CustomField
|
from documents.models import CustomField
|
||||||
from documents.models import CustomFieldInstance
|
from documents.models import CustomFieldInstance
|
||||||
from documents.models import Document
|
from documents.models import Document
|
||||||
from documents.tests.utils import DirectoriesMixin
|
from paperless_testing.dirs import DirectoriesMixin
|
||||||
|
from paperless_testing.factories import UserFactory
|
||||||
|
from paperless_testing.permissions import grant_global
|
||||||
|
from paperless_testing.permissions import grant_object
|
||||||
|
|
||||||
|
|
||||||
class TestCustomFieldsAPI(DirectoriesMixin, APITestCase):
|
class TestCustomFieldsAPI(DirectoriesMixin, APITestCase):
|
||||||
ENDPOINT = "/api/custom_fields/"
|
ENDPOINT = "/api/custom_fields/"
|
||||||
|
|
||||||
def setUp(self) -> None:
|
def setUp(self) -> None:
|
||||||
self.user = User.objects.create_superuser(username="temp_admin")
|
self.user = UserFactory(username="temp_admin", superuser=True)
|
||||||
self.client.force_authenticate(user=self.user)
|
self.client.force_authenticate(user=self.user)
|
||||||
return super().setUp()
|
return super().setUp()
|
||||||
|
|
||||||
@@ -1174,11 +1175,9 @@ class TestCustomFieldsAPI(DirectoriesMixin, APITestCase):
|
|||||||
def test_documentlink_patch_requires_change_permission_on_target_documents(
|
def test_documentlink_patch_requires_change_permission_on_target_documents(
|
||||||
self,
|
self,
|
||||||
) -> None:
|
) -> None:
|
||||||
source_owner = User.objects.create_user(username="source-owner")
|
source_owner = UserFactory(username="source-owner")
|
||||||
source_owner.user_permissions.add(
|
grant_global(source_owner, "change_document")
|
||||||
Permission.objects.get(codename="change_document"),
|
other_user = UserFactory(username="other-user")
|
||||||
)
|
|
||||||
other_user = User.objects.create_user(username="other-user")
|
|
||||||
|
|
||||||
source_doc = Document.objects.create(
|
source_doc = Document.objects.create(
|
||||||
title="Source",
|
title="Source",
|
||||||
@@ -1221,11 +1220,9 @@ class TestCustomFieldsAPI(DirectoriesMixin, APITestCase):
|
|||||||
def test_documentlink_patch_allowed_with_change_permission_on_target_documents(
|
def test_documentlink_patch_allowed_with_change_permission_on_target_documents(
|
||||||
self,
|
self,
|
||||||
) -> None:
|
) -> None:
|
||||||
source_owner = User.objects.create_user(username="source-owner")
|
source_owner = UserFactory(username="source-owner")
|
||||||
source_owner.user_permissions.add(
|
grant_global(source_owner, "change_document")
|
||||||
Permission.objects.get(codename="change_document"),
|
other_user = UserFactory(username="other-user")
|
||||||
)
|
|
||||||
other_user = User.objects.create_user(username="other-user")
|
|
||||||
|
|
||||||
source_doc = Document.objects.create(
|
source_doc = Document.objects.create(
|
||||||
title="Source",
|
title="Source",
|
||||||
@@ -1244,7 +1241,7 @@ class TestCustomFieldsAPI(DirectoriesMixin, APITestCase):
|
|||||||
data_type=CustomField.FieldDataType.DOCUMENTLINK,
|
data_type=CustomField.FieldDataType.DOCUMENTLINK,
|
||||||
)
|
)
|
||||||
|
|
||||||
assign_perm("change_document", source_owner, target_doc)
|
grant_object(source_owner, target_doc, "change_document")
|
||||||
self.client.force_authenticate(user=source_owner)
|
self.client.force_authenticate(user=source_owner)
|
||||||
|
|
||||||
resp = self.client.patch(
|
resp = self.client.patch(
|
||||||
@@ -1337,7 +1334,7 @@ class TestCustomFieldsAPI(DirectoriesMixin, APITestCase):
|
|||||||
self.assertEqual(results[0]["document_count"], 1)
|
self.assertEqual(results[0]["document_count"], 1)
|
||||||
|
|
||||||
# Test as user without access to the document
|
# Test as user without access to the document
|
||||||
non_superuser = User.objects.create_user(username="non_superuser")
|
non_superuser = UserFactory(username="non_superuser")
|
||||||
non_superuser.user_permissions.add(
|
non_superuser.user_permissions.add(
|
||||||
*Permission.objects.exclude(codename="view_global_statistics"),
|
*Permission.objects.exclude(codename="view_global_statistics"),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -5,8 +5,6 @@ from typing import TYPE_CHECKING
|
|||||||
from unittest import mock
|
from unittest import mock
|
||||||
|
|
||||||
from auditlog.models import LogEntry # type: ignore[import-untyped]
|
from auditlog.models import LogEntry # type: ignore[import-untyped]
|
||||||
from django.contrib.auth.models import Permission
|
|
||||||
from django.contrib.auth.models import User
|
|
||||||
from django.contrib.contenttypes.models import ContentType
|
from django.contrib.contenttypes.models import ContentType
|
||||||
from django.core.files.uploadedfile import SimpleUploadedFile
|
from django.core.files.uploadedfile import SimpleUploadedFile
|
||||||
from django.test import TestCase as DjangoTestCase
|
from django.test import TestCase as DjangoTestCase
|
||||||
@@ -18,10 +16,12 @@ from documents.data_models import DocumentSource
|
|||||||
from documents.filters import EffectiveContentFilter
|
from documents.filters import EffectiveContentFilter
|
||||||
from documents.filters import TitleContentFilter
|
from documents.filters import TitleContentFilter
|
||||||
from documents.models import Document
|
from documents.models import Document
|
||||||
from documents.tests.utils import DirectoriesMixin
|
|
||||||
from documents.tests.utils import read_streaming_response
|
|
||||||
from documents.versioning import annotate_effective_content
|
from documents.versioning import annotate_effective_content
|
||||||
from documents.views import DocumentSelectionMixin
|
from documents.views import DocumentSelectionMixin
|
||||||
|
from paperless_testing.dirs import DirectoriesMixin
|
||||||
|
from paperless_testing.factories import UserFactory
|
||||||
|
from paperless_testing.http import read_streaming_response
|
||||||
|
from paperless_testing.permissions import grant_global
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
@@ -31,7 +31,7 @@ class TestDocumentVersioningApi(DirectoriesMixin, APITestCase):
|
|||||||
def setUp(self) -> None:
|
def setUp(self) -> None:
|
||||||
super().setUp()
|
super().setUp()
|
||||||
|
|
||||||
self.user = User.objects.create_superuser(username="temp_admin")
|
self.user = UserFactory(username="temp_admin", superuser=True)
|
||||||
self.client.force_authenticate(user=self.user)
|
self.client.force_authenticate(user=self.user)
|
||||||
|
|
||||||
def _make_pdf_upload(self, name: str = "version.pdf") -> SimpleUploadedFile:
|
def _make_pdf_upload(self, name: str = "version.pdf") -> SimpleUploadedFile:
|
||||||
@@ -89,11 +89,9 @@ class TestDocumentVersioningApi(DirectoriesMixin, APITestCase):
|
|||||||
self.assertEqual(resp.status_code, status.HTTP_404_NOT_FOUND)
|
self.assertEqual(resp.status_code, status.HTTP_404_NOT_FOUND)
|
||||||
|
|
||||||
def test_root_endpoint_returns_403_when_user_lacks_permission(self) -> None:
|
def test_root_endpoint_returns_403_when_user_lacks_permission(self) -> None:
|
||||||
owner = User.objects.create_user(username="owner")
|
owner = UserFactory(username="owner")
|
||||||
viewer = User.objects.create_user(username="viewer")
|
viewer = UserFactory(username="viewer")
|
||||||
viewer.user_permissions.add(
|
grant_global(viewer, "view_document")
|
||||||
Permission.objects.get(codename="view_document"),
|
|
||||||
)
|
|
||||||
root = Document.objects.create(
|
root = Document.objects.create(
|
||||||
title="root",
|
title="root",
|
||||||
checksum="root",
|
checksum="root",
|
||||||
@@ -283,11 +281,9 @@ class TestDocumentVersioningApi(DirectoriesMixin, APITestCase):
|
|||||||
self.assertEqual(mock_backend.add_or_update.call_args[0][0].id, root.id)
|
self.assertEqual(mock_backend.add_or_update.call_args[0][0].id, root.id)
|
||||||
|
|
||||||
def test_delete_version_returns_403_without_permission(self) -> None:
|
def test_delete_version_returns_403_without_permission(self) -> None:
|
||||||
owner = User.objects.create_user(username="owner")
|
owner = UserFactory(username="owner")
|
||||||
other = User.objects.create_user(username="other")
|
other = UserFactory(username="other")
|
||||||
other.user_permissions.add(
|
grant_global(other, "delete_document")
|
||||||
Permission.objects.get(codename="delete_document"),
|
|
||||||
)
|
|
||||||
root = Document.objects.create(
|
root = Document.objects.create(
|
||||||
title="root",
|
title="root",
|
||||||
checksum="root",
|
checksum="root",
|
||||||
@@ -371,11 +367,9 @@ class TestDocumentVersioningApi(DirectoriesMixin, APITestCase):
|
|||||||
self.assertTrue(resp.data["is_root"])
|
self.assertTrue(resp.data["is_root"])
|
||||||
|
|
||||||
def test_update_version_label_returns_403_without_permission(self) -> None:
|
def test_update_version_label_returns_403_without_permission(self) -> None:
|
||||||
owner = User.objects.create_user(username="owner")
|
owner = UserFactory(username="owner")
|
||||||
other = User.objects.create_user(username="other")
|
other = UserFactory(username="other")
|
||||||
other.user_permissions.add(
|
grant_global(other, "change_document")
|
||||||
Permission.objects.get(codename="change_document"),
|
|
||||||
)
|
|
||||||
root = Document.objects.create(
|
root = Document.objects.create(
|
||||||
title="root",
|
title="root",
|
||||||
checksum="root",
|
checksum="root",
|
||||||
@@ -553,11 +547,9 @@ class TestDocumentVersioningApi(DirectoriesMixin, APITestCase):
|
|||||||
self.assertEqual(resp.status_code, status.HTTP_404_NOT_FOUND)
|
self.assertEqual(resp.status_code, status.HTTP_404_NOT_FOUND)
|
||||||
|
|
||||||
def test_metadata_returns_403_when_user_lacks_permission(self) -> None:
|
def test_metadata_returns_403_when_user_lacks_permission(self) -> None:
|
||||||
owner = User.objects.create_user(username="owner")
|
owner = UserFactory(username="owner")
|
||||||
other = User.objects.create_user(username="other")
|
other = UserFactory(username="other")
|
||||||
other.user_permissions.add(
|
grant_global(other, "view_document")
|
||||||
Permission.objects.get(codename="view_document"),
|
|
||||||
)
|
|
||||||
doc = Document.objects.create(
|
doc = Document.objects.create(
|
||||||
title="root",
|
title="root",
|
||||||
checksum="root",
|
checksum="root",
|
||||||
@@ -653,8 +645,8 @@ class TestDocumentVersioningApi(DirectoriesMixin, APITestCase):
|
|||||||
self.assertEqual(resp.status_code, status.HTTP_500_INTERNAL_SERVER_ERROR)
|
self.assertEqual(resp.status_code, status.HTTP_500_INTERNAL_SERVER_ERROR)
|
||||||
|
|
||||||
def test_update_version_returns_403_without_permission(self) -> None:
|
def test_update_version_returns_403_without_permission(self) -> None:
|
||||||
owner = User.objects.create_user(username="owner")
|
owner = UserFactory(username="owner")
|
||||||
other = User.objects.create_user(username="other")
|
other = UserFactory(username="other")
|
||||||
root = Document.objects.create(
|
root = Document.objects.create(
|
||||||
title="root",
|
title="root",
|
||||||
checksum="root",
|
checksum="root",
|
||||||
@@ -672,8 +664,8 @@ class TestDocumentVersioningApi(DirectoriesMixin, APITestCase):
|
|||||||
self.assertEqual(resp.status_code, status.HTTP_403_FORBIDDEN)
|
self.assertEqual(resp.status_code, status.HTTP_403_FORBIDDEN)
|
||||||
|
|
||||||
def test_update_version_requires_global_change_permission(self) -> None:
|
def test_update_version_requires_global_change_permission(self) -> None:
|
||||||
user = User.objects.create_user(username="add-only")
|
user = UserFactory(username="add-only")
|
||||||
user.user_permissions.add(Permission.objects.get(codename="add_document"))
|
grant_global(user, "add_document")
|
||||||
root = Document.objects.create(
|
root = Document.objects.create(
|
||||||
title="root",
|
title="root",
|
||||||
checksum="root",
|
checksum="root",
|
||||||
@@ -978,7 +970,7 @@ class TestVersionAwareFilters(DjangoTestCase):
|
|||||||
superseded content -- selecting documents the list view, filtered by
|
superseded content -- selecting documents the list view, filtered by
|
||||||
the same term, does not show.
|
the same term, does not show.
|
||||||
"""
|
"""
|
||||||
user = User.objects.create_superuser(username="bulk_selection")
|
user = UserFactory(username="bulk_selection", superuser=True)
|
||||||
|
|
||||||
selected = DocumentSelectionMixin()._resolve_document_ids(
|
selected = DocumentSelectionMixin()._resolve_document_ids(
|
||||||
user=user,
|
user=user,
|
||||||
@@ -1005,7 +997,7 @@ class TestBulkSelectionExcludesVersions(DjangoTestCase):
|
|||||||
"Select all matching" reconstructs the document list, which never
|
"Select all matching" reconstructs the document list, which never
|
||||||
contains version documents as rows of their own.
|
contains version documents as rows of their own.
|
||||||
"""
|
"""
|
||||||
user = User.objects.create_superuser(username="bulk_versions")
|
user = UserFactory(username="bulk_versions", superuser=True)
|
||||||
root = Document.objects.create(
|
root = Document.objects.create(
|
||||||
title="shared-title root",
|
title="shared-title root",
|
||||||
checksum="bulk-root",
|
checksum="bulk-root",
|
||||||
|
|||||||
@@ -23,7 +23,6 @@ from django.core.files.uploadedfile import SimpleUploadedFile
|
|||||||
from django.db import DataError
|
from django.db import DataError
|
||||||
from django.test import override_settings
|
from django.test import override_settings
|
||||||
from django.utils import timezone
|
from django.utils import timezone
|
||||||
from guardian.shortcuts import assign_perm
|
|
||||||
from rest_framework import status
|
from rest_framework import status
|
||||||
from rest_framework.test import APITestCase
|
from rest_framework.test import APITestCase
|
||||||
|
|
||||||
@@ -48,18 +47,22 @@ from documents.models import Workflow
|
|||||||
from documents.models import WorkflowAction
|
from documents.models import WorkflowAction
|
||||||
from documents.models import WorkflowTrigger
|
from documents.models import WorkflowTrigger
|
||||||
from documents.signals.handlers import run_workflows
|
from documents.signals.handlers import run_workflows
|
||||||
from documents.tests.factories import DocumentFactory
|
|
||||||
from documents.tests.factories import TagFactory
|
|
||||||
from documents.tests.utils import ConsumeTaskMixin
|
from documents.tests.utils import ConsumeTaskMixin
|
||||||
from documents.tests.utils import DirectoriesMixin
|
from paperless_testing.dirs import DirectoriesMixin
|
||||||
from documents.tests.utils import read_streaming_response
|
from paperless_testing.factories import DocumentFactory
|
||||||
|
from paperless_testing.factories import TagFactory
|
||||||
|
from paperless_testing.factories import UserFactory
|
||||||
|
from paperless_testing.http import read_streaming_response
|
||||||
|
from paperless_testing.permissions import grant_all_global
|
||||||
|
from paperless_testing.permissions import grant_global
|
||||||
|
from paperless_testing.permissions import grant_object
|
||||||
|
|
||||||
|
|
||||||
class TestDocumentApi(DirectoriesMixin, ConsumeTaskMixin, APITestCase):
|
class TestDocumentApi(DirectoriesMixin, ConsumeTaskMixin, APITestCase):
|
||||||
def setUp(self) -> None:
|
def setUp(self) -> None:
|
||||||
super().setUp()
|
super().setUp()
|
||||||
|
|
||||||
self.user = User.objects.create_superuser(username="temp_admin")
|
self.user = UserFactory(username="temp_admin", superuser=True)
|
||||||
self.client.force_authenticate(user=self.user)
|
self.client.force_authenticate(user=self.user)
|
||||||
cache.clear()
|
cache.clear()
|
||||||
|
|
||||||
@@ -357,10 +360,10 @@ class TestDocumentApi(DirectoriesMixin, ConsumeTaskMixin, APITestCase):
|
|||||||
with Path(filename).open("wb") as f:
|
with Path(filename).open("wb") as f:
|
||||||
f.write(content)
|
f.write(content)
|
||||||
|
|
||||||
user1 = User.objects.create_user(username="test1")
|
user1 = UserFactory(username="test1")
|
||||||
user2 = User.objects.create_user(username="test2")
|
user2 = UserFactory(username="test2")
|
||||||
user1.user_permissions.add(*Permission.objects.filter(codename="view_document"))
|
grant_global(user1, "view_document")
|
||||||
user2.user_permissions.add(*Permission.objects.filter(codename="view_document"))
|
grant_global(user2, "view_document")
|
||||||
|
|
||||||
self.client.force_authenticate(user2)
|
self.client.force_authenticate(user2)
|
||||||
|
|
||||||
@@ -383,7 +386,7 @@ class TestDocumentApi(DirectoriesMixin, ConsumeTaskMixin, APITestCase):
|
|||||||
response = self.client.get(f"/api/documents/{doc.pk}/thumb/")
|
response = self.client.get(f"/api/documents/{doc.pk}/thumb/")
|
||||||
self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN)
|
self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN)
|
||||||
|
|
||||||
assign_perm("view_document", user2, doc)
|
grant_object(user2, doc, "view_document")
|
||||||
|
|
||||||
response = self.client.get(f"/api/documents/{doc.pk}/download/")
|
response = self.client.get(f"/api/documents/{doc.pk}/download/")
|
||||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||||
@@ -760,8 +763,8 @@ class TestDocumentApi(DirectoriesMixin, ConsumeTaskMixin, APITestCase):
|
|||||||
- History is returned
|
- History is returned
|
||||||
"""
|
"""
|
||||||
# No auditlog permissions
|
# No auditlog permissions
|
||||||
user = User.objects.create_user(username="test")
|
user = UserFactory(username="test")
|
||||||
user.user_permissions.add(*Permission.objects.filter(codename="view_document"))
|
grant_global(user, "view_document")
|
||||||
self.client.force_authenticate(user=user)
|
self.client.force_authenticate(user=user)
|
||||||
doc = Document.objects.create(
|
doc = Document.objects.create(
|
||||||
title="First title",
|
title="First title",
|
||||||
@@ -776,7 +779,7 @@ class TestDocumentApi(DirectoriesMixin, ConsumeTaskMixin, APITestCase):
|
|||||||
# superuser
|
# superuser
|
||||||
user.is_superuser = True
|
user.is_superuser = True
|
||||||
user.save()
|
user.save()
|
||||||
user2 = User.objects.create_user(username="test2")
|
user2 = UserFactory(username="test2")
|
||||||
doc2 = Document.objects.create(
|
doc2 = Document.objects.create(
|
||||||
title="Second title",
|
title="Second title",
|
||||||
checksum="456",
|
checksum="456",
|
||||||
@@ -1073,11 +1076,9 @@ class TestDocumentApi(DirectoriesMixin, ConsumeTaskMixin, APITestCase):
|
|||||||
self.assertEqual(response.data["duplicate_documents"], [])
|
self.assertEqual(response.data["duplicate_documents"], [])
|
||||||
|
|
||||||
def test_has_duplicates_filter_respects_document_permissions(self) -> None:
|
def test_has_duplicates_filter_respects_document_permissions(self) -> None:
|
||||||
owner = User.objects.create_user(username="duplicate-owner")
|
owner = UserFactory(username="duplicate-owner")
|
||||||
requester = User.objects.create_user(username="duplicate-requester")
|
requester = UserFactory(username="duplicate-requester")
|
||||||
requester.user_permissions.add(
|
grant_global(requester, "view_document")
|
||||||
Permission.objects.get(codename="view_document"),
|
|
||||||
)
|
|
||||||
visible_document = Document.objects.create(
|
visible_document = Document.objects.create(
|
||||||
title="visible document",
|
title="visible document",
|
||||||
checksum="permission-match",
|
checksum="permission-match",
|
||||||
@@ -1096,7 +1097,7 @@ class TestDocumentApi(DirectoriesMixin, ConsumeTaskMixin, APITestCase):
|
|||||||
[document["id"] for document in response.data["results"]],
|
[document["id"] for document in response.data["results"]],
|
||||||
)
|
)
|
||||||
|
|
||||||
assign_perm("view_document", requester, hidden_duplicate)
|
grant_object(requester, hidden_duplicate, "view_document")
|
||||||
response = self.client.get("/api/documents/?has_duplicates=true")
|
response = self.client.get("/api/documents/?has_duplicates=true")
|
||||||
self.assertIn(
|
self.assertIn(
|
||||||
visible_document.id,
|
visible_document.id,
|
||||||
@@ -1317,10 +1318,10 @@ class TestDocumentApi(DirectoriesMixin, ConsumeTaskMixin, APITestCase):
|
|||||||
THEN:
|
THEN:
|
||||||
- Owner filters work correctly but still respect permissions
|
- Owner filters work correctly but still respect permissions
|
||||||
"""
|
"""
|
||||||
u1 = User.objects.create_user("user1")
|
u1 = UserFactory(username="user1")
|
||||||
u2 = User.objects.create_user("user2")
|
u2 = UserFactory(username="user2")
|
||||||
u1.user_permissions.add(*Permission.objects.filter(codename="view_document"))
|
grant_global(u1, "view_document")
|
||||||
u2.user_permissions.add(*Permission.objects.filter(codename="view_document"))
|
grant_global(u2, "view_document")
|
||||||
|
|
||||||
u1_doc1 = Document.objects.create(
|
u1_doc1 = Document.objects.create(
|
||||||
title="none1",
|
title="none1",
|
||||||
@@ -1353,7 +1354,7 @@ class TestDocumentApi(DirectoriesMixin, ConsumeTaskMixin, APITestCase):
|
|||||||
)
|
)
|
||||||
|
|
||||||
self.client.force_authenticate(user=u1)
|
self.client.force_authenticate(user=u1)
|
||||||
assign_perm("view_document", u1, u2_doc2)
|
grant_object(u1, u2_doc2, "view_document")
|
||||||
|
|
||||||
# Will not show any u1 docs or u2_doc1 which isn't shared
|
# Will not show any u1 docs or u2_doc1 which isn't shared
|
||||||
response = self.client.get(f"/api/documents/?owner__id__none={u1.id}")
|
response = self.client.get(f"/api/documents/?owner__id__none={u1.id}")
|
||||||
@@ -1400,7 +1401,7 @@ class TestDocumentApi(DirectoriesMixin, ConsumeTaskMixin, APITestCase):
|
|||||||
[u1_doc1.id, u1_doc2.id, u2_doc2.id],
|
[u1_doc1.id, u1_doc2.id, u2_doc2.id],
|
||||||
)
|
)
|
||||||
|
|
||||||
assign_perm("view_document", u2, u1_doc1)
|
grant_object(u2, u1_doc1, "view_document")
|
||||||
|
|
||||||
# Will show only documents shared by user
|
# Will show only documents shared by user
|
||||||
response = self.client.get(f"/api/documents/?shared_by__id={u1.id}")
|
response = self.client.get(f"/api/documents/?shared_by__id={u1.id}")
|
||||||
@@ -1424,8 +1425,8 @@ class TestDocumentApi(DirectoriesMixin, ConsumeTaskMixin, APITestCase):
|
|||||||
- The document is returned exactly once, not once per permission path
|
- The document is returned exactly once, not once per permission path
|
||||||
(regression test for https://github.com/paperless-ngx/paperless-ngx/issues/13331)
|
(regression test for https://github.com/paperless-ngx/paperless-ngx/issues/13331)
|
||||||
"""
|
"""
|
||||||
user = User.objects.create_user("user1")
|
user = UserFactory(username="user1")
|
||||||
user.user_permissions.add(*Permission.objects.filter(codename="view_document"))
|
grant_global(user, "view_document")
|
||||||
group = Group.objects.create(name="group1")
|
group = Group.objects.create(name="group1")
|
||||||
user.groups.add(group)
|
user.groups.add(group)
|
||||||
|
|
||||||
@@ -1433,7 +1434,7 @@ class TestDocumentApi(DirectoriesMixin, ConsumeTaskMixin, APITestCase):
|
|||||||
tag2 = TagFactory()
|
tag2 = TagFactory()
|
||||||
doc = DocumentFactory(title="shared", owner=user)
|
doc = DocumentFactory(title="shared", owner=user)
|
||||||
doc.tags.add(tag1, tag2)
|
doc.tags.add(tag1, tag2)
|
||||||
assign_perm("view_document", group, doc)
|
grant_object(group, doc, "view_document")
|
||||||
|
|
||||||
self.client.force_authenticate(user=user)
|
self.client.force_authenticate(user=user)
|
||||||
response = self.client.get(
|
response = self.client.get(
|
||||||
@@ -1452,11 +1453,9 @@ class TestDocumentApi(DirectoriesMixin, ConsumeTaskMixin, APITestCase):
|
|||||||
THEN:
|
THEN:
|
||||||
- The document does not appear in their results
|
- The document does not appear in their results
|
||||||
"""
|
"""
|
||||||
owner = User.objects.create_user("owner1")
|
owner = UserFactory(username="owner1")
|
||||||
stranger = User.objects.create_user("stranger1")
|
stranger = UserFactory(username="stranger1")
|
||||||
stranger.user_permissions.add(
|
grant_global(stranger, "view_document")
|
||||||
*Permission.objects.filter(codename="view_document"),
|
|
||||||
)
|
|
||||||
|
|
||||||
DocumentFactory(title="private", owner=owner)
|
DocumentFactory(title="private", owner=owner)
|
||||||
|
|
||||||
@@ -1474,17 +1473,17 @@ class TestDocumentApi(DirectoriesMixin, ConsumeTaskMixin, APITestCase):
|
|||||||
THEN:
|
THEN:
|
||||||
- Only the group member sees the document
|
- Only the group member sees the document
|
||||||
"""
|
"""
|
||||||
owner = User.objects.create_user("owner2")
|
owner = UserFactory(username="owner2")
|
||||||
member = User.objects.create_user("member1")
|
member = UserFactory(username="member1")
|
||||||
non_member = User.objects.create_user("nonmember1")
|
non_member = UserFactory(username="nonmember1")
|
||||||
for u in (member, non_member):
|
for u in (member, non_member):
|
||||||
u.user_permissions.add(*Permission.objects.filter(codename="view_document"))
|
grant_global(u, "view_document")
|
||||||
|
|
||||||
group = Group.objects.create(name="group2")
|
group = Group.objects.create(name="group2")
|
||||||
member.groups.add(group)
|
member.groups.add(group)
|
||||||
|
|
||||||
doc = DocumentFactory(title="shared2", owner=owner)
|
doc = DocumentFactory(title="shared2", owner=owner)
|
||||||
assign_perm("view_document", group, doc)
|
grant_object(group, doc, "view_document")
|
||||||
|
|
||||||
self.client.force_authenticate(user=member)
|
self.client.force_authenticate(user=member)
|
||||||
response = self.client.get("/api/documents/")
|
response = self.client.get("/api/documents/")
|
||||||
@@ -1785,8 +1784,8 @@ class TestDocumentApi(DirectoriesMixin, ConsumeTaskMixin, APITestCase):
|
|||||||
THEN:
|
THEN:
|
||||||
- Statistics only include inbox counts for tags accessible by the user
|
- Statistics only include inbox counts for tags accessible by the user
|
||||||
"""
|
"""
|
||||||
u1 = User.objects.create_user("user1")
|
u1 = UserFactory(username="user1")
|
||||||
u2 = User.objects.create_user("user2")
|
u2 = UserFactory(username="user2")
|
||||||
inbox_tag_u1 = Tag.objects.create(name="inbox_u1", is_inbox_tag=True, owner=u1)
|
inbox_tag_u1 = Tag.objects.create(name="inbox_u1", is_inbox_tag=True, owner=u1)
|
||||||
Tag.objects.create(name="inbox_u2", is_inbox_tag=True, owner=u2)
|
Tag.objects.create(name="inbox_u2", is_inbox_tag=True, owner=u2)
|
||||||
doc_u1 = Document.objects.create(
|
doc_u1 = Document.objects.create(
|
||||||
@@ -1816,11 +1815,9 @@ class TestDocumentApi(DirectoriesMixin, ConsumeTaskMixin, APITestCase):
|
|||||||
self.assertEqual(response.data["documents_inbox"], 0)
|
self.assertEqual(response.data["documents_inbox"], 0)
|
||||||
|
|
||||||
def test_statistics_with_statistics_permission(self) -> None:
|
def test_statistics_with_statistics_permission(self) -> None:
|
||||||
owner = User.objects.create_user("owner")
|
owner = UserFactory(username="owner")
|
||||||
stats_user = User.objects.create_user("stats-user")
|
stats_user = UserFactory(username="stats-user")
|
||||||
stats_user.user_permissions.add(
|
grant_global(stats_user, "view_global_statistics")
|
||||||
Permission.objects.get(codename="view_global_statistics"),
|
|
||||||
)
|
|
||||||
|
|
||||||
inbox_tag = Tag.objects.create(
|
inbox_tag = Tag.objects.create(
|
||||||
name="stats_inbox",
|
name="stats_inbox",
|
||||||
@@ -1986,7 +1983,7 @@ class TestDocumentApi(DirectoriesMixin, ConsumeTaskMixin, APITestCase):
|
|||||||
self.assertEqual(response.status_code, status.HTTP_405_METHOD_NOT_ALLOWED)
|
self.assertEqual(response.status_code, status.HTTP_405_METHOD_NOT_ALLOWED)
|
||||||
|
|
||||||
def test_upload_insufficient_permissions(self) -> None:
|
def test_upload_insufficient_permissions(self) -> None:
|
||||||
self.client.force_authenticate(user=User.objects.create_user("testuser2"))
|
self.client.force_authenticate(user=UserFactory(username="testuser2"))
|
||||||
|
|
||||||
with (Path(__file__).parent / "samples" / "simple.pdf").open("rb") as f:
|
with (Path(__file__).parent / "samples" / "simple.pdf").open("rb") as f:
|
||||||
response = self.client.post(
|
response = self.client.post(
|
||||||
@@ -2782,9 +2779,9 @@ class TestDocumentApi(DirectoriesMixin, ConsumeTaskMixin, APITestCase):
|
|||||||
mock_get_date_parser.assert_not_called()
|
mock_get_date_parser.assert_not_called()
|
||||||
|
|
||||||
def test_saved_views(self) -> None:
|
def test_saved_views(self) -> None:
|
||||||
u1 = User.objects.create_user("user1")
|
u1 = UserFactory(username="user1")
|
||||||
u2 = User.objects.create_user("user2")
|
u2 = UserFactory(username="user2")
|
||||||
u3 = User.objects.create_user("user3")
|
u3 = UserFactory(username="user3")
|
||||||
|
|
||||||
view_perm = Permission.objects.get(codename="view_savedview")
|
view_perm = Permission.objects.get(codename="view_savedview")
|
||||||
change_perm = Permission.objects.get(codename="change_savedview")
|
change_perm = Permission.objects.get(codename="change_savedview")
|
||||||
@@ -2807,9 +2804,9 @@ class TestDocumentApi(DirectoriesMixin, ConsumeTaskMixin, APITestCase):
|
|||||||
sort_field="",
|
sort_field="",
|
||||||
)
|
)
|
||||||
|
|
||||||
assign_perm("view_savedview", u1, v2)
|
grant_object(u1, v2, "view_savedview")
|
||||||
assign_perm("change_savedview", u1, v2)
|
grant_object(u1, v2, "change_savedview")
|
||||||
assign_perm("view_savedview", u1, v3)
|
grant_object(u1, v3, "view_savedview")
|
||||||
|
|
||||||
self.client.force_authenticate(user=u1)
|
self.client.force_authenticate(user=u1)
|
||||||
|
|
||||||
@@ -3064,7 +3061,7 @@ class TestDocumentApi(DirectoriesMixin, ConsumeTaskMixin, APITestCase):
|
|||||||
self.assertListEqual(saved_view_settings["sidebar_views_visible_ids"], [v2.id])
|
self.assertListEqual(saved_view_settings["sidebar_views_visible_ids"], [v2.id])
|
||||||
|
|
||||||
def test_saved_view_create_update_patch(self) -> None:
|
def test_saved_view_create_update_patch(self) -> None:
|
||||||
User.objects.create_user("user1")
|
UserFactory(username="user1")
|
||||||
|
|
||||||
view = {
|
view = {
|
||||||
"name": "test",
|
"name": "test",
|
||||||
@@ -3127,7 +3124,7 @@ class TestDocumentApi(DirectoriesMixin, ConsumeTaskMixin, APITestCase):
|
|||||||
- Display options are updated
|
- Display options are updated
|
||||||
- Display fields are validated
|
- Display fields are validated
|
||||||
"""
|
"""
|
||||||
User.objects.create_user("user1")
|
UserFactory(username="user1")
|
||||||
|
|
||||||
view = {
|
view = {
|
||||||
"name": "test",
|
"name": "test",
|
||||||
@@ -3568,11 +3565,11 @@ class TestDocumentApi(DirectoriesMixin, ConsumeTaskMixin, APITestCase):
|
|||||||
THEN:
|
THEN:
|
||||||
- Notes are neither created nor deleted
|
- Notes are neither created nor deleted
|
||||||
"""
|
"""
|
||||||
user1 = User.objects.create_user(username="test1")
|
user1 = UserFactory(username="test1")
|
||||||
user1.user_permissions.add(*Permission.objects.all())
|
grant_all_global(user1)
|
||||||
user1.save()
|
user1.save()
|
||||||
|
|
||||||
user2 = User.objects.create_user(username="test2")
|
user2 = UserFactory(username="test2")
|
||||||
user2.save()
|
user2.save()
|
||||||
|
|
||||||
doc = Document.objects.create(
|
doc = Document.objects.create(
|
||||||
@@ -3592,7 +3589,7 @@ class TestDocumentApi(DirectoriesMixin, ConsumeTaskMixin, APITestCase):
|
|||||||
self.assertEqual(resp.content, b"Insufficient permissions to view notes")
|
self.assertEqual(resp.content, b"Insufficient permissions to view notes")
|
||||||
self.assertEqual(resp.status_code, status.HTTP_403_FORBIDDEN)
|
self.assertEqual(resp.status_code, status.HTTP_403_FORBIDDEN)
|
||||||
|
|
||||||
assign_perm("view_document", user1, doc)
|
grant_object(user1, doc, "view_document")
|
||||||
|
|
||||||
resp = self.client.post(
|
resp = self.client.post(
|
||||||
f"/api/documents/{doc.pk}/notes/",
|
f"/api/documents/{doc.pk}/notes/",
|
||||||
@@ -3616,12 +3613,8 @@ class TestDocumentApi(DirectoriesMixin, ConsumeTaskMixin, APITestCase):
|
|||||||
self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN)
|
self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN)
|
||||||
|
|
||||||
def test_notes_require_global_document_permissions(self) -> None:
|
def test_notes_require_global_document_permissions(self) -> None:
|
||||||
user = User.objects.create_user(username="note_editor")
|
user = UserFactory(username="note_editor")
|
||||||
user.user_permissions.add(
|
grant_global(user, "view_note", "add_note", "delete_note")
|
||||||
*Permission.objects.filter(
|
|
||||||
codename__in=["view_note", "add_note", "delete_note"],
|
|
||||||
),
|
|
||||||
)
|
|
||||||
doc = Document.objects.create(
|
doc = Document.objects.create(
|
||||||
title="test",
|
title="test",
|
||||||
mime_type="application/pdf",
|
mime_type="application/pdf",
|
||||||
@@ -3634,9 +3627,7 @@ class TestDocumentApi(DirectoriesMixin, ConsumeTaskMixin, APITestCase):
|
|||||||
response = self.client.get(f"/api/documents/{doc.pk}/notes/")
|
response = self.client.get(f"/api/documents/{doc.pk}/notes/")
|
||||||
self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN)
|
self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN)
|
||||||
|
|
||||||
user.user_permissions.add(
|
grant_global(user, "view_document")
|
||||||
Permission.objects.get(codename="view_document"),
|
|
||||||
)
|
|
||||||
user = User.objects.get(pk=user.pk)
|
user = User.objects.get(pk=user.pk)
|
||||||
self.client.force_authenticate(user)
|
self.client.force_authenticate(user)
|
||||||
response = self.client.get(f"/api/documents/{doc.pk}/notes/")
|
response = self.client.get(f"/api/documents/{doc.pk}/notes/")
|
||||||
@@ -3648,9 +3639,7 @@ class TestDocumentApi(DirectoriesMixin, ConsumeTaskMixin, APITestCase):
|
|||||||
)
|
)
|
||||||
self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN)
|
self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN)
|
||||||
|
|
||||||
user.user_permissions.add(
|
grant_global(user, "change_document")
|
||||||
Permission.objects.get(codename="change_document"),
|
|
||||||
)
|
|
||||||
user = User.objects.get(pk=user.pk)
|
user = User.objects.get(pk=user.pk)
|
||||||
self.client.force_authenticate(user)
|
self.client.force_authenticate(user)
|
||||||
response = self.client.post(
|
response = self.client.post(
|
||||||
@@ -3797,12 +3786,12 @@ class TestDocumentApi(DirectoriesMixin, ConsumeTaskMixin, APITestCase):
|
|||||||
- Unique items are created
|
- Unique items are created
|
||||||
- Non-unique items are not allowed
|
- Non-unique items are not allowed
|
||||||
"""
|
"""
|
||||||
user1 = User.objects.create_user(username="test1")
|
user1 = UserFactory(username="test1")
|
||||||
user1.user_permissions.add(*Permission.objects.filter(codename="add_tag"))
|
grant_global(user1, "add_tag")
|
||||||
user1.save()
|
user1.save()
|
||||||
|
|
||||||
user2 = User.objects.create_user(username="test2")
|
user2 = UserFactory(username="test2")
|
||||||
user2.user_permissions.add(*Permission.objects.filter(codename="add_tag"))
|
grant_global(user2, "add_tag")
|
||||||
user2.save()
|
user2.save()
|
||||||
|
|
||||||
# User 1 creates tag 1 owned by user 1 by default
|
# User 1 creates tag 1 owned by user 1 by default
|
||||||
@@ -3857,12 +3846,12 @@ class TestDocumentApi(DirectoriesMixin, ConsumeTaskMixin, APITestCase):
|
|||||||
- Unique items are created
|
- Unique items are created
|
||||||
- Non-unique items are not allowed on update
|
- Non-unique items are not allowed on update
|
||||||
"""
|
"""
|
||||||
user1 = User.objects.create_user(username="test1")
|
user1 = UserFactory(username="test1")
|
||||||
user1.user_permissions.add(*Permission.objects.filter(codename="change_tag"))
|
grant_global(user1, "change_tag")
|
||||||
user1.save()
|
user1.save()
|
||||||
|
|
||||||
user2 = User.objects.create_user(username="test2")
|
user2 = UserFactory(username="test2")
|
||||||
user2.user_permissions.add(*Permission.objects.filter(codename="change_tag"))
|
grant_global(user2, "change_tag")
|
||||||
user2.save()
|
user2.save()
|
||||||
|
|
||||||
# Create name tag 1 owned by user 1
|
# Create name tag 1 owned by user 1
|
||||||
@@ -3993,11 +3982,11 @@ class TestDocumentApi(DirectoriesMixin, ConsumeTaskMixin, APITestCase):
|
|||||||
THEN:
|
THEN:
|
||||||
- Links only shown if user has permissions
|
- Links only shown if user has permissions
|
||||||
"""
|
"""
|
||||||
user1 = User.objects.create_user(username="test1")
|
user1 = UserFactory(username="test1")
|
||||||
user1.user_permissions.add(*Permission.objects.all())
|
grant_all_global(user1)
|
||||||
user1.save()
|
user1.save()
|
||||||
|
|
||||||
user2 = User.objects.create_user(username="test2")
|
user2 = UserFactory(username="test2")
|
||||||
user2.save()
|
user2.save()
|
||||||
|
|
||||||
doc = Document.objects.create(
|
doc = Document.objects.create(
|
||||||
@@ -4017,7 +4006,7 @@ class TestDocumentApi(DirectoriesMixin, ConsumeTaskMixin, APITestCase):
|
|||||||
self.assertEqual(resp.content, b"Insufficient permissions to add share link")
|
self.assertEqual(resp.content, b"Insufficient permissions to add share link")
|
||||||
self.assertEqual(resp.status_code, status.HTTP_403_FORBIDDEN)
|
self.assertEqual(resp.status_code, status.HTTP_403_FORBIDDEN)
|
||||||
|
|
||||||
assign_perm("change_document", user1, doc)
|
grant_object(user1, doc, "change_document")
|
||||||
|
|
||||||
resp = self.client.get(
|
resp = self.client.get(
|
||||||
f"/api/documents/{doc.pk}/share_links/",
|
f"/api/documents/{doc.pk}/share_links/",
|
||||||
@@ -4034,11 +4023,11 @@ class TestDocumentApi(DirectoriesMixin, ConsumeTaskMixin, APITestCase):
|
|||||||
THEN:
|
THEN:
|
||||||
- Share link creation is denied until view permission is granted
|
- Share link creation is denied until view permission is granted
|
||||||
"""
|
"""
|
||||||
user1 = User.objects.create_user(username="test1")
|
user1 = UserFactory(username="test1")
|
||||||
user1.user_permissions.add(*Permission.objects.filter(codename="add_sharelink"))
|
grant_global(user1, "add_sharelink")
|
||||||
user1.save()
|
user1.save()
|
||||||
|
|
||||||
user2 = User.objects.create_user(username="test2")
|
user2 = UserFactory(username="test2")
|
||||||
user2.save()
|
user2.save()
|
||||||
|
|
||||||
doc = Document.objects.create(
|
doc = Document.objects.create(
|
||||||
@@ -4060,7 +4049,7 @@ class TestDocumentApi(DirectoriesMixin, ConsumeTaskMixin, APITestCase):
|
|||||||
)
|
)
|
||||||
self.assertEqual(create_resp.status_code, status.HTTP_403_FORBIDDEN)
|
self.assertEqual(create_resp.status_code, status.HTTP_403_FORBIDDEN)
|
||||||
|
|
||||||
assign_perm("view_document", user1, doc)
|
grant_object(user1, doc, "view_document")
|
||||||
|
|
||||||
create_resp = self.client.post(
|
create_resp = self.client.post(
|
||||||
"/api/share_links/",
|
"/api/share_links/",
|
||||||
@@ -4072,9 +4061,7 @@ class TestDocumentApi(DirectoriesMixin, ConsumeTaskMixin, APITestCase):
|
|||||||
)
|
)
|
||||||
self.assertEqual(create_resp.status_code, status.HTTP_403_FORBIDDEN)
|
self.assertEqual(create_resp.status_code, status.HTTP_403_FORBIDDEN)
|
||||||
|
|
||||||
user1.user_permissions.add(
|
grant_global(user1, "view_document")
|
||||||
Permission.objects.get(codename="view_document"),
|
|
||||||
)
|
|
||||||
user1 = User.objects.get(pk=user1.pk)
|
user1 = User.objects.get(pk=user1.pk)
|
||||||
self.client.force_authenticate(user1)
|
self.client.force_authenticate(user1)
|
||||||
create_resp = self.client.post(
|
create_resp = self.client.post(
|
||||||
@@ -4097,11 +4084,11 @@ class TestDocumentApi(DirectoriesMixin, ConsumeTaskMixin, APITestCase):
|
|||||||
THEN:
|
THEN:
|
||||||
- ASN +1 from user2's doc is returned for user1
|
- ASN +1 from user2's doc is returned for user1
|
||||||
"""
|
"""
|
||||||
user1 = User.objects.create_user(username="test1")
|
user1 = UserFactory(username="test1")
|
||||||
user1.user_permissions.add(*Permission.objects.all())
|
grant_all_global(user1)
|
||||||
user1.save()
|
user1.save()
|
||||||
|
|
||||||
user2 = User.objects.create_user(username="test2")
|
user2 = UserFactory(username="test2")
|
||||||
user2.save()
|
user2.save()
|
||||||
|
|
||||||
doc1 = Document.objects.create(
|
doc1 = Document.objects.create(
|
||||||
@@ -4141,8 +4128,8 @@ class TestDocumentApi(DirectoriesMixin, ConsumeTaskMixin, APITestCase):
|
|||||||
THEN:
|
THEN:
|
||||||
- ASN 1 is returned
|
- ASN 1 is returned
|
||||||
"""
|
"""
|
||||||
user1 = User.objects.create_user(username="test1")
|
user1 = UserFactory(username="test1")
|
||||||
user1.user_permissions.add(*Permission.objects.all())
|
grant_all_global(user1)
|
||||||
user1.save()
|
user1.save()
|
||||||
|
|
||||||
doc1 = Document.objects.create(
|
doc1 = Document.objects.create(
|
||||||
@@ -4170,7 +4157,7 @@ class TestDocumentApi(DirectoriesMixin, ConsumeTaskMixin, APITestCase):
|
|||||||
THEN:
|
THEN:
|
||||||
- Explicit error is returned
|
- Explicit error is returned
|
||||||
"""
|
"""
|
||||||
user1 = User.objects.create_superuser(username="test1")
|
user1 = UserFactory(username="test1", superuser=True)
|
||||||
|
|
||||||
self.client.force_authenticate(user1)
|
self.client.force_authenticate(user1)
|
||||||
|
|
||||||
@@ -4348,8 +4335,8 @@ class TestDocumentApi(DirectoriesMixin, ConsumeTaskMixin, APITestCase):
|
|||||||
THEN:
|
THEN:
|
||||||
- Error response is returned
|
- Error response is returned
|
||||||
"""
|
"""
|
||||||
user1 = User.objects.create_user(username="test1")
|
user1 = UserFactory(username="test1")
|
||||||
user1.user_permissions.add(*Permission.objects.all())
|
grant_all_global(user1)
|
||||||
user1.save()
|
user1.save()
|
||||||
|
|
||||||
doc = Document.objects.create(
|
doc = Document.objects.create(
|
||||||
@@ -4454,7 +4441,7 @@ class TestDocumentApiTagColors(DirectoriesMixin, APITestCase):
|
|||||||
def setUp(self) -> None:
|
def setUp(self) -> None:
|
||||||
super().setUp()
|
super().setUp()
|
||||||
|
|
||||||
self.user = User.objects.create_superuser(username="temp_admin")
|
self.user = UserFactory(username="temp_admin", superuser=True)
|
||||||
|
|
||||||
self.client.force_authenticate(user=self.user)
|
self.client.force_authenticate(user=self.user)
|
||||||
|
|
||||||
@@ -4534,7 +4521,7 @@ class TestDocumentApiCustomFieldsSorting(DirectoriesMixin, APITestCase):
|
|||||||
def setUp(self) -> None:
|
def setUp(self) -> None:
|
||||||
super().setUp()
|
super().setUp()
|
||||||
|
|
||||||
self.user = User.objects.create_superuser(username="temp_admin")
|
self.user = UserFactory(username="temp_admin", superuser=True)
|
||||||
self.client.force_authenticate(user=self.user)
|
self.client.force_authenticate(user=self.user)
|
||||||
|
|
||||||
self.doc1 = Document.objects.create(
|
self.doc1 = Document.objects.create(
|
||||||
|
|||||||
@@ -2,16 +2,16 @@ import json
|
|||||||
import shutil
|
import shutil
|
||||||
from unittest import mock
|
from unittest import mock
|
||||||
|
|
||||||
from django.contrib.auth.models import Permission
|
|
||||||
from django.contrib.auth.models import User
|
|
||||||
from django.core import mail
|
from django.core import mail
|
||||||
from django.test import override_settings
|
from django.test import override_settings
|
||||||
from rest_framework import status
|
from rest_framework import status
|
||||||
from rest_framework.test import APITestCase
|
from rest_framework.test import APITestCase
|
||||||
|
|
||||||
from documents.models import Document
|
from documents.models import Document
|
||||||
from documents.tests.utils import DirectoriesMixin
|
|
||||||
from documents.tests.utils import SampleDirMixin
|
from documents.tests.utils import SampleDirMixin
|
||||||
|
from paperless_testing.dirs import DirectoriesMixin
|
||||||
|
from paperless_testing.factories import UserFactory
|
||||||
|
from paperless_testing.permissions import grant_global
|
||||||
|
|
||||||
|
|
||||||
class TestEmail(DirectoriesMixin, SampleDirMixin, APITestCase):
|
class TestEmail(DirectoriesMixin, SampleDirMixin, APITestCase):
|
||||||
@@ -20,7 +20,7 @@ class TestEmail(DirectoriesMixin, SampleDirMixin, APITestCase):
|
|||||||
def setUp(self) -> None:
|
def setUp(self) -> None:
|
||||||
super().setUp()
|
super().setUp()
|
||||||
|
|
||||||
self.user = User.objects.create_superuser(username="temp_admin")
|
self.user = UserFactory(username="temp_admin", superuser=True)
|
||||||
self.client.force_authenticate(user=self.user)
|
self.client.force_authenticate(user=self.user)
|
||||||
|
|
||||||
self.doc1 = Document.objects.create(
|
self.doc1 = Document.objects.create(
|
||||||
@@ -303,8 +303,8 @@ class TestEmail(DirectoriesMixin, SampleDirMixin, APITestCase):
|
|||||||
THEN:
|
THEN:
|
||||||
- Forbidden response is returned
|
- Forbidden response is returned
|
||||||
"""
|
"""
|
||||||
user1 = User.objects.create_user(username="test1")
|
user1 = UserFactory(username="test1")
|
||||||
user1.user_permissions.add(*Permission.objects.filter(codename="view_document"))
|
grant_global(user1, "view_document")
|
||||||
|
|
||||||
doc_owned = Document.objects.create(
|
doc_owned = Document.objects.create(
|
||||||
title="owned_doc",
|
title="owned_doc",
|
||||||
@@ -338,8 +338,8 @@ class TestEmail(DirectoriesMixin, SampleDirMixin, APITestCase):
|
|||||||
THEN:
|
THEN:
|
||||||
- Request succeeds
|
- Request succeeds
|
||||||
"""
|
"""
|
||||||
user1 = User.objects.create_user(username="test1")
|
user1 = UserFactory(username="test1")
|
||||||
user1.user_permissions.add(*Permission.objects.filter(codename="view_document"))
|
grant_global(user1, "view_document")
|
||||||
|
|
||||||
self.client.force_authenticate(user1)
|
self.client.force_authenticate(user1)
|
||||||
|
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ from collections.abc import Callable
|
|||||||
from datetime import date
|
from datetime import date
|
||||||
from urllib.parse import quote
|
from urllib.parse import quote
|
||||||
|
|
||||||
from django.contrib.auth.models import User
|
|
||||||
from rest_framework.test import APITestCase
|
from rest_framework.test import APITestCase
|
||||||
|
|
||||||
from documents.models import CustomField
|
from documents.models import CustomField
|
||||||
@@ -13,7 +12,8 @@ from documents.models import Document
|
|||||||
from documents.models import SavedView
|
from documents.models import SavedView
|
||||||
from documents.models import SavedViewFilterRule
|
from documents.models import SavedViewFilterRule
|
||||||
from documents.serialisers import DocumentSerializer
|
from documents.serialisers import DocumentSerializer
|
||||||
from documents.tests.utils import DirectoriesMixin
|
from paperless_testing.dirs import DirectoriesMixin
|
||||||
|
from paperless_testing.factories import UserFactory
|
||||||
|
|
||||||
|
|
||||||
class DocumentWrapper:
|
class DocumentWrapper:
|
||||||
@@ -35,7 +35,7 @@ class TestCustomFieldsSearch(DirectoriesMixin, APITestCase):
|
|||||||
def setUp(self) -> None:
|
def setUp(self) -> None:
|
||||||
super().setUp()
|
super().setUp()
|
||||||
|
|
||||||
self.user = User.objects.create_superuser(username="temp_admin")
|
self.user = UserFactory(username="temp_admin", superuser=True)
|
||||||
self.client.force_authenticate(user=self.user)
|
self.client.force_authenticate(user=self.user)
|
||||||
|
|
||||||
# Create one custom field per type. The fields are called f"{type}_field".
|
# Create one custom field per type. The fields are called f"{type}_field".
|
||||||
|
|||||||
@@ -3,12 +3,10 @@ import json
|
|||||||
from unittest import mock
|
from unittest import mock
|
||||||
|
|
||||||
from django.contrib.auth.models import Group
|
from django.contrib.auth.models import Group
|
||||||
from django.contrib.auth.models import Permission
|
|
||||||
from django.contrib.auth.models import User
|
from django.contrib.auth.models import User
|
||||||
from django.db import connection
|
from django.db import connection
|
||||||
from django.test import override_settings
|
from django.test import override_settings
|
||||||
from django.test.utils import CaptureQueriesContext
|
from django.test.utils import CaptureQueriesContext
|
||||||
from guardian.shortcuts import assign_perm
|
|
||||||
from guardian.shortcuts import get_groups_with_perms
|
from guardian.shortcuts import get_groups_with_perms
|
||||||
from guardian.shortcuts import get_users_with_perms
|
from guardian.shortcuts import get_users_with_perms
|
||||||
from rest_framework import status
|
from rest_framework import status
|
||||||
@@ -21,14 +19,17 @@ from documents.models import Document
|
|||||||
from documents.models import DocumentType
|
from documents.models import DocumentType
|
||||||
from documents.models import StoragePath
|
from documents.models import StoragePath
|
||||||
from documents.models import Tag
|
from documents.models import Tag
|
||||||
from documents.tests.utils import DirectoriesMixin
|
from paperless_testing.dirs import DirectoriesMixin
|
||||||
|
from paperless_testing.factories import UserFactory
|
||||||
|
from paperless_testing.permissions import grant_global
|
||||||
|
from paperless_testing.permissions import grant_object
|
||||||
|
|
||||||
|
|
||||||
class TestApiObjects(DirectoriesMixin, APITestCase):
|
class TestApiObjects(DirectoriesMixin, APITestCase):
|
||||||
def setUp(self) -> None:
|
def setUp(self) -> None:
|
||||||
super().setUp()
|
super().setUp()
|
||||||
|
|
||||||
user = User.objects.create_superuser(username="temp_admin")
|
user = UserFactory(username="temp_admin", superuser=True)
|
||||||
self.client.force_authenticate(user=user)
|
self.client.force_authenticate(user=user)
|
||||||
|
|
||||||
self.tag1 = Tag.objects.create(name="t1", is_inbox_tag=True)
|
self.tag1 = Tag.objects.create(name="t1", is_inbox_tag=True)
|
||||||
@@ -153,7 +154,7 @@ class TestApiObjects(DirectoriesMixin, APITestCase):
|
|||||||
|
|
||||||
# A newer document owned by another user must not leak through the
|
# A newer document owned by another user must not leak through the
|
||||||
# aggregate for a non-superuser who cannot view it
|
# aggregate for a non-superuser who cannot view it
|
||||||
other = User.objects.create_user(username="other")
|
other = UserFactory(username="other")
|
||||||
Document.objects.create(
|
Document.objects.create(
|
||||||
mime_type="application/pdf",
|
mime_type="application/pdf",
|
||||||
correspondent=self.c1,
|
correspondent=self.c1,
|
||||||
@@ -162,10 +163,8 @@ class TestApiObjects(DirectoriesMixin, APITestCase):
|
|||||||
owner=other,
|
owner=other,
|
||||||
)
|
)
|
||||||
|
|
||||||
user = User.objects.create_user(username="regular")
|
user = UserFactory(username="regular")
|
||||||
user.user_permissions.add(
|
grant_global(user, "view_correspondent")
|
||||||
Permission.objects.get(codename="view_correspondent"),
|
|
||||||
)
|
|
||||||
self.client.force_authenticate(user=user)
|
self.client.force_authenticate(user=user)
|
||||||
|
|
||||||
response = self.client.get("/api/correspondents/?last_correspondence=true")
|
response = self.client.get("/api/correspondents/?last_correspondence=true")
|
||||||
@@ -200,7 +199,7 @@ class TestApiStoragePaths(DirectoriesMixin, APITestCase):
|
|||||||
def setUp(self) -> None:
|
def setUp(self) -> None:
|
||||||
super().setUp()
|
super().setUp()
|
||||||
|
|
||||||
user = User.objects.create_superuser(username="temp_admin")
|
user = UserFactory(username="temp_admin", superuser=True)
|
||||||
self.client.force_authenticate(user=user)
|
self.client.force_authenticate(user=user)
|
||||||
|
|
||||||
self.sp1 = StoragePath.objects.create(name="sp1", path="Something/{checksum}")
|
self.sp1 = StoragePath.objects.create(name="sp1", path="Something/{checksum}")
|
||||||
@@ -455,11 +454,9 @@ class TestApiStoragePaths(DirectoriesMixin, APITestCase):
|
|||||||
self.assertEqual(response.data, "folder/Something.pdf")
|
self.assertEqual(response.data, "folder/Something.pdf")
|
||||||
|
|
||||||
def test_test_storage_path_requires_document_view_permission(self) -> None:
|
def test_test_storage_path_requires_document_view_permission(self) -> None:
|
||||||
owner = User.objects.create_user(username="owner")
|
owner = UserFactory(username="owner")
|
||||||
unprivileged = User.objects.create_user(username="unprivileged")
|
unprivileged = UserFactory(username="unprivileged")
|
||||||
unprivileged.user_permissions.add(
|
grant_global(unprivileged, "view_document")
|
||||||
Permission.objects.get(codename="view_document"),
|
|
||||||
)
|
|
||||||
document = Document.objects.create(
|
document = Document.objects.create(
|
||||||
mime_type="application/pdf",
|
mime_type="application/pdf",
|
||||||
owner=owner,
|
owner=owner,
|
||||||
@@ -481,15 +478,15 @@ class TestApiStoragePaths(DirectoriesMixin, APITestCase):
|
|||||||
self.assertIn("document", response.data)
|
self.assertIn("document", response.data)
|
||||||
|
|
||||||
def test_test_storage_path_allows_shared_document_view_permission(self) -> None:
|
def test_test_storage_path_allows_shared_document_view_permission(self) -> None:
|
||||||
owner = User.objects.create_user(username="owner")
|
owner = UserFactory(username="owner")
|
||||||
viewer = User.objects.create_user(username="viewer")
|
viewer = UserFactory(username="viewer")
|
||||||
document = Document.objects.create(
|
document = Document.objects.create(
|
||||||
mime_type="application/pdf",
|
mime_type="application/pdf",
|
||||||
owner=owner,
|
owner=owner,
|
||||||
title="Shared",
|
title="Shared",
|
||||||
checksum="123",
|
checksum="123",
|
||||||
)
|
)
|
||||||
assign_perm("view_document", viewer, document)
|
grant_object(viewer, document, "view_document")
|
||||||
|
|
||||||
self.client.force_authenticate(user=viewer)
|
self.client.force_authenticate(user=viewer)
|
||||||
response = self.client.post(
|
response = self.client.post(
|
||||||
@@ -504,9 +501,7 @@ class TestApiStoragePaths(DirectoriesMixin, APITestCase):
|
|||||||
)
|
)
|
||||||
self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN)
|
self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN)
|
||||||
|
|
||||||
viewer.user_permissions.add(
|
grant_global(viewer, "view_document")
|
||||||
Permission.objects.get(codename="view_document"),
|
|
||||||
)
|
|
||||||
viewer = User.objects.get(pk=viewer.pk)
|
viewer = User.objects.get(pk=viewer.pk)
|
||||||
self.client.force_authenticate(user=viewer)
|
self.client.force_authenticate(user=viewer)
|
||||||
response = self.client.post(
|
response = self.client.post(
|
||||||
@@ -545,14 +540,12 @@ class TestApiStoragePaths(DirectoriesMixin, APITestCase):
|
|||||||
def test_test_storage_path_exposes_basic_document_context_but_not_sensitive_owner_data(
|
def test_test_storage_path_exposes_basic_document_context_but_not_sensitive_owner_data(
|
||||||
self,
|
self,
|
||||||
) -> None:
|
) -> None:
|
||||||
owner = User.objects.create_user(
|
owner = UserFactory(
|
||||||
username="owner",
|
username="owner",
|
||||||
password="password",
|
password="password",
|
||||||
email="owner@example.com",
|
email="owner@example.com",
|
||||||
)
|
)
|
||||||
owner.user_permissions.add(
|
grant_global(owner, "view_document")
|
||||||
Permission.objects.get(codename="view_document"),
|
|
||||||
)
|
|
||||||
document = Document.objects.create(
|
document = Document.objects.create(
|
||||||
mime_type="application/pdf",
|
mime_type="application/pdf",
|
||||||
owner=owner,
|
owner=owner,
|
||||||
@@ -614,8 +607,8 @@ class TestApiStoragePaths(DirectoriesMixin, APITestCase):
|
|||||||
def test_test_storage_path_includes_related_objects_for_visible_document(
|
def test_test_storage_path_includes_related_objects_for_visible_document(
|
||||||
self,
|
self,
|
||||||
) -> None:
|
) -> None:
|
||||||
owner = User.objects.create_user(username="owner")
|
owner = UserFactory(username="owner")
|
||||||
viewer = User.objects.create_user(username="viewer")
|
viewer = UserFactory(username="viewer")
|
||||||
private_correspondent = Correspondent.objects.create(
|
private_correspondent = Correspondent.objects.create(
|
||||||
name="Private Correspondent",
|
name="Private Correspondent",
|
||||||
owner=owner,
|
owner=owner,
|
||||||
@@ -627,10 +620,8 @@ class TestApiStoragePaths(DirectoriesMixin, APITestCase):
|
|||||||
title="Document",
|
title="Document",
|
||||||
checksum="123",
|
checksum="123",
|
||||||
)
|
)
|
||||||
assign_perm("view_document", viewer, document)
|
grant_object(viewer, document, "view_document")
|
||||||
viewer.user_permissions.add(
|
grant_global(viewer, "view_document")
|
||||||
Permission.objects.get(codename="view_document"),
|
|
||||||
)
|
|
||||||
|
|
||||||
self.client.force_authenticate(user=viewer)
|
self.client.force_authenticate(user=viewer)
|
||||||
response = self.client.post(
|
response = self.client.post(
|
||||||
@@ -662,7 +653,7 @@ class TestApiStoragePaths(DirectoriesMixin, APITestCase):
|
|||||||
self.assertEqual(response.data, "Private Correspondent.pdf")
|
self.assertEqual(response.data, "Private Correspondent.pdf")
|
||||||
|
|
||||||
def test_test_storage_path_superuser_can_view_private_related_objects(self) -> None:
|
def test_test_storage_path_superuser_can_view_private_related_objects(self) -> None:
|
||||||
owner = User.objects.create_user(username="owner")
|
owner = UserFactory(username="owner")
|
||||||
private_correspondent = Correspondent.objects.create(
|
private_correspondent = Correspondent.objects.create(
|
||||||
name="Private Correspondent",
|
name="Private Correspondent",
|
||||||
owner=owner,
|
owner=owner,
|
||||||
@@ -693,8 +684,8 @@ class TestApiStoragePaths(DirectoriesMixin, APITestCase):
|
|||||||
def test_test_storage_path_includes_doc_type_storage_path_and_tags(
|
def test_test_storage_path_includes_doc_type_storage_path_and_tags(
|
||||||
self,
|
self,
|
||||||
) -> None:
|
) -> None:
|
||||||
owner = User.objects.create_user(username="owner")
|
owner = UserFactory(username="owner")
|
||||||
viewer = User.objects.create_user(username="viewer")
|
viewer = UserFactory(username="viewer")
|
||||||
private_document_type = DocumentType.objects.create(
|
private_document_type = DocumentType.objects.create(
|
||||||
name="Private Type",
|
name="Private Type",
|
||||||
owner=owner,
|
owner=owner,
|
||||||
@@ -717,10 +708,8 @@ class TestApiStoragePaths(DirectoriesMixin, APITestCase):
|
|||||||
checksum="123",
|
checksum="123",
|
||||||
)
|
)
|
||||||
document.tags.add(private_tag)
|
document.tags.add(private_tag)
|
||||||
assign_perm("view_document", viewer, document)
|
grant_object(viewer, document, "view_document")
|
||||||
viewer.user_permissions.add(
|
grant_global(viewer, "view_document")
|
||||||
Permission.objects.get(codename="view_document"),
|
|
||||||
)
|
|
||||||
|
|
||||||
self.client.force_authenticate(user=viewer)
|
self.client.force_authenticate(user=viewer)
|
||||||
response = self.client.post(
|
response = self.client.post(
|
||||||
@@ -756,8 +745,8 @@ class TestApiStoragePaths(DirectoriesMixin, APITestCase):
|
|||||||
def test_test_storage_path_includes_custom_fields_for_visible_document(
|
def test_test_storage_path_includes_custom_fields_for_visible_document(
|
||||||
self,
|
self,
|
||||||
) -> None:
|
) -> None:
|
||||||
owner = User.objects.create_user(username="owner")
|
owner = UserFactory(username="owner")
|
||||||
viewer = User.objects.create_user(username="viewer")
|
viewer = UserFactory(username="viewer")
|
||||||
document = Document.objects.create(
|
document = Document.objects.create(
|
||||||
mime_type="application/pdf",
|
mime_type="application/pdf",
|
||||||
owner=owner,
|
owner=owner,
|
||||||
@@ -773,10 +762,8 @@ class TestApiStoragePaths(DirectoriesMixin, APITestCase):
|
|||||||
field=custom_field,
|
field=custom_field,
|
||||||
value_int=42,
|
value_int=42,
|
||||||
)
|
)
|
||||||
assign_perm("view_document", viewer, document)
|
grant_object(viewer, document, "view_document")
|
||||||
viewer.user_permissions.add(
|
grant_global(viewer, "view_document")
|
||||||
Permission.objects.get(codename="view_document"),
|
|
||||||
)
|
|
||||||
|
|
||||||
self.client.force_authenticate(user=viewer)
|
self.client.force_authenticate(user=viewer)
|
||||||
response = self.client.post(
|
response = self.client.post(
|
||||||
@@ -798,7 +785,7 @@ class TestBulkEditObjects(APITestCase):
|
|||||||
def setUp(self) -> None:
|
def setUp(self) -> None:
|
||||||
super().setUp()
|
super().setUp()
|
||||||
|
|
||||||
self.temp_admin = User.objects.create_superuser(username="temp_admin")
|
self.temp_admin = UserFactory(username="temp_admin", superuser=True)
|
||||||
self.client.force_authenticate(user=self.temp_admin)
|
self.client.force_authenticate(user=self.temp_admin)
|
||||||
|
|
||||||
self.t1 = Tag.objects.create(name="t1")
|
self.t1 = Tag.objects.create(name="t1")
|
||||||
@@ -1030,9 +1017,7 @@ class TestBulkEditObjects(APITestCase):
|
|||||||
THEN:
|
THEN:
|
||||||
- User is able to delete objects
|
- User is able to delete objects
|
||||||
"""
|
"""
|
||||||
self.user1.user_permissions.add(
|
grant_global(self.user1, "delete_tag")
|
||||||
*Permission.objects.filter(codename="delete_tag"),
|
|
||||||
)
|
|
||||||
self.user1.save()
|
self.user1.save()
|
||||||
self.client.force_authenticate(user=self.user1)
|
self.client.force_authenticate(user=self.user1)
|
||||||
|
|
||||||
@@ -1062,9 +1047,7 @@ class TestBulkEditObjects(APITestCase):
|
|||||||
self.t2.owner = User.objects.get(username="temp_admin")
|
self.t2.owner = User.objects.get(username="temp_admin")
|
||||||
self.t2.save()
|
self.t2.save()
|
||||||
|
|
||||||
self.user1.user_permissions.add(
|
grant_global(self.user1, "delete_tag")
|
||||||
*Permission.objects.filter(codename="delete_tag"),
|
|
||||||
)
|
|
||||||
self.user1.save()
|
self.user1.save()
|
||||||
self.client.force_authenticate(user=self.user1)
|
self.client.force_authenticate(user=self.user1)
|
||||||
|
|
||||||
@@ -1097,9 +1080,7 @@ class TestBulkEditObjects(APITestCase):
|
|||||||
self.t2.owner = User.objects.get(username="temp_admin")
|
self.t2.owner = User.objects.get(username="temp_admin")
|
||||||
self.t2.save()
|
self.t2.save()
|
||||||
|
|
||||||
self.user1.user_permissions.add(
|
grant_global(self.user1, "delete_tag")
|
||||||
*Permission.objects.filter(codename="delete_tag"),
|
|
||||||
)
|
|
||||||
self.user1.save()
|
self.user1.save()
|
||||||
self.client.force_authenticate(user=self.user1)
|
self.client.force_authenticate(user=self.user1)
|
||||||
|
|
||||||
|
|||||||
@@ -5,9 +5,7 @@ from unittest import mock
|
|||||||
from allauth.mfa.models import Authenticator
|
from allauth.mfa.models import Authenticator
|
||||||
from allauth.mfa.totp.internal import auth as totp_auth
|
from allauth.mfa.totp.internal import auth as totp_auth
|
||||||
from django.contrib.auth.models import Group
|
from django.contrib.auth.models import Group
|
||||||
from django.contrib.auth.models import Permission
|
|
||||||
from django.contrib.auth.models import User
|
from django.contrib.auth.models import User
|
||||||
from guardian.shortcuts import assign_perm
|
|
||||||
from guardian.shortcuts import get_perms
|
from guardian.shortcuts import get_perms
|
||||||
from guardian.shortcuts import get_users_with_perms
|
from guardian.shortcuts import get_users_with_perms
|
||||||
from rest_framework import status
|
from rest_framework import status
|
||||||
@@ -19,7 +17,11 @@ from documents.models import DocumentType
|
|||||||
from documents.models import MatchingModel
|
from documents.models import MatchingModel
|
||||||
from documents.models import StoragePath
|
from documents.models import StoragePath
|
||||||
from documents.models import Tag
|
from documents.models import Tag
|
||||||
from documents.tests.utils import DirectoriesMixin
|
from paperless_testing.dirs import DirectoriesMixin
|
||||||
|
from paperless_testing.factories import UserFactory
|
||||||
|
from paperless_testing.permissions import grant_all_global
|
||||||
|
from paperless_testing.permissions import grant_global
|
||||||
|
from paperless_testing.permissions import grant_object
|
||||||
|
|
||||||
|
|
||||||
class TestApiAuth(DirectoriesMixin, APITestCase):
|
class TestApiAuth(DirectoriesMixin, APITestCase):
|
||||||
@@ -93,14 +95,14 @@ class TestApiAuth(DirectoriesMixin, APITestCase):
|
|||||||
self.assertNotIn("X-Version", response)
|
self.assertNotIn("X-Version", response)
|
||||||
|
|
||||||
def test_api_version_with_auth(self) -> None:
|
def test_api_version_with_auth(self) -> None:
|
||||||
user = User.objects.create_superuser(username="test")
|
user = UserFactory(username="test", superuser=True)
|
||||||
self.client.force_authenticate(user)
|
self.client.force_authenticate(user)
|
||||||
response = self.client.get("/api/documents/")
|
response = self.client.get("/api/documents/")
|
||||||
self.assertIn("X-Api-Version", response)
|
self.assertIn("X-Api-Version", response)
|
||||||
self.assertIn("X-Version", response)
|
self.assertIn("X-Version", response)
|
||||||
|
|
||||||
def test_api_insufficient_permissions(self) -> None:
|
def test_api_insufficient_permissions(self) -> None:
|
||||||
user = User.objects.create_user(username="test")
|
user = UserFactory(username="test")
|
||||||
self.client.force_authenticate(user)
|
self.client.force_authenticate(user)
|
||||||
|
|
||||||
Document.objects.create(title="Test")
|
Document.objects.create(title="Test")
|
||||||
@@ -137,8 +139,8 @@ class TestApiAuth(DirectoriesMixin, APITestCase):
|
|||||||
)
|
)
|
||||||
|
|
||||||
def test_api_sufficient_permissions(self) -> None:
|
def test_api_sufficient_permissions(self) -> None:
|
||||||
user = User.objects.create_user(username="test")
|
user = UserFactory(username="test")
|
||||||
user.user_permissions.add(*Permission.objects.all())
|
grant_all_global(user)
|
||||||
user.is_staff = True
|
user.is_staff = True
|
||||||
self.client.force_authenticate(user)
|
self.client.force_authenticate(user)
|
||||||
|
|
||||||
@@ -166,9 +168,9 @@ class TestApiAuth(DirectoriesMixin, APITestCase):
|
|||||||
)
|
)
|
||||||
|
|
||||||
def test_api_get_object_permissions(self) -> None:
|
def test_api_get_object_permissions(self) -> None:
|
||||||
user1 = User.objects.create_user(username="test1")
|
user1 = UserFactory(username="test1")
|
||||||
user2 = User.objects.create_user(username="test2")
|
user2 = UserFactory(username="test2")
|
||||||
user1.user_permissions.add(*Permission.objects.filter(codename="view_document"))
|
grant_global(user1, "view_document")
|
||||||
self.client.force_authenticate(user1)
|
self.client.force_authenticate(user1)
|
||||||
|
|
||||||
self.assertEqual(
|
self.assertEqual(
|
||||||
@@ -205,7 +207,7 @@ class TestApiAuth(DirectoriesMixin, APITestCase):
|
|||||||
THEN:
|
THEN:
|
||||||
- Object created with current user as owner
|
- Object created with current user as owner
|
||||||
"""
|
"""
|
||||||
user1 = User.objects.create_superuser(username="user1")
|
user1 = UserFactory(username="user1", superuser=True)
|
||||||
|
|
||||||
self.client.force_authenticate(user1)
|
self.client.force_authenticate(user1)
|
||||||
|
|
||||||
@@ -234,7 +236,7 @@ class TestApiAuth(DirectoriesMixin, APITestCase):
|
|||||||
THEN:
|
THEN:
|
||||||
- Object created with no owner
|
- Object created with no owner
|
||||||
"""
|
"""
|
||||||
user1 = User.objects.create_superuser(username="user1")
|
user1 = UserFactory(username="user1", superuser=True)
|
||||||
|
|
||||||
self.client.force_authenticate(user1)
|
self.client.force_authenticate(user1)
|
||||||
|
|
||||||
@@ -265,7 +267,7 @@ class TestApiAuth(DirectoriesMixin, APITestCase):
|
|||||||
THEN:
|
THEN:
|
||||||
- Object permissions are set appropriately
|
- Object permissions are set appropriately
|
||||||
"""
|
"""
|
||||||
user1 = User.objects.create_superuser(username="user1")
|
user1 = UserFactory(username="user1", superuser=True)
|
||||||
user2 = User.objects.create(username="user2")
|
user2 = User.objects.create(username="user2")
|
||||||
group1 = Group.objects.create(name="group1")
|
group1 = Group.objects.create(name="group1")
|
||||||
|
|
||||||
@@ -313,7 +315,7 @@ class TestApiAuth(DirectoriesMixin, APITestCase):
|
|||||||
THEN:
|
THEN:
|
||||||
- Object permissions are set appropriately
|
- Object permissions are set appropriately
|
||||||
"""
|
"""
|
||||||
user1 = User.objects.create_superuser(username="user1")
|
user1 = UserFactory(username="user1", superuser=True)
|
||||||
user2 = User.objects.create(username="user2")
|
user2 = User.objects.create(username="user2")
|
||||||
group1 = Group.objects.create(name="group1")
|
group1 = Group.objects.create(name="group1")
|
||||||
|
|
||||||
@@ -363,7 +365,7 @@ class TestApiAuth(DirectoriesMixin, APITestCase):
|
|||||||
mime_type="application/pdf",
|
mime_type="application/pdf",
|
||||||
content="this is a document",
|
content="this is a document",
|
||||||
)
|
)
|
||||||
user1 = User.objects.create_superuser(username="user1")
|
user1 = UserFactory(username="user1", superuser=True)
|
||||||
user2 = User.objects.create(username="user2")
|
user2 = User.objects.create(username="user2")
|
||||||
group1 = Group.objects.create(name="group1")
|
group1 = Group.objects.create(name="group1")
|
||||||
|
|
||||||
@@ -413,16 +415,16 @@ class TestApiAuth(DirectoriesMixin, APITestCase):
|
|||||||
mime_type="application/pdf",
|
mime_type="application/pdf",
|
||||||
content="this is a document",
|
content="this is a document",
|
||||||
)
|
)
|
||||||
user1 = User.objects.create_superuser(username="user1")
|
user1 = UserFactory(username="user1", superuser=True)
|
||||||
user2 = User.objects.create(username="user2")
|
user2 = User.objects.create(username="user2")
|
||||||
group1 = Group.objects.create(name="group1")
|
group1 = Group.objects.create(name="group1")
|
||||||
doc.owner = user1
|
doc.owner = user1
|
||||||
doc.save()
|
doc.save()
|
||||||
|
|
||||||
assign_perm("view_document", user2, doc)
|
grant_object(user2, doc, "view_document")
|
||||||
assign_perm("change_document", user2, doc)
|
grant_object(user2, doc, "change_document")
|
||||||
assign_perm("view_document", group1, doc)
|
grant_object(group1, doc, "view_document")
|
||||||
assign_perm("change_document", group1, doc)
|
grant_object(group1, doc, "change_document")
|
||||||
|
|
||||||
self.client.force_authenticate(user1)
|
self.client.force_authenticate(user1)
|
||||||
|
|
||||||
@@ -446,11 +448,9 @@ class TestApiAuth(DirectoriesMixin, APITestCase):
|
|||||||
self.assertIn("change_document", get_perms(group1, doc))
|
self.assertIn("change_document", get_perms(group1, doc))
|
||||||
|
|
||||||
def test_document_permissions_change_requires_owner(self) -> None:
|
def test_document_permissions_change_requires_owner(self) -> None:
|
||||||
owner = User.objects.create_user(username="owner")
|
owner = UserFactory(username="owner")
|
||||||
editor = User.objects.create_user(username="editor")
|
editor = UserFactory(username="editor")
|
||||||
editor.user_permissions.add(
|
grant_all_global(editor)
|
||||||
*Permission.objects.all(),
|
|
||||||
)
|
|
||||||
|
|
||||||
doc = Document.objects.create(
|
doc = Document.objects.create(
|
||||||
title="Ownered doc",
|
title="Ownered doc",
|
||||||
@@ -460,8 +460,8 @@ class TestApiAuth(DirectoriesMixin, APITestCase):
|
|||||||
owner=owner,
|
owner=owner,
|
||||||
)
|
)
|
||||||
|
|
||||||
assign_perm("view_document", editor, doc)
|
grant_object(editor, doc, "view_document")
|
||||||
assign_perm("change_document", editor, doc)
|
grant_object(editor, doc, "change_document")
|
||||||
|
|
||||||
self.client.force_authenticate(editor)
|
self.client.force_authenticate(editor)
|
||||||
response = self.client.patch(
|
response = self.client.patch(
|
||||||
@@ -499,9 +499,9 @@ class TestApiAuth(DirectoriesMixin, APITestCase):
|
|||||||
self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN)
|
self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN)
|
||||||
|
|
||||||
def test_dynamic_permissions_fields(self) -> None:
|
def test_dynamic_permissions_fields(self) -> None:
|
||||||
user1 = User.objects.create_user(username="user1")
|
user1 = UserFactory(username="user1")
|
||||||
user1.user_permissions.add(*Permission.objects.filter(codename="view_document"))
|
grant_global(user1, "view_document")
|
||||||
user2 = User.objects.create_user(username="user2")
|
user2 = UserFactory(username="user2")
|
||||||
|
|
||||||
Document.objects.create(title="Test", content="content 1", checksum="1")
|
Document.objects.create(title="Test", content="content 1", checksum="1")
|
||||||
doc2 = Document.objects.create(
|
doc2 = Document.objects.create(
|
||||||
@@ -523,10 +523,10 @@ class TestApiAuth(DirectoriesMixin, APITestCase):
|
|||||||
owner=user1,
|
owner=user1,
|
||||||
)
|
)
|
||||||
|
|
||||||
assign_perm("view_document", user1, doc2)
|
grant_object(user1, doc2, "view_document")
|
||||||
assign_perm("view_document", user1, doc3)
|
grant_object(user1, doc3, "view_document")
|
||||||
assign_perm("change_document", user1, doc3)
|
grant_object(user1, doc3, "change_document")
|
||||||
assign_perm("view_document", user2, doc4)
|
grant_object(user2, doc4, "view_document")
|
||||||
|
|
||||||
self.client.force_authenticate(user1)
|
self.client.force_authenticate(user1)
|
||||||
|
|
||||||
@@ -574,8 +574,8 @@ class TestApiAuth(DirectoriesMixin, APITestCase):
|
|||||||
owned by someone else with no explicit guardian grant -- mirrors
|
owned by someone else with no explicit guardian grant -- mirrors
|
||||||
guardian's own ObjectPermissionChecker.has_perm() superuser shortcut.
|
guardian's own ObjectPermissionChecker.has_perm() superuser shortcut.
|
||||||
"""
|
"""
|
||||||
superuser = User.objects.create_superuser(username="admin")
|
superuser = UserFactory(username="admin", superuser=True)
|
||||||
other_user = User.objects.create_user(username="user2")
|
other_user = UserFactory(username="user2")
|
||||||
Document.objects.create(
|
Document.objects.create(
|
||||||
title="Test",
|
title="Test",
|
||||||
content="content",
|
content="content",
|
||||||
@@ -602,7 +602,7 @@ class TestApiAuth(DirectoriesMixin, APITestCase):
|
|||||||
THEN:
|
THEN:
|
||||||
- MFA required error is returned
|
- MFA required error is returned
|
||||||
"""
|
"""
|
||||||
user1 = User.objects.create_user(username="user1")
|
user1 = UserFactory(username="user1")
|
||||||
user1.set_password("password")
|
user1.set_password("password")
|
||||||
user1.save()
|
user1.save()
|
||||||
|
|
||||||
@@ -626,7 +626,7 @@ class TestApiAuth(DirectoriesMixin, APITestCase):
|
|||||||
THEN:
|
THEN:
|
||||||
- MFA code is required
|
- MFA code is required
|
||||||
"""
|
"""
|
||||||
user1 = User.objects.create_user(username="user1")
|
user1 = UserFactory(username="user1")
|
||||||
user1.set_password("password")
|
user1.set_password("password")
|
||||||
user1.save()
|
user1.save()
|
||||||
|
|
||||||
@@ -688,7 +688,7 @@ class TestApiUser(DirectoriesMixin, APITestCase):
|
|||||||
def setUp(self) -> None:
|
def setUp(self) -> None:
|
||||||
super().setUp()
|
super().setUp()
|
||||||
|
|
||||||
self.user = User.objects.create_superuser(username="temp_admin")
|
self.user = UserFactory(username="temp_admin", superuser=True)
|
||||||
self.client.force_authenticate(user=self.user)
|
self.client.force_authenticate(user=self.user)
|
||||||
|
|
||||||
def test_get_users(self) -> None:
|
def test_get_users(self) -> None:
|
||||||
@@ -858,10 +858,8 @@ class TestApiUser(DirectoriesMixin, APITestCase):
|
|||||||
)
|
)
|
||||||
self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND)
|
self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND)
|
||||||
|
|
||||||
regular_user = User.objects.create_user(username="regular_user")
|
regular_user = UserFactory(username="regular_user")
|
||||||
regular_user.user_permissions.add(
|
grant_all_global(regular_user)
|
||||||
*Permission.objects.all(),
|
|
||||||
)
|
|
||||||
self.client.force_authenticate(regular_user)
|
self.client.force_authenticate(regular_user)
|
||||||
Authenticator.objects.create(
|
Authenticator.objects.create(
|
||||||
user=user1,
|
user=user1,
|
||||||
@@ -885,9 +883,9 @@ class TestApiUser(DirectoriesMixin, APITestCase):
|
|||||||
- Only superusers can change superuser status
|
- Only superusers can change superuser status
|
||||||
"""
|
"""
|
||||||
|
|
||||||
user1 = User.objects.create_user(username="user1")
|
user1 = UserFactory(username="user1")
|
||||||
user1.user_permissions.add(*Permission.objects.all())
|
grant_all_global(user1)
|
||||||
user2 = User.objects.create_superuser(username="user2")
|
user2 = UserFactory(username="user2", superuser=True)
|
||||||
|
|
||||||
self.client.force_authenticate(user1)
|
self.client.force_authenticate(user1)
|
||||||
|
|
||||||
@@ -972,9 +970,9 @@ class TestApiUser(DirectoriesMixin, APITestCase):
|
|||||||
- Only superusers can change staff status
|
- Only superusers can change staff status
|
||||||
"""
|
"""
|
||||||
|
|
||||||
user1 = User.objects.create_user(username="user1")
|
user1 = UserFactory(username="user1")
|
||||||
user1.user_permissions.add(*Permission.objects.all())
|
grant_all_global(user1)
|
||||||
user2 = User.objects.create_superuser(username="user2")
|
user2 = UserFactory(username="user2", superuser=True)
|
||||||
|
|
||||||
self.client.force_authenticate(user1)
|
self.client.force_authenticate(user1)
|
||||||
|
|
||||||
@@ -1027,7 +1025,7 @@ class TestApiGroup(DirectoriesMixin, APITestCase):
|
|||||||
def setUp(self) -> None:
|
def setUp(self) -> None:
|
||||||
super().setUp()
|
super().setUp()
|
||||||
|
|
||||||
self.user = User.objects.create_superuser(username="temp_admin")
|
self.user = UserFactory(username="temp_admin", superuser=True)
|
||||||
self.client.force_authenticate(user=self.user)
|
self.client.force_authenticate(user=self.user)
|
||||||
|
|
||||||
def test_get_groups(self) -> None:
|
def test_get_groups(self) -> None:
|
||||||
@@ -1128,7 +1126,7 @@ class TestBulkEditObjectPermissions(APITestCase):
|
|||||||
def setUp(self) -> None:
|
def setUp(self) -> None:
|
||||||
super().setUp()
|
super().setUp()
|
||||||
|
|
||||||
self.temp_admin = User.objects.create_superuser(username="temp_admin")
|
self.temp_admin = UserFactory(username="temp_admin", superuser=True)
|
||||||
self.client.force_authenticate(user=self.temp_admin)
|
self.client.force_authenticate(user=self.temp_admin)
|
||||||
|
|
||||||
self.t1 = Tag.objects.create(name="t1")
|
self.t1 = Tag.objects.create(name="t1")
|
||||||
@@ -1276,7 +1274,7 @@ class TestBulkEditObjectPermissions(APITestCase):
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
assign_perm("view_tag", self.user3, self.t1)
|
grant_object(self.user3, self.t1, "view_tag")
|
||||||
self.t1.owner = self.user3
|
self.t1.owner = self.user3
|
||||||
self.t1.save()
|
self.t1.save()
|
||||||
|
|
||||||
@@ -1373,13 +1371,9 @@ class TestBulkEditObjectPermissions(APITestCase):
|
|||||||
"""
|
"""
|
||||||
self.t1.owner = self.user2
|
self.t1.owner = self.user2
|
||||||
self.t1.save()
|
self.t1.save()
|
||||||
assign_perm("view_tag", self.user1, self.t1)
|
grant_object(self.user1, self.t1, "view_tag")
|
||||||
assign_perm("change_tag", self.user1, self.t1)
|
grant_object(self.user1, self.t1, "change_tag")
|
||||||
self.user1.user_permissions.add(
|
grant_global(self.user1, "view_tag", "change_tag")
|
||||||
*Permission.objects.filter(
|
|
||||||
codename__in=["view_tag", "change_tag"],
|
|
||||||
),
|
|
||||||
)
|
|
||||||
user1 = User.objects.get(pk=self.user1.pk)
|
user1 = User.objects.get(pk=self.user1.pk)
|
||||||
self.client.force_authenticate(user=user1)
|
self.client.force_authenticate(user=user1)
|
||||||
|
|
||||||
@@ -1426,13 +1420,9 @@ class TestBulkEditObjectPermissions(APITestCase):
|
|||||||
"""
|
"""
|
||||||
owned = Tag.objects.create(name="owned", owner=self.user1)
|
owned = Tag.objects.create(name="owned", owner=self.user1)
|
||||||
shared = Tag.objects.create(name="shared", owner=self.user2)
|
shared = Tag.objects.create(name="shared", owner=self.user2)
|
||||||
assign_perm("view_tag", self.user1, shared)
|
grant_object(self.user1, shared, "view_tag")
|
||||||
assign_perm("change_tag", self.user1, shared)
|
grant_object(self.user1, shared, "change_tag")
|
||||||
self.user1.user_permissions.add(
|
grant_global(self.user1, "view_tag", "change_tag")
|
||||||
*Permission.objects.filter(
|
|
||||||
codename__in=["view_tag", "change_tag"],
|
|
||||||
),
|
|
||||||
)
|
|
||||||
user1 = User.objects.get(pk=self.user1.pk)
|
user1 = User.objects.get(pk=self.user1.pk)
|
||||||
self.client.force_authenticate(user=user1)
|
self.client.force_authenticate(user=user1)
|
||||||
|
|
||||||
@@ -1473,14 +1463,10 @@ class TestBulkEditObjectPermissions(APITestCase):
|
|||||||
"""
|
"""
|
||||||
self.t1.owner = self.user2
|
self.t1.owner = self.user2
|
||||||
self.t1.save()
|
self.t1.save()
|
||||||
assign_perm("view_tag", self.user1, self.t1)
|
grant_object(self.user1, self.t1, "view_tag")
|
||||||
assign_perm("change_tag", self.user1, self.t1)
|
grant_object(self.user1, self.t1, "change_tag")
|
||||||
assign_perm("delete_tag", self.user1, self.t1)
|
grant_object(self.user1, self.t1, "delete_tag")
|
||||||
self.user1.user_permissions.add(
|
grant_global(self.user1, "view_tag", "change_tag", "delete_tag")
|
||||||
*Permission.objects.filter(
|
|
||||||
codename__in=["view_tag", "change_tag", "delete_tag"],
|
|
||||||
),
|
|
||||||
)
|
|
||||||
user1 = User.objects.get(pk=self.user1.pk)
|
user1 = User.objects.get(pk=self.user1.pk)
|
||||||
self.client.force_authenticate(user=user1)
|
self.client.force_authenticate(user=user1)
|
||||||
|
|
||||||
@@ -1585,7 +1571,7 @@ class TestBulkEditObjectPermissions(APITestCase):
|
|||||||
- Request succeeds and null is treated as an empty user list,
|
- Request succeeds and null is treated as an empty user list,
|
||||||
so the existing view permission is removed
|
so the existing view permission is removed
|
||||||
"""
|
"""
|
||||||
assign_perm("view_tag", self.user1, self.t1)
|
grant_object(self.user1, self.t1, "view_tag")
|
||||||
|
|
||||||
response = self.client.post(
|
response = self.client.post(
|
||||||
"/api/bulk_edit_objects/",
|
"/api/bulk_edit_objects/",
|
||||||
@@ -1680,7 +1666,7 @@ class TestFullPermissionsFlag(APITestCase):
|
|||||||
def setUp(self) -> None:
|
def setUp(self) -> None:
|
||||||
super().setUp()
|
super().setUp()
|
||||||
|
|
||||||
self.admin = User.objects.create_superuser(username="admin")
|
self.admin = UserFactory(username="admin", superuser=True)
|
||||||
|
|
||||||
def test_full_perms_flag(self) -> None:
|
def test_full_perms_flag(self) -> None:
|
||||||
"""
|
"""
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
import unicodedata
|
import unicodedata
|
||||||
from typing import TYPE_CHECKING
|
from typing import TYPE_CHECKING
|
||||||
from unittest import mock
|
from unittest import mock
|
||||||
@@ -7,8 +9,11 @@ import pytest
|
|||||||
from django.core.files.uploadedfile import SimpleUploadedFile
|
from django.core.files.uploadedfile import SimpleUploadedFile
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
|
from rest_framework.test import APIClient
|
||||||
|
|
||||||
from documents.data_models import ConsumableDocument
|
from documents.data_models import ConsumableDocument
|
||||||
from documents.data_models import DocumentMetadataOverrides
|
from documents.data_models import DocumentMetadataOverrides
|
||||||
|
from paperless_testing.dirs import PaperlessDirs
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture()
|
@pytest.fixture()
|
||||||
@@ -18,22 +23,14 @@ def consume_file_mock():
|
|||||||
yield m
|
yield m
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture()
|
|
||||||
def directories(tmp_path, settings, _media_settings):
|
|
||||||
scratch = tmp_path / "scratch"
|
|
||||||
scratch.mkdir()
|
|
||||||
settings.SCRATCH_DIR = scratch
|
|
||||||
return scratch
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.django_db
|
@pytest.mark.django_db
|
||||||
class TestPostDocumentNFCNormalization:
|
class TestPostDocumentNFCNormalization:
|
||||||
def test_nfd_filename_normalized_to_nfc(
|
def test_nfd_filename_normalized_to_nfc(
|
||||||
self,
|
self,
|
||||||
admin_client,
|
admin_client: APIClient,
|
||||||
consume_file_mock: mock.MagicMock,
|
consume_file_mock: mock.MagicMock,
|
||||||
directories,
|
paperless_dirs: PaperlessDirs,
|
||||||
):
|
) -> None:
|
||||||
"""Uploaded file with NFD filename must have its name stored as NFC."""
|
"""Uploaded file with NFD filename must have its name stored as NFC."""
|
||||||
nfd = unicodedata.normalize("NFD", "Rechnung März.pdf")
|
nfd = unicodedata.normalize("NFD", "Rechnung März.pdf")
|
||||||
nfc = unicodedata.normalize("NFC", "Rechnung März.pdf")
|
nfc = unicodedata.normalize("NFC", "Rechnung März.pdf")
|
||||||
@@ -69,10 +66,10 @@ class TestPostDocumentNFCNormalization:
|
|||||||
|
|
||||||
def test_already_nfc_filename_unchanged(
|
def test_already_nfc_filename_unchanged(
|
||||||
self,
|
self,
|
||||||
admin_client,
|
admin_client: APIClient,
|
||||||
consume_file_mock: mock.MagicMock,
|
consume_file_mock: mock.MagicMock,
|
||||||
directories,
|
paperless_dirs: PaperlessDirs,
|
||||||
):
|
) -> None:
|
||||||
"""Uploaded file with already-NFC filename must pass through unchanged."""
|
"""Uploaded file with already-NFC filename must pass through unchanged."""
|
||||||
nfc = unicodedata.normalize("NFC", "Invoice_2024.pdf")
|
nfc = unicodedata.normalize("NFC", "Invoice_2024.pdf")
|
||||||
|
|
||||||
|
|||||||
@@ -8,7 +8,8 @@ from rest_framework import status
|
|||||||
from rest_framework.authtoken.models import Token
|
from rest_framework.authtoken.models import Token
|
||||||
from rest_framework.test import APITestCase
|
from rest_framework.test import APITestCase
|
||||||
|
|
||||||
from documents.tests.utils import DirectoriesMixin
|
from paperless_testing.dirs import DirectoriesMixin
|
||||||
|
from paperless_testing.factories import UserFactory
|
||||||
|
|
||||||
|
|
||||||
# see allauth.socialaccount.providers.openid.provider.OpenIDProvider
|
# see allauth.socialaccount.providers.openid.provider.OpenIDProvider
|
||||||
@@ -55,23 +56,23 @@ class TestApiProfile(DirectoriesMixin, APITestCase):
|
|||||||
def setUp(self) -> None:
|
def setUp(self) -> None:
|
||||||
super().setUp()
|
super().setUp()
|
||||||
|
|
||||||
self.user = User.objects.create_superuser(
|
self.user = UserFactory(
|
||||||
username="temp_admin",
|
username="temp_admin",
|
||||||
first_name="firstname",
|
first_name="firstname",
|
||||||
last_name="surname",
|
last_name="surname",
|
||||||
|
superuser=True,
|
||||||
)
|
)
|
||||||
self.client.force_authenticate(user=self.user)
|
self.client.force_authenticate(user=self.user)
|
||||||
|
|
||||||
def setupSocialAccount(self) -> None:
|
def setupSocialAccount(self) -> SocialAccount:
|
||||||
SocialApp.objects.create(
|
SocialApp.objects.create(
|
||||||
name="Keycloak",
|
name="Keycloak",
|
||||||
provider="openid_connect",
|
provider="openid_connect",
|
||||||
provider_id="keycloak-test",
|
provider_id="keycloak-test",
|
||||||
)
|
)
|
||||||
self.user.socialaccount_set.add(
|
social_account = SocialAccount(uid="123456789", provider="keycloak-test")
|
||||||
SocialAccount(uid="123456789", provider="keycloak-test"),
|
self.user.socialaccount_set.add(social_account, bulk=False)
|
||||||
bulk=False,
|
return social_account
|
||||||
)
|
|
||||||
|
|
||||||
def test_get_profile(self) -> None:
|
def test_get_profile(self) -> None:
|
||||||
"""
|
"""
|
||||||
@@ -109,19 +110,17 @@ class TestApiProfile(DirectoriesMixin, APITestCase):
|
|||||||
THEN:
|
THEN:
|
||||||
- Profile is returned with social accounts
|
- Profile is returned with social accounts
|
||||||
"""
|
"""
|
||||||
self.setupSocialAccount()
|
social_account = self.setupSocialAccount()
|
||||||
|
|
||||||
openid_provider = (
|
openid_provider = MockOpenIDConnectProvider(
|
||||||
MockOpenIDConnectProvider(
|
app=SocialApp.objects.get(provider_id="keycloak-test"),
|
||||||
app=SocialApp.objects.get(provider_id="keycloak-test"),
|
|
||||||
),
|
|
||||||
)
|
)
|
||||||
mock_list_providers.return_value = [
|
mock_list_providers.return_value = [
|
||||||
openid_provider,
|
openid_provider,
|
||||||
]
|
]
|
||||||
mock_get_provider_account.return_value = MockOpenIDConnectProviderAccount(
|
mock_get_provider_account.return_value = MockOpenIDConnectProviderAccount(
|
||||||
mock_social_account_dict={
|
mock_social_account_dict={
|
||||||
"name": openid_provider[0].name,
|
"name": openid_provider.name,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -133,7 +132,7 @@ class TestApiProfile(DirectoriesMixin, APITestCase):
|
|||||||
response.data["social_accounts"],
|
response.data["social_accounts"],
|
||||||
[
|
[
|
||||||
{
|
{
|
||||||
"id": 1,
|
"id": social_account.pk,
|
||||||
"provider": "keycloak-test",
|
"provider": "keycloak-test",
|
||||||
"name": "Keycloak",
|
"name": "Keycloak",
|
||||||
},
|
},
|
||||||
@@ -150,7 +149,7 @@ class TestApiProfile(DirectoriesMixin, APITestCase):
|
|||||||
THEN:
|
THEN:
|
||||||
- Profile is returned with "Unknown App" as name
|
- Profile is returned with "Unknown App" as name
|
||||||
"""
|
"""
|
||||||
self.setupSocialAccount()
|
social_account = self.setupSocialAccount()
|
||||||
|
|
||||||
# Remove the social app
|
# Remove the social app
|
||||||
SocialApp.objects.get(provider_id="keycloak-test").delete()
|
SocialApp.objects.get(provider_id="keycloak-test").delete()
|
||||||
@@ -163,7 +162,7 @@ class TestApiProfile(DirectoriesMixin, APITestCase):
|
|||||||
response.data["social_accounts"],
|
response.data["social_accounts"],
|
||||||
[
|
[
|
||||||
{
|
{
|
||||||
"id": 1,
|
"id": social_account.pk,
|
||||||
"provider": "keycloak-test",
|
"provider": "keycloak-test",
|
||||||
"name": "Unknown App",
|
"name": "Unknown App",
|
||||||
},
|
},
|
||||||
@@ -401,7 +400,7 @@ class TestApiTOTPViews(APITestCase):
|
|||||||
def setUp(self) -> None:
|
def setUp(self) -> None:
|
||||||
super().setUp()
|
super().setUp()
|
||||||
|
|
||||||
self.user = User.objects.create_superuser(username="temp_admin")
|
self.user = UserFactory(username="temp_admin", superuser=True)
|
||||||
self.client.force_authenticate(user=self.user)
|
self.client.force_authenticate(user=self.user)
|
||||||
|
|
||||||
def test_get_totp(self) -> None:
|
def test_get_totp(self) -> None:
|
||||||
|
|||||||
@@ -7,10 +7,8 @@ import time_machine
|
|||||||
from dateutil.relativedelta import relativedelta
|
from dateutil.relativedelta import relativedelta
|
||||||
from django.contrib.auth.models import Group
|
from django.contrib.auth.models import Group
|
||||||
from django.contrib.auth.models import Permission
|
from django.contrib.auth.models import Permission
|
||||||
from django.contrib.auth.models import User
|
|
||||||
from django.test import override_settings
|
from django.test import override_settings
|
||||||
from django.utils import timezone
|
from django.utils import timezone
|
||||||
from guardian.shortcuts import assign_perm
|
|
||||||
from rest_framework import status
|
from rest_framework import status
|
||||||
from rest_framework.test import APITestCase
|
from rest_framework.test import APITestCase
|
||||||
|
|
||||||
@@ -27,10 +25,13 @@ from documents.models import Tag
|
|||||||
from documents.models import Workflow
|
from documents.models import Workflow
|
||||||
from documents.search import get_backend
|
from documents.search import get_backend
|
||||||
from documents.search import reset_backend
|
from documents.search import reset_backend
|
||||||
from documents.tests.factories import DocumentFactory
|
|
||||||
from documents.tests.utils import DirectoriesMixin
|
|
||||||
from paperless_mail.models import MailAccount
|
from paperless_mail.models import MailAccount
|
||||||
from paperless_mail.models import MailRule
|
from paperless_mail.models import MailRule
|
||||||
|
from paperless_testing.dirs import DirectoriesMixin
|
||||||
|
from paperless_testing.factories import DocumentFactory
|
||||||
|
from paperless_testing.factories import UserFactory
|
||||||
|
from paperless_testing.permissions import grant_global
|
||||||
|
from paperless_testing.permissions import grant_object
|
||||||
|
|
||||||
pytestmark = pytest.mark.search
|
pytestmark = pytest.mark.search
|
||||||
|
|
||||||
@@ -40,7 +41,7 @@ class TestDocumentSearchApi(DirectoriesMixin, APITestCase):
|
|||||||
super().setUp()
|
super().setUp()
|
||||||
reset_backend()
|
reset_backend()
|
||||||
|
|
||||||
self.user = User.objects.create_superuser(username="temp_admin")
|
self.user = UserFactory(username="temp_admin", superuser=True)
|
||||||
self.client.force_authenticate(user=self.user)
|
self.client.force_authenticate(user=self.user)
|
||||||
|
|
||||||
def tearDown(self) -> None:
|
def tearDown(self) -> None:
|
||||||
@@ -949,9 +950,9 @@ class TestDocumentSearchApi(DirectoriesMixin, APITestCase):
|
|||||||
THEN:
|
THEN:
|
||||||
- Terms only within docs user has access to are returned
|
- Terms only within docs user has access to are returned
|
||||||
"""
|
"""
|
||||||
u1 = User.objects.create_user("user1")
|
u1 = UserFactory(username="user1")
|
||||||
u2 = User.objects.create_user("user2")
|
u2 = UserFactory(username="user2")
|
||||||
u1.user_permissions.add(Permission.objects.get(codename="view_document"))
|
grant_global(u1, "view_document")
|
||||||
|
|
||||||
self.client.force_authenticate(user=u1)
|
self.client.force_authenticate(user=u1)
|
||||||
|
|
||||||
@@ -991,7 +992,7 @@ class TestDocumentSearchApi(DirectoriesMixin, APITestCase):
|
|||||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||||
self.assertEqual(response.data, ["applebaum", "apples"])
|
self.assertEqual(response.data, ["applebaum", "apples"])
|
||||||
|
|
||||||
assign_perm("view_document", u1, d3)
|
grant_object(u1, d3, "view_document")
|
||||||
backend.add_or_update(d3)
|
backend.add_or_update(d3)
|
||||||
|
|
||||||
response = self.client.get("/api/search/autocomplete/?term=app")
|
response = self.client.get("/api/search/autocomplete/?term=app")
|
||||||
@@ -999,10 +1000,10 @@ class TestDocumentSearchApi(DirectoriesMixin, APITestCase):
|
|||||||
self.assertEqual(response.data, ["applebaum", "apples", "appletini"])
|
self.assertEqual(response.data, ["applebaum", "apples", "appletini"])
|
||||||
|
|
||||||
def test_search_autocomplete_group_revocation_is_immediate(self) -> None:
|
def test_search_autocomplete_group_revocation_is_immediate(self) -> None:
|
||||||
user = User.objects.create_user("group-user")
|
user = UserFactory(username="group-user")
|
||||||
owner = User.objects.create_user("document-owner")
|
owner = UserFactory(username="document-owner")
|
||||||
group = Group.objects.create(name="temporary-viewers")
|
group = Group.objects.create(name="temporary-viewers")
|
||||||
user.user_permissions.add(Permission.objects.get(codename="view_document"))
|
grant_global(user, "view_document")
|
||||||
user.groups.add(group)
|
user.groups.add(group)
|
||||||
|
|
||||||
document = Document.objects.create(
|
document = Document.objects.create(
|
||||||
@@ -1011,7 +1012,7 @@ class TestDocumentSearchApi(DirectoriesMixin, APITestCase):
|
|||||||
checksum="group-revocation",
|
checksum="group-revocation",
|
||||||
owner=owner,
|
owner=owner,
|
||||||
)
|
)
|
||||||
assign_perm("view_document", group, document)
|
grant_object(group, document, "view_document")
|
||||||
get_backend().add_or_update(document)
|
get_backend().add_or_update(document)
|
||||||
self.client.force_authenticate(user=user)
|
self.client.force_authenticate(user=user)
|
||||||
|
|
||||||
@@ -1091,11 +1092,9 @@ class TestDocumentSearchApi(DirectoriesMixin, APITestCase):
|
|||||||
self.assertIsNone(response.data["corrected_query"])
|
self.assertIsNone(response.data["corrected_query"])
|
||||||
|
|
||||||
def test_search_spelling_suggestion_suppressed_for_private_terms(self) -> None:
|
def test_search_spelling_suggestion_suppressed_for_private_terms(self) -> None:
|
||||||
owner = User.objects.create_user("owner")
|
owner = UserFactory(username="owner")
|
||||||
attacker = User.objects.create_user("attacker")
|
attacker = UserFactory(username="attacker")
|
||||||
attacker.user_permissions.add(
|
grant_global(attacker, "view_document")
|
||||||
Permission.objects.get(codename="view_document"),
|
|
||||||
)
|
|
||||||
|
|
||||||
backend = get_backend()
|
backend = get_backend()
|
||||||
for i in range(5):
|
for i in range(5):
|
||||||
@@ -1222,11 +1221,9 @@ class TestDocumentSearchApi(DirectoriesMixin, APITestCase):
|
|||||||
THEN:
|
THEN:
|
||||||
- The request is rejected
|
- The request is rejected
|
||||||
"""
|
"""
|
||||||
owner = User.objects.create_user("owner")
|
owner = UserFactory(username="owner")
|
||||||
attacker = User.objects.create_user("attacker")
|
attacker = UserFactory(username="attacker")
|
||||||
attacker.user_permissions.add(
|
grant_global(attacker, "view_document")
|
||||||
Permission.objects.get(codename="view_document"),
|
|
||||||
)
|
|
||||||
|
|
||||||
private_seed = Document.objects.create(
|
private_seed = Document.objects.create(
|
||||||
title="private bank statement",
|
title="private bank statement",
|
||||||
@@ -1534,11 +1531,11 @@ class TestDocumentSearchApi(DirectoriesMixin, APITestCase):
|
|||||||
- Only owned docs are returned for regular users
|
- Only owned docs are returned for regular users
|
||||||
- All docs are returned for superuser
|
- All docs are returned for superuser
|
||||||
"""
|
"""
|
||||||
superuser = User.objects.create_superuser("superuser")
|
superuser = UserFactory(username="superuser", superuser=True)
|
||||||
u1 = User.objects.create_user("user1")
|
u1 = UserFactory(username="user1")
|
||||||
u2 = User.objects.create_user("user2")
|
u2 = UserFactory(username="user2")
|
||||||
u1.user_permissions.add(*Permission.objects.filter(codename="view_document"))
|
grant_global(u1, "view_document")
|
||||||
u2.user_permissions.add(*Permission.objects.filter(codename="view_document"))
|
grant_global(u2, "view_document")
|
||||||
|
|
||||||
Document.objects.create(checksum="1", content="test 1", owner=u1)
|
Document.objects.create(checksum="1", content="test 1", owner=u1)
|
||||||
Document.objects.create(checksum="2", content="test 2", owner=u2)
|
Document.objects.create(checksum="2", content="test 2", owner=u2)
|
||||||
@@ -1588,10 +1585,10 @@ class TestDocumentSearchApi(DirectoriesMixin, APITestCase):
|
|||||||
THEN:
|
THEN:
|
||||||
- Only docs with granted view permissions are returned
|
- Only docs with granted view permissions are returned
|
||||||
"""
|
"""
|
||||||
u1 = User.objects.create_user("user1")
|
u1 = UserFactory(username="user1")
|
||||||
u2 = User.objects.create_user("user2")
|
u2 = UserFactory(username="user2")
|
||||||
u1.user_permissions.add(*Permission.objects.filter(codename="view_document"))
|
grant_global(u1, "view_document")
|
||||||
u2.user_permissions.add(*Permission.objects.filter(codename="view_document"))
|
grant_global(u2, "view_document")
|
||||||
|
|
||||||
d1 = Document.objects.create(checksum="1", content="test 1", owner=u1)
|
d1 = Document.objects.create(checksum="1", content="test 1", owner=u1)
|
||||||
d2 = Document.objects.create(checksum="2", content="test 2", owner=u2)
|
d2 = Document.objects.create(checksum="2", content="test 2", owner=u2)
|
||||||
@@ -1616,9 +1613,9 @@ class TestDocumentSearchApi(DirectoriesMixin, APITestCase):
|
|||||||
r = self.client.get("/api/documents/?query=test&owner__isnull=true")
|
r = self.client.get("/api/documents/?query=test&owner__isnull=true")
|
||||||
self.assertEqual(r.data["count"], 1)
|
self.assertEqual(r.data["count"], 1)
|
||||||
|
|
||||||
assign_perm("view_document", u1, d2)
|
grant_object(u1, d2, "view_document")
|
||||||
assign_perm("view_document", u1, d3)
|
grant_object(u1, d3, "view_document")
|
||||||
assign_perm("view_document", u2, d1)
|
grant_object(u2, d1, "view_document")
|
||||||
|
|
||||||
backend.add_or_update(d1)
|
backend.add_or_update(d1)
|
||||||
backend.add_or_update(d2)
|
backend.add_or_update(d2)
|
||||||
@@ -1641,8 +1638,8 @@ class TestDocumentSearchApi(DirectoriesMixin, APITestCase):
|
|||||||
self.assertEqual(r.data["count"], 1)
|
self.assertEqual(r.data["count"], 1)
|
||||||
|
|
||||||
def test_search_sorting(self) -> None:
|
def test_search_sorting(self) -> None:
|
||||||
u1 = User.objects.create_user("user1")
|
u1 = UserFactory(username="user1")
|
||||||
u2 = User.objects.create_user("user2")
|
u2 = UserFactory(username="user2")
|
||||||
c1 = Correspondent.objects.create(name="corres Ax")
|
c1 = Correspondent.objects.create(name="corres Ax")
|
||||||
c2 = Correspondent.objects.create(name="corres Cx")
|
c2 = Correspondent.objects.create(name="corres Cx")
|
||||||
c3 = Correspondent.objects.create(name="corres Bx")
|
c3 = Correspondent.objects.create(name="corres Bx")
|
||||||
@@ -1892,8 +1889,8 @@ class TestDocumentSearchApi(DirectoriesMixin, APITestCase):
|
|||||||
pk=5,
|
pk=5,
|
||||||
)
|
)
|
||||||
|
|
||||||
user1 = User.objects.create_user("bank user1")
|
user1 = UserFactory(username="bank user1")
|
||||||
user2 = User.objects.create_superuser("user2")
|
user2 = UserFactory(username="user2", superuser=True)
|
||||||
group1 = Group.objects.create(name="bank group1")
|
group1 = Group.objects.create(name="bank group1")
|
||||||
Group.objects.create(name="group2")
|
Group.objects.create(name="group2")
|
||||||
|
|
||||||
@@ -1925,7 +1922,7 @@ class TestDocumentSearchApi(DirectoriesMixin, APITestCase):
|
|||||||
sort_field="",
|
sort_field="",
|
||||||
owner=user2,
|
owner=user2,
|
||||||
)
|
)
|
||||||
assign_perm("view_savedview", user1, shared_view)
|
grant_object(user1, shared_view, "view_savedview")
|
||||||
mail_account1 = MailAccount.objects.create(name="bank mail account 1")
|
mail_account1 = MailAccount.objects.create(name="bank mail account 1")
|
||||||
mail_account2 = MailAccount.objects.create(name="mail account 2")
|
mail_account2 = MailAccount.objects.create(name="mail account 2")
|
||||||
mail_rule1 = MailRule.objects.create(
|
mail_rule1 = MailRule.objects.create(
|
||||||
@@ -2018,12 +2015,9 @@ class TestDocumentSearchApi(DirectoriesMixin, APITestCase):
|
|||||||
self.assertEqual(returned.get(root.id), "latest content")
|
self.assertEqual(returned.get(root.id), "latest content")
|
||||||
|
|
||||||
def test_global_search_filters_owned_mail_objects(self) -> None:
|
def test_global_search_filters_owned_mail_objects(self) -> None:
|
||||||
user1 = User.objects.create_user("mail-search-user")
|
user1 = UserFactory(username="mail-search-user")
|
||||||
user2 = User.objects.create_user("other-mail-search-user")
|
user2 = UserFactory(username="other-mail-search-user")
|
||||||
user1.user_permissions.add(
|
grant_global(user1, "view_mailaccount", "view_mailrule")
|
||||||
Permission.objects.get(codename="view_mailaccount"),
|
|
||||||
Permission.objects.get(codename="view_mailrule"),
|
|
||||||
)
|
|
||||||
|
|
||||||
own_account = MailAccount.objects.create(
|
own_account = MailAccount.objects.create(
|
||||||
name="bank owned account",
|
name="bank owned account",
|
||||||
|
|||||||
@@ -33,7 +33,7 @@ class TestSearchQueryErrorStillBecomesA400:
|
|||||||
self,
|
self,
|
||||||
admin_client: APIClient,
|
admin_client: APIClient,
|
||||||
monkeypatch: pytest.MonkeyPatch,
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
indexed_document: Document,
|
searchable_document: Document,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""
|
"""
|
||||||
GIVEN:
|
GIVEN:
|
||||||
@@ -68,7 +68,7 @@ class TestLibraryDefectsPropagate:
|
|||||||
self,
|
self,
|
||||||
admin_client: APIClient,
|
admin_client: APIClient,
|
||||||
monkeypatch: pytest.MonkeyPatch,
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
indexed_document: Document,
|
searchable_document: Document,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""
|
"""
|
||||||
GIVEN:
|
GIVEN:
|
||||||
@@ -98,7 +98,7 @@ class TestLibraryDefectsPropagate:
|
|||||||
self,
|
self,
|
||||||
admin_client: APIClient,
|
admin_client: APIClient,
|
||||||
monkeypatch: pytest.MonkeyPatch,
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
indexed_document: Document,
|
searchable_document: Document,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""
|
"""
|
||||||
GIVEN:
|
GIVEN:
|
||||||
@@ -141,7 +141,7 @@ class TestSelectionPathsAgreeWithSearch:
|
|||||||
self,
|
self,
|
||||||
admin_client: APIClient,
|
admin_client: APIClient,
|
||||||
monkeypatch: pytest.MonkeyPatch,
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
indexed_document: Document,
|
searchable_document: Document,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""
|
"""
|
||||||
GIVEN:
|
GIVEN:
|
||||||
@@ -181,7 +181,7 @@ class TestSelectionPathsAgreeWithSearch:
|
|||||||
self,
|
self,
|
||||||
admin_client: APIClient,
|
admin_client: APIClient,
|
||||||
monkeypatch: pytest.MonkeyPatch,
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
indexed_document: Document,
|
searchable_document: Document,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""
|
"""
|
||||||
GIVEN:
|
GIVEN:
|
||||||
@@ -221,7 +221,7 @@ class TestSelectionPathsAgreeWithSearch:
|
|||||||
self,
|
self,
|
||||||
admin_client: APIClient,
|
admin_client: APIClient,
|
||||||
monkeypatch: pytest.MonkeyPatch,
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
indexed_document: Document,
|
searchable_document: Document,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""
|
"""
|
||||||
GIVEN:
|
GIVEN:
|
||||||
@@ -259,7 +259,7 @@ class TestSelectionPathsAgreeWithSearch:
|
|||||||
self,
|
self,
|
||||||
admin_client: APIClient,
|
admin_client: APIClient,
|
||||||
monkeypatch: pytest.MonkeyPatch,
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
indexed_document: Document,
|
searchable_document: Document,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""
|
"""
|
||||||
GIVEN:
|
GIVEN:
|
||||||
@@ -287,7 +287,7 @@ class TestSelectionPathsAgreeWithSearch:
|
|||||||
{
|
{
|
||||||
"documents": [],
|
"documents": [],
|
||||||
"all": True,
|
"all": True,
|
||||||
"filters": {"more_like_id": indexed_document.pk},
|
"filters": {"more_like_id": searchable_document.pk},
|
||||||
},
|
},
|
||||||
format="json",
|
format="json",
|
||||||
)
|
)
|
||||||
@@ -298,7 +298,7 @@ class TestSelectionPathsAgreeWithSearch:
|
|||||||
self,
|
self,
|
||||||
admin_client: APIClient,
|
admin_client: APIClient,
|
||||||
monkeypatch: pytest.MonkeyPatch,
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
indexed_document: Document,
|
searchable_document: Document,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""
|
"""
|
||||||
GIVEN:
|
GIVEN:
|
||||||
@@ -328,7 +328,7 @@ class TestSelectionPathsAgreeWithSearch:
|
|||||||
{
|
{
|
||||||
"documents": [],
|
"documents": [],
|
||||||
"all": True,
|
"all": True,
|
||||||
"filters": {"more_like_id": indexed_document.pk},
|
"filters": {"more_like_id": searchable_document.pk},
|
||||||
},
|
},
|
||||||
format="json",
|
format="json",
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -36,7 +36,7 @@ class TestGetSearchEndpointEnforcesTheCap:
|
|||||||
def test_query_one_over_the_cap_is_a_400(
|
def test_query_one_over_the_cap_is_a_400(
|
||||||
self,
|
self,
|
||||||
admin_client: APIClient,
|
admin_client: APIClient,
|
||||||
indexed_document: Document,
|
searchable_document: Document,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""
|
"""
|
||||||
GIVEN:
|
GIVEN:
|
||||||
@@ -66,7 +66,7 @@ class TestGetSearchEndpointEnforcesTheCap:
|
|||||||
def test_query_at_exactly_the_cap_is_accepted(
|
def test_query_at_exactly_the_cap_is_accepted(
|
||||||
self,
|
self,
|
||||||
admin_client: APIClient,
|
admin_client: APIClient,
|
||||||
indexed_document: Document,
|
searchable_document: Document,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""
|
"""
|
||||||
GIVEN:
|
GIVEN:
|
||||||
@@ -86,7 +86,7 @@ class TestGetSearchEndpointEnforcesTheCap:
|
|||||||
def test_an_ordinary_query_is_unaffected(
|
def test_an_ordinary_query_is_unaffected(
|
||||||
self,
|
self,
|
||||||
admin_client: APIClient,
|
admin_client: APIClient,
|
||||||
indexed_document: Document,
|
searchable_document: Document,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""
|
"""
|
||||||
GIVEN:
|
GIVEN:
|
||||||
@@ -112,7 +112,7 @@ class TestPostSelectionPathsEnforceTheCap:
|
|||||||
def test_bulk_edit_query_one_over_the_cap_is_a_400(
|
def test_bulk_edit_query_one_over_the_cap_is_a_400(
|
||||||
self,
|
self,
|
||||||
admin_client: APIClient,
|
admin_client: APIClient,
|
||||||
indexed_document: Document,
|
searchable_document: Document,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""
|
"""
|
||||||
GIVEN:
|
GIVEN:
|
||||||
@@ -154,7 +154,7 @@ class TestPostSelectionPathsEnforceTheCap:
|
|||||||
self,
|
self,
|
||||||
bulk_update_task_mock: mock.MagicMock,
|
bulk_update_task_mock: mock.MagicMock,
|
||||||
admin_client: APIClient,
|
admin_client: APIClient,
|
||||||
indexed_document: Document,
|
searchable_document: Document,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""
|
"""
|
||||||
GIVEN:
|
GIVEN:
|
||||||
@@ -187,7 +187,7 @@ class TestPostSelectionPathsEnforceTheCap:
|
|||||||
def test_bulk_download_query_one_over_the_cap_is_a_400(
|
def test_bulk_download_query_one_over_the_cap_is_a_400(
|
||||||
self,
|
self,
|
||||||
admin_client: APIClient,
|
admin_client: APIClient,
|
||||||
indexed_document: Document,
|
searchable_document: Document,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""
|
"""
|
||||||
GIVEN:
|
GIVEN:
|
||||||
@@ -236,7 +236,7 @@ class TestGlobalSearchEnforcesTheCapToo:
|
|||||||
def test_query_one_over_the_cap_is_a_400(
|
def test_query_one_over_the_cap_is_a_400(
|
||||||
self,
|
self,
|
||||||
admin_client: APIClient,
|
admin_client: APIClient,
|
||||||
indexed_document: Document,
|
searchable_document: Document,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""
|
"""
|
||||||
GIVEN:
|
GIVEN:
|
||||||
@@ -260,7 +260,7 @@ class TestGlobalSearchEnforcesTheCapToo:
|
|||||||
def test_query_at_exactly_the_cap_is_accepted(
|
def test_query_at_exactly_the_cap_is_accepted(
|
||||||
self,
|
self,
|
||||||
admin_client: APIClient,
|
admin_client: APIClient,
|
||||||
indexed_document: Document,
|
searchable_document: Document,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""
|
"""
|
||||||
GIVEN:
|
GIVEN:
|
||||||
|
|||||||
@@ -38,7 +38,7 @@ class TestUnterminatedBracketReturnsA400:
|
|||||||
def test_unterminated_bracket_is_a_400(
|
def test_unterminated_bracket_is_a_400(
|
||||||
self,
|
self,
|
||||||
admin_client: APIClient,
|
admin_client: APIClient,
|
||||||
indexed_document: Document,
|
searchable_document: Document,
|
||||||
query: str,
|
query: str,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""
|
"""
|
||||||
@@ -59,7 +59,7 @@ class TestUnterminatedBracketReturnsA400:
|
|||||||
def test_properly_closed_bracket_still_searches_cleanly(
|
def test_properly_closed_bracket_still_searches_cleanly(
|
||||||
self,
|
self,
|
||||||
admin_client: APIClient,
|
admin_client: APIClient,
|
||||||
indexed_document: Document,
|
searchable_document: Document,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""
|
"""
|
||||||
GIVEN:
|
GIVEN:
|
||||||
|
|||||||
@@ -5,8 +5,6 @@ from datetime import timedelta
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from unittest import mock
|
from unittest import mock
|
||||||
|
|
||||||
from django.contrib.auth.models import Permission
|
|
||||||
from django.contrib.auth.models import User
|
|
||||||
from django.test import override_settings
|
from django.test import override_settings
|
||||||
from django.utils import timezone
|
from django.utils import timezone
|
||||||
from rest_framework import status
|
from rest_framework import status
|
||||||
@@ -14,8 +12,10 @@ from rest_framework.test import APITestCase
|
|||||||
|
|
||||||
from documents.models import PaperlessTask
|
from documents.models import PaperlessTask
|
||||||
from documents.permissions import has_system_status_permission
|
from documents.permissions import has_system_status_permission
|
||||||
from documents.tests.factories import PaperlessTaskFactory
|
|
||||||
from paperless import version
|
from paperless import version
|
||||||
|
from paperless_testing.factories import PaperlessTaskFactory
|
||||||
|
from paperless_testing.factories import UserFactory
|
||||||
|
from paperless_testing.permissions import grant_global
|
||||||
|
|
||||||
|
|
||||||
class TestSystemStatus(APITestCase):
|
class TestSystemStatus(APITestCase):
|
||||||
@@ -23,9 +23,7 @@ class TestSystemStatus(APITestCase):
|
|||||||
|
|
||||||
def setUp(self) -> None:
|
def setUp(self) -> None:
|
||||||
super().setUp()
|
super().setUp()
|
||||||
self.user = User.objects.create_superuser(
|
self.user = UserFactory(username="temp_admin", superuser=True)
|
||||||
username="temp_admin",
|
|
||||||
)
|
|
||||||
self.tmp_dir = Path(tempfile.mkdtemp())
|
self.tmp_dir = Path(tempfile.mkdtemp())
|
||||||
self.override = override_settings(MEDIA_ROOT=self.tmp_dir)
|
self.override = override_settings(MEDIA_ROOT=self.tmp_dir)
|
||||||
self.override.enable()
|
self.override.enable()
|
||||||
@@ -96,7 +94,7 @@ class TestSystemStatus(APITestCase):
|
|||||||
response = self.client.get(self.ENDPOINT)
|
response = self.client.get(self.ENDPOINT)
|
||||||
self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED)
|
self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED)
|
||||||
self.assertEqual(response["WWW-Authenticate"], "Token")
|
self.assertEqual(response["WWW-Authenticate"], "Token")
|
||||||
normal_user = User.objects.create_user(username="normal_user")
|
normal_user = UserFactory(username="normal_user")
|
||||||
self.client.force_login(normal_user)
|
self.client.force_login(normal_user)
|
||||||
response = self.client.get(self.ENDPOINT)
|
response = self.client.get(self.ENDPOINT)
|
||||||
self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN)
|
self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN)
|
||||||
@@ -107,10 +105,8 @@ class TestSystemStatus(APITestCase):
|
|||||||
response = self.client.get(self.ENDPOINT)
|
response = self.client.get(self.ENDPOINT)
|
||||||
self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED)
|
self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED)
|
||||||
|
|
||||||
user = User.objects.create_user(username="status_user")
|
user = UserFactory(username="status_user")
|
||||||
user.user_permissions.add(
|
grant_global(user, "view_system_monitoring")
|
||||||
Permission.objects.get(codename="view_system_monitoring"),
|
|
||||||
)
|
|
||||||
|
|
||||||
self.client.force_login(user)
|
self.client.force_login(user)
|
||||||
response = self.client.get(self.ENDPOINT)
|
response = self.client.get(self.ENDPOINT)
|
||||||
|
|||||||
@@ -11,22 +11,21 @@ from datetime import timedelta
|
|||||||
from unittest import mock
|
from unittest import mock
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
from django.contrib.auth.models import Permission
|
|
||||||
from django.contrib.auth.models import User
|
from django.contrib.auth.models import User
|
||||||
from django.utils import timezone
|
from django.utils import timezone
|
||||||
from guardian.shortcuts import assign_perm
|
|
||||||
from rest_framework import status
|
from rest_framework import status
|
||||||
from rest_framework.test import APIClient
|
from rest_framework.test import APIClient
|
||||||
|
|
||||||
from documents.filters import PaperlessTaskFilterSet
|
from documents.filters import PaperlessTaskFilterSet
|
||||||
from documents.models import PaperlessTask
|
from documents.models import PaperlessTask
|
||||||
from documents.tests.factories import DocumentFactory
|
from paperless_testing.factories import DocumentFactory
|
||||||
from documents.tests.factories import PaperlessTaskFactory
|
from paperless_testing.factories import PaperlessTaskFactory
|
||||||
|
from paperless_testing.permissions import grant_global
|
||||||
|
from paperless_testing.permissions import grant_object
|
||||||
|
|
||||||
pytestmark = pytest.mark.api
|
pytestmark = pytest.mark.api
|
||||||
|
|
||||||
ENDPOINT = "/api/tasks/"
|
ENDPOINT = "/api/tasks/"
|
||||||
ACCEPT_V10 = "application/json; version=10"
|
|
||||||
ACCEPT_V9 = "application/json; version=9"
|
ACCEPT_V9 = "application/json; version=9"
|
||||||
|
|
||||||
|
|
||||||
@@ -346,21 +345,16 @@ class TestGetTasksV10:
|
|||||||
self,
|
self,
|
||||||
admin_user: User,
|
admin_user: User,
|
||||||
regular_user: User,
|
regular_user: User,
|
||||||
|
user_client: APIClient,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Regular users see their own tasks and unowned (system) tasks; other users' tasks are hidden."""
|
"""Regular users see their own tasks and unowned (system) tasks; other users' tasks are hidden."""
|
||||||
regular_user.user_permissions.add(
|
grant_global(regular_user, "view_paperlesstask")
|
||||||
Permission.objects.get(codename="view_paperlesstask"),
|
|
||||||
)
|
|
||||||
|
|
||||||
client = APIClient()
|
|
||||||
client.force_authenticate(user=regular_user)
|
|
||||||
client.credentials(HTTP_ACCEPT=ACCEPT_V10)
|
|
||||||
|
|
||||||
PaperlessTaskFactory(owner=admin_user) # other user — not visible
|
PaperlessTaskFactory(owner=admin_user) # other user — not visible
|
||||||
unowned_task = PaperlessTaskFactory() # unowned (system task) — visible
|
unowned_task = PaperlessTaskFactory() # unowned (system task) — visible
|
||||||
own_task = PaperlessTaskFactory(owner=regular_user)
|
own_task = PaperlessTaskFactory(owner=regular_user)
|
||||||
|
|
||||||
response = client.get(ENDPOINT)
|
response = user_client.get(ENDPOINT)
|
||||||
|
|
||||||
assert response.status_code == status.HTTP_200_OK
|
assert response.status_code == status.HTTP_200_OK
|
||||||
assert response.data["count"] == 2
|
assert response.data["count"] == 2
|
||||||
@@ -590,9 +584,7 @@ class TestGetTasksV9:
|
|||||||
regular_user: User,
|
regular_user: User,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Non-staff users see their own tasks plus unowned tasks via v9 API."""
|
"""Non-staff users see their own tasks plus unowned tasks via v9 API."""
|
||||||
regular_user.user_permissions.add(
|
grant_global(regular_user, "view_paperlesstask")
|
||||||
Permission.objects.get(codename="view_paperlesstask"),
|
|
||||||
)
|
|
||||||
|
|
||||||
client = APIClient()
|
client = APIClient()
|
||||||
client.force_authenticate(user=regular_user)
|
client.force_authenticate(user=regular_user)
|
||||||
@@ -732,19 +724,17 @@ class TestAcknowledge:
|
|||||||
|
|
||||||
assert response.status_code == status.HTTP_403_FORBIDDEN
|
assert response.status_code == status.HTTP_403_FORBIDDEN
|
||||||
|
|
||||||
def test_succeeds_with_change_permission(self, regular_user: User) -> None:
|
def test_succeeds_with_change_permission(
|
||||||
|
self,
|
||||||
|
regular_user: User,
|
||||||
|
user_client: APIClient,
|
||||||
|
) -> None:
|
||||||
"""Users granted change_paperlesstask permission can acknowledge tasks."""
|
"""Users granted change_paperlesstask permission can acknowledge tasks."""
|
||||||
regular_user.user_permissions.add(
|
grant_global(regular_user, "change_paperlesstask")
|
||||||
Permission.objects.get(codename="change_paperlesstask"),
|
|
||||||
)
|
|
||||||
regular_user.save()
|
regular_user.save()
|
||||||
|
|
||||||
client = APIClient()
|
|
||||||
client.force_authenticate(user=regular_user)
|
|
||||||
client.credentials(HTTP_ACCEPT=ACCEPT_V10)
|
|
||||||
|
|
||||||
task = PaperlessTaskFactory()
|
task = PaperlessTaskFactory()
|
||||||
response = client.post(
|
response = user_client.post(
|
||||||
ENDPOINT + "acknowledge/",
|
ENDPOINT + "acknowledge/",
|
||||||
{"tasks": [task.id]},
|
{"tasks": [task.id]},
|
||||||
format="json",
|
format="json",
|
||||||
@@ -807,9 +797,7 @@ class TestSummaryPermissions:
|
|||||||
regular_user,
|
regular_user,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""A user with view_system_monitoring but no document permissions can access summary/."""
|
"""A user with view_system_monitoring but no document permissions can access summary/."""
|
||||||
regular_user.user_permissions.add(
|
grant_global(regular_user, "view_system_monitoring")
|
||||||
Permission.objects.get(codename="view_system_monitoring"),
|
|
||||||
)
|
|
||||||
|
|
||||||
response = user_client.get(ENDPOINT + "summary/")
|
response = user_client.get(ENDPOINT + "summary/")
|
||||||
|
|
||||||
@@ -822,9 +810,7 @@ class TestSummaryPermissions:
|
|||||||
admin_user,
|
admin_user,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Monitoring user sees aggregate data for all tasks, not just unowned ones."""
|
"""Monitoring user sees aggregate data for all tasks, not just unowned ones."""
|
||||||
regular_user.user_permissions.add(
|
grant_global(regular_user, "view_system_monitoring")
|
||||||
Permission.objects.get(codename="view_system_monitoring"),
|
|
||||||
)
|
|
||||||
PaperlessTaskFactory(
|
PaperlessTaskFactory(
|
||||||
owner=admin_user,
|
owner=admin_user,
|
||||||
task_type=PaperlessTask.TaskType.CONSUME_FILE,
|
task_type=PaperlessTask.TaskType.CONSUME_FILE,
|
||||||
@@ -845,9 +831,7 @@ class TestSummaryPermissions:
|
|||||||
) -> None:
|
) -> None:
|
||||||
"""A regular user with view_paperlesstask but not view_system_monitoring sees only
|
"""A regular user with view_paperlesstask but not view_system_monitoring sees only
|
||||||
their own tasks and unowned tasks in the summary, not other users' tasks."""
|
their own tasks and unowned tasks in the summary, not other users' tasks."""
|
||||||
regular_user.user_permissions.add(
|
grant_global(regular_user, "view_paperlesstask")
|
||||||
Permission.objects.get(codename="view_paperlesstask"),
|
|
||||||
)
|
|
||||||
|
|
||||||
PaperlessTaskFactory(
|
PaperlessTaskFactory(
|
||||||
owner=regular_user,
|
owner=regular_user,
|
||||||
@@ -1012,9 +996,7 @@ class TestDuplicateDocumentsPermissions:
|
|||||||
|
|
||||||
@pytest.fixture()
|
@pytest.fixture()
|
||||||
def user_v9_client(self, regular_user: User) -> APIClient:
|
def user_v9_client(self, regular_user: User) -> APIClient:
|
||||||
regular_user.user_permissions.add(
|
grant_global(regular_user, "view_paperlesstask")
|
||||||
Permission.objects.get(codename="view_paperlesstask"),
|
|
||||||
)
|
|
||||||
client = APIClient()
|
client = APIClient()
|
||||||
client.force_authenticate(user=regular_user)
|
client.force_authenticate(user=regular_user)
|
||||||
client.credentials(HTTP_ACCEPT=ACCEPT_V9)
|
client.credentials(HTTP_ACCEPT=ACCEPT_V9)
|
||||||
@@ -1085,7 +1067,7 @@ class TestDuplicateDocumentsPermissions:
|
|||||||
) -> None:
|
) -> None:
|
||||||
"""A user with explicit guardian view_document permission sees the duplicate_of document."""
|
"""A user with explicit guardian view_document permission sees the duplicate_of document."""
|
||||||
doc = DocumentFactory(owner=admin_user, title="Granted Doc")
|
doc = DocumentFactory(owner=admin_user, title="Granted Doc")
|
||||||
assign_perm("view_document", regular_user, doc)
|
grant_object(regular_user, doc, "view_document")
|
||||||
PaperlessTaskFactory(
|
PaperlessTaskFactory(
|
||||||
owner=regular_user,
|
owner=regular_user,
|
||||||
status=PaperlessTask.Status.SUCCESS,
|
status=PaperlessTask.Status.SUCCESS,
|
||||||
|
|||||||
@@ -1,21 +1,21 @@
|
|||||||
from datetime import date
|
from datetime import date
|
||||||
|
|
||||||
from django.contrib.auth.models import Permission
|
|
||||||
from django.contrib.auth.models import User
|
|
||||||
from django.core.cache import cache
|
from django.core.cache import cache
|
||||||
from rest_framework import status
|
from rest_framework import status
|
||||||
from rest_framework.test import APITestCase
|
from rest_framework.test import APITestCase
|
||||||
|
|
||||||
from documents.models import Document
|
from documents.models import Document
|
||||||
from documents.tests.utils import DirectoriesMixin
|
from paperless_testing.dirs import DirectoriesMixin
|
||||||
|
from paperless_testing.factories import UserFactory
|
||||||
|
from paperless_testing.permissions import grant_all_global
|
||||||
|
|
||||||
|
|
||||||
class TestTrashAPI(DirectoriesMixin, APITestCase):
|
class TestTrashAPI(DirectoriesMixin, APITestCase):
|
||||||
def setUp(self) -> None:
|
def setUp(self) -> None:
|
||||||
super().setUp()
|
super().setUp()
|
||||||
|
|
||||||
self.user = User.objects.create_user(username="temp_admin")
|
self.user = UserFactory(username="temp_admin")
|
||||||
self.user.user_permissions.add(*Permission.objects.all())
|
grant_all_global(self.user)
|
||||||
self.client.force_authenticate(user=self.user)
|
self.client.force_authenticate(user=self.user)
|
||||||
cache.clear()
|
cache.clear()
|
||||||
|
|
||||||
@@ -70,7 +70,7 @@ class TestTrashAPI(DirectoriesMixin, APITestCase):
|
|||||||
self.assertEqual(Document.global_objects.count(), 0)
|
self.assertEqual(Document.global_objects.count(), 0)
|
||||||
|
|
||||||
def test_trash_list_requires_global_document_view_permission(self) -> None:
|
def test_trash_list_requires_global_document_view_permission(self) -> None:
|
||||||
user = User.objects.create_user(username="trash_owner")
|
user = UserFactory(username="trash_owner")
|
||||||
document = Document.objects.create(title="Owned", owner=user)
|
document = Document.objects.create(title="Owned", owner=user)
|
||||||
document.delete()
|
document.delete()
|
||||||
self.client.force_authenticate(user)
|
self.client.force_authenticate(user)
|
||||||
@@ -140,7 +140,7 @@ class TestTrashAPI(DirectoriesMixin, APITestCase):
|
|||||||
created=date(2023, 1, 2),
|
created=date(2023, 1, 2),
|
||||||
)
|
)
|
||||||
document_not_owned.delete()
|
document_not_owned.delete()
|
||||||
user2 = User.objects.create_user(username="user2")
|
user2 = UserFactory(username="user2")
|
||||||
document_u2 = Document.objects.create(
|
document_u2 = Document.objects.create(
|
||||||
title="Title3",
|
title="Title3",
|
||||||
content="content3",
|
content="content3",
|
||||||
@@ -158,7 +158,7 @@ class TestTrashAPI(DirectoriesMixin, APITestCase):
|
|||||||
self.assertEqual(resp.data["results"][1]["id"], document_u1.pk)
|
self.assertEqual(resp.data["results"][1]["id"], document_u1.pk)
|
||||||
|
|
||||||
# superuser sees all documents
|
# superuser sees all documents
|
||||||
superuser = User.objects.create_superuser(username="superuser")
|
superuser = UserFactory(username="superuser", superuser=True)
|
||||||
self.client.force_authenticate(user=superuser)
|
self.client.force_authenticate(user=superuser)
|
||||||
resp = self.client.get("/api/trash/")
|
resp = self.client.get("/api/trash/")
|
||||||
self.assertEqual(resp.status_code, status.HTTP_200_OK)
|
self.assertEqual(resp.status_code, status.HTTP_200_OK)
|
||||||
@@ -174,7 +174,7 @@ class TestTrashAPI(DirectoriesMixin, APITestCase):
|
|||||||
- 403 Forbidden
|
- 403 Forbidden
|
||||||
"""
|
"""
|
||||||
|
|
||||||
user2 = User.objects.create_user(username="user2")
|
user2 = UserFactory(username="user2")
|
||||||
document = Document.objects.create(
|
document = Document.objects.create(
|
||||||
title="Title",
|
title="Title",
|
||||||
content="content",
|
content="content",
|
||||||
|
|||||||
@@ -1,13 +1,13 @@
|
|||||||
import json
|
import json
|
||||||
|
|
||||||
from django.contrib.auth.models import Permission
|
from django.contrib.auth.models import Permission
|
||||||
from django.contrib.auth.models import User
|
|
||||||
from django.test import override_settings
|
from django.test import override_settings
|
||||||
from rest_framework import status
|
from rest_framework import status
|
||||||
from rest_framework.test import APITestCase
|
from rest_framework.test import APITestCase
|
||||||
|
|
||||||
from documents.tests.utils import DirectoriesMixin
|
|
||||||
from paperless.version import __full_version_str__
|
from paperless.version import __full_version_str__
|
||||||
|
from paperless_testing.dirs import DirectoriesMixin
|
||||||
|
from paperless_testing.factories import UserFactory
|
||||||
|
|
||||||
|
|
||||||
class TestApiUiSettings(DirectoriesMixin, APITestCase):
|
class TestApiUiSettings(DirectoriesMixin, APITestCase):
|
||||||
@@ -15,7 +15,7 @@ class TestApiUiSettings(DirectoriesMixin, APITestCase):
|
|||||||
|
|
||||||
def setUp(self) -> None:
|
def setUp(self) -> None:
|
||||||
super().setUp()
|
super().setUp()
|
||||||
self.test_user = User.objects.create_superuser(username="test")
|
self.test_user = UserFactory(username="test", superuser=True)
|
||||||
self.test_user.first_name = "Test"
|
self.test_user.first_name = "Test"
|
||||||
self.test_user.last_name = "User"
|
self.test_user.last_name = "User"
|
||||||
self.test_user.save()
|
self.test_user.save()
|
||||||
@@ -91,7 +91,7 @@ class TestApiUiSettings(DirectoriesMixin, APITestCase):
|
|||||||
)
|
)
|
||||||
|
|
||||||
def test_api_set_ui_settings_insufficient_global_permissions(self) -> None:
|
def test_api_set_ui_settings_insufficient_global_permissions(self) -> None:
|
||||||
not_superuser = User.objects.create_user(username="test_not_superuser")
|
not_superuser = UserFactory(username="test_not_superuser")
|
||||||
self.client.force_authenticate(user=not_superuser)
|
self.client.force_authenticate(user=not_superuser)
|
||||||
|
|
||||||
settings = {
|
settings = {
|
||||||
@@ -111,7 +111,7 @@ class TestApiUiSettings(DirectoriesMixin, APITestCase):
|
|||||||
self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN)
|
self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN)
|
||||||
|
|
||||||
def test_api_set_ui_settings_sufficient_global_permissions(self) -> None:
|
def test_api_set_ui_settings_sufficient_global_permissions(self) -> None:
|
||||||
not_superuser = User.objects.create_user(username="test_not_superuser")
|
not_superuser = UserFactory(username="test_not_superuser")
|
||||||
not_superuser.user_permissions.add(
|
not_superuser.user_permissions.add(
|
||||||
*Permission.objects.filter(codename__contains="uisettings"),
|
*Permission.objects.filter(codename__contains="uisettings"),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -14,7 +14,8 @@ from documents.models import Tag
|
|||||||
from documents.models import Workflow
|
from documents.models import Workflow
|
||||||
from documents.models import WorkflowAction
|
from documents.models import WorkflowAction
|
||||||
from documents.models import WorkflowTrigger
|
from documents.models import WorkflowTrigger
|
||||||
from documents.tests.utils import DirectoriesMixin
|
from paperless_testing.dirs import DirectoriesMixin
|
||||||
|
from paperless_testing.factories import UserFactory
|
||||||
|
|
||||||
|
|
||||||
class TestApiWorkflows(DirectoriesMixin, APITestCase):
|
class TestApiWorkflows(DirectoriesMixin, APITestCase):
|
||||||
@@ -25,7 +26,7 @@ class TestApiWorkflows(DirectoriesMixin, APITestCase):
|
|||||||
def setUp(self) -> None:
|
def setUp(self) -> None:
|
||||||
super().setUp()
|
super().setUp()
|
||||||
|
|
||||||
user = User.objects.create_superuser(username="temp_admin")
|
user = UserFactory(username="temp_admin", superuser=True)
|
||||||
self.client.force_authenticate(user=user)
|
self.client.force_authenticate(user=user)
|
||||||
self.user2 = User.objects.create(username="user2")
|
self.user2 = User.objects.create(username="user2")
|
||||||
self.user3 = User.objects.create(username="user3")
|
self.user3 = User.objects.create(username="user3")
|
||||||
|
|||||||
@@ -2,8 +2,8 @@ import shutil
|
|||||||
from collections.abc import Generator
|
from collections.abc import Generator
|
||||||
from contextlib import contextmanager
|
from contextlib import contextmanager
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from unittest import mock
|
|
||||||
|
|
||||||
|
import pytest
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.test import TestCase
|
from django.test import TestCase
|
||||||
from django.test import override_settings
|
from django.test import override_settings
|
||||||
@@ -18,11 +18,11 @@ from documents.models import Document
|
|||||||
from documents.models import Tag
|
from documents.models import Tag
|
||||||
from documents.plugins.base import StopConsumeTaskError
|
from documents.plugins.base import StopConsumeTaskError
|
||||||
from documents.tests.utils import ConsumeTaskMixin
|
from documents.tests.utils import ConsumeTaskMixin
|
||||||
from documents.tests.utils import DirectoriesMixin
|
|
||||||
from documents.tests.utils import DummyProgressManager
|
|
||||||
from documents.tests.utils import FileSystemAssertsMixin
|
|
||||||
from documents.tests.utils import SampleDirMixin
|
from documents.tests.utils import SampleDirMixin
|
||||||
from paperless.models import ApplicationConfiguration
|
from paperless.models import ApplicationConfiguration
|
||||||
|
from paperless_testing.assertions import FileSystemAssertsMixin
|
||||||
|
from paperless_testing.dirs import DirectoriesMixin
|
||||||
|
from paperless_testing.fakes.progress import FakeProgressManager
|
||||||
|
|
||||||
|
|
||||||
class GetReaderPluginMixin:
|
class GetReaderPluginMixin:
|
||||||
@@ -31,7 +31,7 @@ class GetReaderPluginMixin:
|
|||||||
reader = BarcodePlugin(
|
reader = BarcodePlugin(
|
||||||
ConsumableDocument(DocumentSource.ConsumeFolder, original_file=filepath),
|
ConsumableDocument(DocumentSource.ConsumeFolder, original_file=filepath),
|
||||||
DocumentMetadataOverrides(),
|
DocumentMetadataOverrides(),
|
||||||
DummyProgressManager(filepath.name, None),
|
FakeProgressManager(filepath.name, None),
|
||||||
self.dirs.scratch_dir,
|
self.dirs.scratch_dir,
|
||||||
"task-id",
|
"task-id",
|
||||||
)
|
)
|
||||||
@@ -86,6 +86,7 @@ class TestBarcode(
|
|||||||
self.assertDictEqual(separator_page_numbers, {1: False})
|
self.assertDictEqual(separator_page_numbers, {1: False})
|
||||||
|
|
||||||
@override_settings(CONSUMER_ENABLE_ASN_BARCODE=True)
|
@override_settings(CONSUMER_ENABLE_ASN_BARCODE=True)
|
||||||
|
@pytest.mark.usefixtures("fake_progress_manager")
|
||||||
def test_asn_barcode_duplicate_in_trash_fails(self) -> None:
|
def test_asn_barcode_duplicate_in_trash_fails(self) -> None:
|
||||||
"""
|
"""
|
||||||
GIVEN:
|
GIVEN:
|
||||||
@@ -110,15 +111,14 @@ class TestBarcode(
|
|||||||
dupe_asn = settings.SCRATCH_DIR / "barcode-39-asn-123-second.pdf"
|
dupe_asn = settings.SCRATCH_DIR / "barcode-39-asn-123-second.pdf"
|
||||||
shutil.copy(test_file, dupe_asn)
|
shutil.copy(test_file, dupe_asn)
|
||||||
|
|
||||||
with mock.patch("documents.tasks.ProgressManager", DummyProgressManager):
|
with self.assertRaisesRegex(ConsumerError, r"ASN 123.*trash"):
|
||||||
with self.assertRaisesRegex(ConsumerError, r"ASN 123.*trash"):
|
tasks.consume_file(
|
||||||
tasks.consume_file(
|
ConsumableDocument(
|
||||||
ConsumableDocument(
|
source=DocumentSource.ConsumeFolder,
|
||||||
source=DocumentSource.ConsumeFolder,
|
original_file=dupe_asn,
|
||||||
original_file=dupe_asn,
|
),
|
||||||
),
|
None,
|
||||||
None,
|
)
|
||||||
)
|
|
||||||
|
|
||||||
@override_settings(
|
@override_settings(
|
||||||
CONSUMER_BARCODE_TIFF_SUPPORT=True,
|
CONSUMER_BARCODE_TIFF_SUPPORT=True,
|
||||||
@@ -606,6 +606,7 @@ class TestBarcodeNewConsume(
|
|||||||
TestCase,
|
TestCase,
|
||||||
):
|
):
|
||||||
@override_settings(CONSUMER_ENABLE_BARCODES=True)
|
@override_settings(CONSUMER_ENABLE_BARCODES=True)
|
||||||
|
@pytest.mark.usefixtures("fake_progress_manager")
|
||||||
def test_consume_barcode_file(self) -> None:
|
def test_consume_barcode_file(self) -> None:
|
||||||
"""
|
"""
|
||||||
GIVEN:
|
GIVEN:
|
||||||
@@ -624,34 +625,33 @@ class TestBarcodeNewConsume(
|
|||||||
|
|
||||||
overrides = DocumentMetadataOverrides(tag_ids=[1, 2, 9])
|
overrides = DocumentMetadataOverrides(tag_ids=[1, 2, 9])
|
||||||
|
|
||||||
with mock.patch("documents.tasks.ProgressManager", DummyProgressManager):
|
self.assertEqual(
|
||||||
self.assertEqual(
|
tasks.consume_file(
|
||||||
tasks.consume_file(
|
ConsumableDocument(
|
||||||
ConsumableDocument(
|
source=DocumentSource.ConsumeFolder,
|
||||||
source=DocumentSource.ConsumeFolder,
|
original_file=temp_copy,
|
||||||
original_file=temp_copy,
|
|
||||||
),
|
|
||||||
overrides,
|
|
||||||
),
|
),
|
||||||
{"reason": "Barcode splitting complete!"},
|
overrides,
|
||||||
)
|
),
|
||||||
# 2 new document consume tasks created
|
{"reason": "Barcode splitting complete!"},
|
||||||
self.assertEqual(self.consume_file_mock.call_count, 2)
|
)
|
||||||
|
# 2 new document consume tasks created
|
||||||
|
self.assertEqual(self.consume_file_mock.call_count, 2)
|
||||||
|
|
||||||
self.assertIsNotFile(temp_copy)
|
self.assertIsNotFile(temp_copy)
|
||||||
|
|
||||||
# Check the split files exist
|
# Check the split files exist
|
||||||
# Check the original_path is set
|
# Check the original_path is set
|
||||||
# Check the source is unchanged
|
# Check the source is unchanged
|
||||||
# Check the overrides are unchanged
|
# Check the overrides are unchanged
|
||||||
for (
|
for (
|
||||||
new_input_doc,
|
new_input_doc,
|
||||||
new_doc_overrides,
|
new_doc_overrides,
|
||||||
) in self.get_all_consume_task_call_args():
|
) in self.get_all_consume_task_call_args():
|
||||||
self.assertIsFile(new_input_doc.original_file)
|
self.assertIsFile(new_input_doc.original_file)
|
||||||
self.assertEqual(new_input_doc.original_path, temp_copy)
|
self.assertEqual(new_input_doc.original_path, temp_copy)
|
||||||
self.assertEqual(new_input_doc.source, DocumentSource.ConsumeFolder)
|
self.assertEqual(new_input_doc.source, DocumentSource.ConsumeFolder)
|
||||||
self.assertEqual(overrides, new_doc_overrides)
|
self.assertEqual(overrides, new_doc_overrides)
|
||||||
|
|
||||||
|
|
||||||
class TestAsnBarcode(DirectoriesMixin, SampleDirMixin, GetReaderPluginMixin, TestCase):
|
class TestAsnBarcode(DirectoriesMixin, SampleDirMixin, GetReaderPluginMixin, TestCase):
|
||||||
@@ -660,7 +660,7 @@ class TestAsnBarcode(DirectoriesMixin, SampleDirMixin, GetReaderPluginMixin, Tes
|
|||||||
reader = BarcodePlugin(
|
reader = BarcodePlugin(
|
||||||
ConsumableDocument(DocumentSource.ConsumeFolder, original_file=filepath),
|
ConsumableDocument(DocumentSource.ConsumeFolder, original_file=filepath),
|
||||||
DocumentMetadataOverrides(),
|
DocumentMetadataOverrides(),
|
||||||
DummyProgressManager(filepath.name, None),
|
FakeProgressManager(filepath.name, None),
|
||||||
self.dirs.scratch_dir,
|
self.dirs.scratch_dir,
|
||||||
"task-id",
|
"task-id",
|
||||||
)
|
)
|
||||||
@@ -745,6 +745,7 @@ class TestAsnBarcode(DirectoriesMixin, SampleDirMixin, GetReaderPluginMixin, Tes
|
|||||||
self.assertEqual(asn, None)
|
self.assertEqual(asn, None)
|
||||||
|
|
||||||
@override_settings(CONSUMER_ENABLE_ASN_BARCODE=True)
|
@override_settings(CONSUMER_ENABLE_ASN_BARCODE=True)
|
||||||
|
@pytest.mark.usefixtures("fake_progress_manager")
|
||||||
def test_consume_barcode_file_asn_assignment(self) -> None:
|
def test_consume_barcode_file_asn_assignment(self) -> None:
|
||||||
"""
|
"""
|
||||||
GIVEN:
|
GIVEN:
|
||||||
@@ -762,19 +763,18 @@ class TestAsnBarcode(DirectoriesMixin, SampleDirMixin, GetReaderPluginMixin, Tes
|
|||||||
dst = settings.SCRATCH_DIR / "barcode-39-asn-123.pdf"
|
dst = settings.SCRATCH_DIR / "barcode-39-asn-123.pdf"
|
||||||
shutil.copy(test_file, dst)
|
shutil.copy(test_file, dst)
|
||||||
|
|
||||||
with mock.patch("documents.tasks.ProgressManager", DummyProgressManager):
|
tasks.consume_file(
|
||||||
tasks.consume_file(
|
ConsumableDocument(
|
||||||
ConsumableDocument(
|
source=DocumentSource.ConsumeFolder,
|
||||||
source=DocumentSource.ConsumeFolder,
|
original_file=dst,
|
||||||
original_file=dst,
|
),
|
||||||
),
|
None,
|
||||||
None,
|
)
|
||||||
)
|
|
||||||
|
|
||||||
document = Document.objects.first()
|
document = Document.objects.first()
|
||||||
assert document is not None
|
assert document is not None
|
||||||
|
|
||||||
self.assertEqual(document.archive_serial_number, 123)
|
self.assertEqual(document.archive_serial_number, 123)
|
||||||
|
|
||||||
def test_scan_file_for_qrcode_without_upscale(self) -> None:
|
def test_scan_file_for_qrcode_without_upscale(self) -> None:
|
||||||
"""
|
"""
|
||||||
@@ -819,7 +819,7 @@ class TestTagBarcode(DirectoriesMixin, SampleDirMixin, GetReaderPluginMixin, Tes
|
|||||||
reader = BarcodePlugin(
|
reader = BarcodePlugin(
|
||||||
ConsumableDocument(DocumentSource.ConsumeFolder, original_file=filepath),
|
ConsumableDocument(DocumentSource.ConsumeFolder, original_file=filepath),
|
||||||
DocumentMetadataOverrides(),
|
DocumentMetadataOverrides(),
|
||||||
DummyProgressManager(filepath.name, None),
|
FakeProgressManager(filepath.name, None),
|
||||||
self.dirs.scratch_dir,
|
self.dirs.scratch_dir,
|
||||||
"task-id",
|
"task-id",
|
||||||
)
|
)
|
||||||
@@ -1024,6 +1024,7 @@ class TestTagBarcode(DirectoriesMixin, SampleDirMixin, GetReaderPluginMixin, Tes
|
|||||||
CELERY_TASK_ALWAYS_EAGER=True,
|
CELERY_TASK_ALWAYS_EAGER=True,
|
||||||
OCR_MODE="auto",
|
OCR_MODE="auto",
|
||||||
)
|
)
|
||||||
|
@pytest.mark.usefixtures("fake_progress_manager")
|
||||||
def test_consume_barcode_file_tag_split_and_assignment(self) -> None:
|
def test_consume_barcode_file_tag_split_and_assignment(self) -> None:
|
||||||
"""
|
"""
|
||||||
GIVEN:
|
GIVEN:
|
||||||
@@ -1042,34 +1043,33 @@ class TestTagBarcode(DirectoriesMixin, SampleDirMixin, GetReaderPluginMixin, Tes
|
|||||||
dst = settings.SCRATCH_DIR / "split-by-tag-basic.pdf"
|
dst = settings.SCRATCH_DIR / "split-by-tag-basic.pdf"
|
||||||
shutil.copy(test_file, dst)
|
shutil.copy(test_file, dst)
|
||||||
|
|
||||||
with mock.patch("documents.tasks.ProgressManager", DummyProgressManager):
|
result = tasks.consume_file(
|
||||||
result = tasks.consume_file(
|
ConsumableDocument(
|
||||||
ConsumableDocument(
|
source=DocumentSource.ConsumeFolder,
|
||||||
source=DocumentSource.ConsumeFolder,
|
original_file=dst,
|
||||||
original_file=dst,
|
),
|
||||||
),
|
None,
|
||||||
None,
|
)
|
||||||
)
|
|
||||||
|
|
||||||
self.assertEqual(result, {"reason": "Barcode splitting complete!"})
|
self.assertEqual(result, {"reason": "Barcode splitting complete!"})
|
||||||
|
|
||||||
documents = Document.objects.all().order_by("id")
|
documents = Document.objects.all().order_by("id")
|
||||||
self.assertEqual(documents.count(), 3)
|
self.assertEqual(documents.count(), 3)
|
||||||
|
|
||||||
doc1 = documents[0]
|
doc1 = documents[0]
|
||||||
self.assertEqual(doc1.tags.count(), 0)
|
self.assertEqual(doc1.tags.count(), 0)
|
||||||
|
|
||||||
doc2 = documents[1]
|
doc2 = documents[1]
|
||||||
self.assertEqual(doc2.tags.count(), 1)
|
self.assertEqual(doc2.tags.count(), 1)
|
||||||
_tag_1 = doc2.tags.first()
|
_tag_1 = doc2.tags.first()
|
||||||
assert _tag_1 is not None
|
assert _tag_1 is not None
|
||||||
self.assertEqual(_tag_1.name, "invoice")
|
self.assertEqual(_tag_1.name, "invoice")
|
||||||
|
|
||||||
doc3 = documents[2]
|
doc3 = documents[2]
|
||||||
self.assertEqual(doc3.tags.count(), 1)
|
self.assertEqual(doc3.tags.count(), 1)
|
||||||
_tag_2 = doc3.tags.first()
|
_tag_2 = doc3.tags.first()
|
||||||
assert _tag_2 is not None
|
assert _tag_2 is not None
|
||||||
self.assertEqual(_tag_2.name, "receipt")
|
self.assertEqual(_tag_2.name, "receipt")
|
||||||
|
|
||||||
@override_settings(
|
@override_settings(
|
||||||
CONSUMER_ENABLE_TAG_BARCODE=True,
|
CONSUMER_ENABLE_TAG_BARCODE=True,
|
||||||
|
|||||||
@@ -10,7 +10,6 @@ from django.contrib.auth.models import User
|
|||||||
from django.db import connection
|
from django.db import connection
|
||||||
from django.test import TestCase
|
from django.test import TestCase
|
||||||
from django.test.utils import CaptureQueriesContext
|
from django.test.utils import CaptureQueriesContext
|
||||||
from guardian.shortcuts import assign_perm
|
|
||||||
from guardian.shortcuts import get_groups_with_perms
|
from guardian.shortcuts import get_groups_with_perms
|
||||||
from guardian.shortcuts import get_users_with_perms
|
from guardian.shortcuts import get_users_with_perms
|
||||||
|
|
||||||
@@ -23,7 +22,8 @@ from documents.models import DocumentType
|
|||||||
from documents.models import StoragePath
|
from documents.models import StoragePath
|
||||||
from documents.models import Tag
|
from documents.models import Tag
|
||||||
from documents.permissions import set_permissions_for_objects
|
from documents.permissions import set_permissions_for_objects
|
||||||
from documents.tests.utils import DirectoriesMixin
|
from paperless_testing.dirs import DirectoriesMixin
|
||||||
|
from paperless_testing.permissions import grant_object
|
||||||
|
|
||||||
|
|
||||||
class TestBulkEdit(DirectoriesMixin, TestCase):
|
class TestBulkEdit(DirectoriesMixin, TestCase):
|
||||||
@@ -440,7 +440,7 @@ class TestBulkEdit(DirectoriesMixin, TestCase):
|
|||||||
def test_set_permissions(self, m) -> None:
|
def test_set_permissions(self, m) -> None:
|
||||||
doc_ids = [self.doc1.id, self.doc2.id, self.doc3.id]
|
doc_ids = [self.doc1.id, self.doc2.id, self.doc3.id]
|
||||||
|
|
||||||
assign_perm("view_document", self.group1, self.doc1)
|
grant_object(self.group1, self.doc1, "view_document")
|
||||||
|
|
||||||
permissions = {
|
permissions = {
|
||||||
"view": {
|
"view": {
|
||||||
@@ -482,8 +482,8 @@ class TestBulkEdit(DirectoriesMixin, TestCase):
|
|||||||
self.doc1.owner = self.user1
|
self.doc1.owner = self.user1
|
||||||
self.doc1.save()
|
self.doc1.save()
|
||||||
|
|
||||||
assign_perm("view_document", self.user1, self.doc1)
|
grant_object(self.user1, self.doc1, "view_document")
|
||||||
assign_perm("view_document", self.group1, self.doc1)
|
grant_object(self.group1, self.doc1, "view_document")
|
||||||
|
|
||||||
permissions = {
|
permissions = {
|
||||||
"view": {
|
"view": {
|
||||||
@@ -609,7 +609,7 @@ class TestBulkEdit(DirectoriesMixin, TestCase):
|
|||||||
self.doc1.owner = self.user1
|
self.doc1.owner = self.user1
|
||||||
self.doc1.save()
|
self.doc1.save()
|
||||||
self.user1.groups.add(self.group1)
|
self.user1.groups.add(self.group1)
|
||||||
assign_perm("view_document", self.group1, self.doc1)
|
grant_object(self.group1, self.doc1, "view_document")
|
||||||
|
|
||||||
bulk_edit.set_permissions(
|
bulk_edit.set_permissions(
|
||||||
[self.doc1.id],
|
[self.doc1.id],
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import pickle
|
import pickle
|
||||||
import re
|
|
||||||
import warnings
|
import warnings
|
||||||
from datetime import UTC
|
from datetime import UTC
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
@@ -28,21 +27,13 @@ from documents.models import DocumentType
|
|||||||
from documents.models import MatchingModel
|
from documents.models import MatchingModel
|
||||||
from documents.models import StoragePath
|
from documents.models import StoragePath
|
||||||
from documents.models import Tag
|
from documents.models import Tag
|
||||||
from documents.tests.factories import DocumentFactory
|
from documents.tests.helpers import dummy_preprocess
|
||||||
from documents.tests.factories import TagFactory
|
|
||||||
from documents.tests.utils import DirectoriesMixin
|
|
||||||
from paperless.settings import CLASSIFIER_LANGUAGES
|
from paperless.settings import CLASSIFIER_LANGUAGES
|
||||||
from paperless.signed_pickle import HMAC_SIZE
|
from paperless.signed_pickle import HMAC_SIZE
|
||||||
from paperless.signed_pickle import signed_pickle_dumps
|
from paperless.signed_pickle import signed_pickle_dumps
|
||||||
|
from paperless_testing.dirs import DirectoriesMixin
|
||||||
|
from paperless_testing.factories import DocumentFactory
|
||||||
def dummy_preprocess(content: str) -> str:
|
from paperless_testing.factories import TagFactory
|
||||||
"""
|
|
||||||
Simpler, faster pre-processing for testing purposes
|
|
||||||
"""
|
|
||||||
content = content.lower().strip()
|
|
||||||
content = re.sub(r"\s+", " ", content)
|
|
||||||
return content
|
|
||||||
|
|
||||||
|
|
||||||
class TestClassifier(DirectoriesMixin, TestCase):
|
class TestClassifier(DirectoriesMixin, TestCase):
|
||||||
|
|||||||
@@ -30,11 +30,12 @@ from documents.models import Tag
|
|||||||
from documents.parsers import ParseError
|
from documents.parsers import ParseError
|
||||||
from documents.plugins.helpers import ProgressStatusOptions
|
from documents.plugins.helpers import ProgressStatusOptions
|
||||||
from documents.tasks import sanity_check
|
from documents.tasks import sanity_check
|
||||||
from documents.tests.utils import DirectoriesMixin
|
|
||||||
from documents.tests.utils import DummyProgressManager
|
|
||||||
from documents.tests.utils import FileSystemAssertsMixin
|
|
||||||
from documents.tests.utils import GetConsumerMixin
|
from documents.tests.utils import GetConsumerMixin
|
||||||
from paperless_mail.models import MailRule
|
from paperless_mail.models import MailRule
|
||||||
|
from paperless_testing.assertions import FileSystemAssertsMixin
|
||||||
|
from paperless_testing.dirs import DirectoriesMixin
|
||||||
|
from paperless_testing.factories import UserFactory
|
||||||
|
from paperless_testing.fakes.progress import FakeProgressManager
|
||||||
|
|
||||||
|
|
||||||
class _BaseNewStyleParser:
|
class _BaseNewStyleParser:
|
||||||
@@ -769,14 +770,14 @@ class TestConsumer(
|
|||||||
|
|
||||||
original_modified = timezone.now() - datetime.timedelta(days=1)
|
original_modified = timezone.now() - datetime.timedelta(days=1)
|
||||||
Document.objects.filter(pk=root_doc.pk).update(modified=original_modified)
|
Document.objects.filter(pk=root_doc.pk).update(modified=original_modified)
|
||||||
actor = User.objects.create_user(
|
actor = UserFactory(
|
||||||
username="actor",
|
username="actor",
|
||||||
email="actor@example.com",
|
email="actor@example.com",
|
||||||
password="password",
|
password="password",
|
||||||
)
|
)
|
||||||
|
|
||||||
version_file = self.get_test_file2()
|
version_file = self.get_test_file2()
|
||||||
status = DummyProgressManager(version_file.name, None)
|
status = FakeProgressManager(version_file.name, None)
|
||||||
overrides = DocumentMetadataOverrides(
|
overrides = DocumentMetadataOverrides(
|
||||||
version_label="v2",
|
version_label="v2",
|
||||||
actor_id=actor.pk,
|
actor_id=actor.pk,
|
||||||
@@ -839,7 +840,7 @@ class TestConsumer(
|
|||||||
assert root_doc is not None
|
assert root_doc is not None
|
||||||
|
|
||||||
version_file = self.get_test_file2()
|
version_file = self.get_test_file2()
|
||||||
status = DummyProgressManager(version_file.name, None)
|
status = FakeProgressManager(version_file.name, None)
|
||||||
overrides = DocumentMetadataOverrides(
|
overrides = DocumentMetadataOverrides(
|
||||||
filename="valid_pdf_version-upload",
|
filename="valid_pdf_version-upload",
|
||||||
actor_id=999999,
|
actor_id=999999,
|
||||||
@@ -896,7 +897,7 @@ class TestConsumer(
|
|||||||
assert root_doc is not None
|
assert root_doc is not None
|
||||||
|
|
||||||
def consume_version(version_file: Path) -> Document:
|
def consume_version(version_file: Path) -> Document:
|
||||||
status = DummyProgressManager(version_file.name, None)
|
status = FakeProgressManager(version_file.name, None)
|
||||||
overrides = DocumentMetadataOverrides()
|
overrides = DocumentMetadataOverrides()
|
||||||
doc = ConsumableDocument(
|
doc = ConsumableDocument(
|
||||||
DocumentSource.ApiUpload,
|
DocumentSource.ApiUpload,
|
||||||
|
|||||||
@@ -9,11 +9,11 @@ from django.test.utils import CaptureQueriesContext
|
|||||||
from rest_framework import status
|
from rest_framework import status
|
||||||
|
|
||||||
from documents.models import Document
|
from documents.models import Document
|
||||||
from documents.tests.factories import DocumentFactory
|
|
||||||
from documents.versioning import LATEST_VERSION_CONTENT_PREFETCH_ATTR
|
from documents.versioning import LATEST_VERSION_CONTENT_PREFETCH_ATTR
|
||||||
from documents.versioning import has_prefetched_effective_content
|
from documents.versioning import has_prefetched_effective_content
|
||||||
from documents.versioning import latest_version_content_prefetch
|
from documents.versioning import latest_version_content_prefetch
|
||||||
from documents.views import DocumentViewSet
|
from documents.views import DocumentViewSet
|
||||||
|
from paperless_testing.factories import DocumentFactory
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from rest_framework.test import APIClient
|
from rest_framework.test import APIClient
|
||||||
|
|||||||
@@ -2,8 +2,8 @@ import datetime as dt
|
|||||||
import os
|
import os
|
||||||
import shutil
|
import shutil
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from unittest import mock
|
|
||||||
|
|
||||||
|
import pytest
|
||||||
from django.test import TestCase
|
from django.test import TestCase
|
||||||
from django.test import override_settings
|
from django.test import override_settings
|
||||||
from pdfminer.high_level import extract_text
|
from pdfminer.high_level import extract_text
|
||||||
@@ -15,22 +15,26 @@ from documents.data_models import ConsumableDocument
|
|||||||
from documents.data_models import DocumentSource
|
from documents.data_models import DocumentSource
|
||||||
from documents.double_sided import STAGING_FILE_NAME
|
from documents.double_sided import STAGING_FILE_NAME
|
||||||
from documents.double_sided import TIMEOUT_MINUTES
|
from documents.double_sided import TIMEOUT_MINUTES
|
||||||
from documents.tests.utils import DirectoriesMixin
|
from documents.tests.utils import SampleDirMixin
|
||||||
from documents.tests.utils import DummyProgressManager
|
from paperless_testing.assertions import FileSystemAssertsMixin
|
||||||
from documents.tests.utils import FileSystemAssertsMixin
|
from paperless_testing.dirs import DirectoriesMixin
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.usefixtures("fake_progress_manager")
|
||||||
@override_settings(
|
@override_settings(
|
||||||
CONSUMER_RECURSIVE=True,
|
CONSUMER_RECURSIVE=True,
|
||||||
CONSUMER_ENABLE_COLLATE_DOUBLE_SIDED=True,
|
CONSUMER_ENABLE_COLLATE_DOUBLE_SIDED=True,
|
||||||
)
|
)
|
||||||
class TestDoubleSided(DirectoriesMixin, FileSystemAssertsMixin, TestCase):
|
class TestDoubleSided(
|
||||||
SAMPLE_DIR = Path(__file__).parent / "samples"
|
DirectoriesMixin,
|
||||||
|
FileSystemAssertsMixin,
|
||||||
|
SampleDirMixin,
|
||||||
|
TestCase,
|
||||||
|
):
|
||||||
def setUp(self) -> None:
|
def setUp(self) -> None:
|
||||||
super().setUp()
|
super().setUp()
|
||||||
self.dirs.double_sided_dir = self.dirs.consumption_dir / "double-sided"
|
self.double_sided_dir = self.dirs.consumption_dir / "double-sided"
|
||||||
self.dirs.double_sided_dir.mkdir()
|
self.double_sided_dir.mkdir()
|
||||||
self.staging_file = self.dirs.scratch_dir / STAGING_FILE_NAME
|
self.staging_file = self.dirs.scratch_dir / STAGING_FILE_NAME
|
||||||
|
|
||||||
def consume_file(self, srcname, dstname: str | Path = "foo.pdf"):
|
def consume_file(self, srcname, dstname: str | Path = "foo.pdf"):
|
||||||
@@ -39,20 +43,16 @@ class TestDoubleSided(DirectoriesMixin, FileSystemAssertsMixin, TestCase):
|
|||||||
destination file does not exist afterwards
|
destination file does not exist afterwards
|
||||||
"""
|
"""
|
||||||
src = self.SAMPLE_DIR / srcname
|
src = self.SAMPLE_DIR / srcname
|
||||||
dst = self.dirs.double_sided_dir / dstname
|
dst = self.double_sided_dir / dstname
|
||||||
dst.parent.mkdir(parents=True, exist_ok=True)
|
dst.parent.mkdir(parents=True, exist_ok=True)
|
||||||
shutil.copy(src, dst)
|
shutil.copy(src, dst)
|
||||||
with mock.patch(
|
msg = tasks.consume_file(
|
||||||
"documents.tasks.ProgressManager",
|
ConsumableDocument(
|
||||||
DummyProgressManager,
|
source=DocumentSource.ConsumeFolder,
|
||||||
):
|
original_file=dst,
|
||||||
msg = tasks.consume_file(
|
),
|
||||||
ConsumableDocument(
|
None,
|
||||||
source=DocumentSource.ConsumeFolder,
|
)
|
||||||
original_file=dst,
|
|
||||||
),
|
|
||||||
None,
|
|
||||||
)
|
|
||||||
self.assertIsNotFile(dst)
|
self.assertIsNotFile(dst)
|
||||||
return msg
|
return msg
|
||||||
|
|
||||||
@@ -214,31 +214,41 @@ class TestDoubleSided(DirectoriesMixin, FileSystemAssertsMixin, TestCase):
|
|||||||
self.assertIsNotFile(self.staging_file)
|
self.assertIsNotFile(self.staging_file)
|
||||||
self.assertIsInstance(msg.get("document_id"), int)
|
self.assertIsInstance(msg.get("document_id"), int)
|
||||||
|
|
||||||
def test_subdirectory_upload(self) -> None:
|
def test_consume_double_sided_in_nested_dir(self) -> None:
|
||||||
"""
|
"""
|
||||||
GIVEN:
|
GIVEN:
|
||||||
- A staging file exists
|
- A staging file exists
|
||||||
WHEN:
|
WHEN:
|
||||||
- A file gets uploaded into foo/bar/double-sided
|
- A file is uploaded into foo/bar/double-sided
|
||||||
or double-sided/foo/bar
|
|
||||||
THEN:
|
THEN:
|
||||||
- The collated file gets put into foo/bar
|
- The collated file is placed into foo/bar
|
||||||
"""
|
"""
|
||||||
# TODO: parameterize this instead
|
self.create_staging_file()
|
||||||
for path in [
|
self.consume_file(
|
||||||
Path("foo") / "bar" / "double-sided",
|
"double-sided-odd.pdf",
|
||||||
Path("double-sided") / "foo" / "bar",
|
Path("foo") / "bar" / "double-sided" / "foo.pdf",
|
||||||
]:
|
)
|
||||||
with self.subTest(path=str(path)):
|
self.assertIsFile(
|
||||||
# Ensure we get fresh directories for each run
|
self.dirs.consumption_dir / "foo" / "bar" / "foo-collated.pdf",
|
||||||
self.tearDown()
|
)
|
||||||
self.setUp()
|
|
||||||
|
|
||||||
self.create_staging_file()
|
def test_consume_double_sided_with_nested_subdir(self) -> None:
|
||||||
self.consume_file("double-sided-odd.pdf", Path(path) / "foo.pdf")
|
"""
|
||||||
self.assertIsFile(
|
GIVEN:
|
||||||
self.dirs.consumption_dir / "foo" / "bar" / "foo-collated.pdf",
|
- A staging file exists
|
||||||
)
|
WHEN:
|
||||||
|
- A file is uploaded into double-sided/foo/bar
|
||||||
|
THEN:
|
||||||
|
- The collated file is placed into foo/bar
|
||||||
|
"""
|
||||||
|
self.create_staging_file()
|
||||||
|
self.consume_file(
|
||||||
|
"double-sided-odd.pdf",
|
||||||
|
Path("double-sided") / "foo" / "bar" / "foo.pdf",
|
||||||
|
)
|
||||||
|
self.assertIsFile(
|
||||||
|
self.dirs.consumption_dir / "foo" / "bar" / "foo-collated.pdf",
|
||||||
|
)
|
||||||
|
|
||||||
@override_settings(CONSUMER_ENABLE_COLLATE_DOUBLE_SIDED=False)
|
@override_settings(CONSUMER_ENABLE_COLLATE_DOUBLE_SIDED=False)
|
||||||
def test_disabled_double_sided_dir_upload(self) -> None:
|
def test_disabled_double_sided_dir_upload(self) -> None:
|
||||||
|
|||||||
@@ -8,7 +8,6 @@ from unittest import mock
|
|||||||
import pytest
|
import pytest
|
||||||
from auditlog.context import disable_auditlog
|
from auditlog.context import disable_auditlog
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.contrib.auth.models import User
|
|
||||||
from django.db import DatabaseError
|
from django.db import DatabaseError
|
||||||
from django.db import connection
|
from django.db import connection
|
||||||
from django.test import TestCase
|
from django.test import TestCase
|
||||||
@@ -30,9 +29,10 @@ from documents.models import DocumentType
|
|||||||
from documents.models import StoragePath
|
from documents.models import StoragePath
|
||||||
from documents.serialisers import DocumentSerializer
|
from documents.serialisers import DocumentSerializer
|
||||||
from documents.tasks import empty_trash
|
from documents.tasks import empty_trash
|
||||||
from documents.tests.factories import DocumentFactory
|
from paperless_testing.assertions import FileSystemAssertsMixin
|
||||||
from documents.tests.utils import DirectoriesMixin
|
from paperless_testing.dirs import DirectoriesMixin
|
||||||
from documents.tests.utils import FileSystemAssertsMixin
|
from paperless_testing.factories import DocumentFactory
|
||||||
|
from paperless_testing.factories import UserFactory
|
||||||
|
|
||||||
|
|
||||||
class TestFileHandling(DirectoriesMixin, FileSystemAssertsMixin, TestCase):
|
class TestFileHandling(DirectoriesMixin, FileSystemAssertsMixin, TestCase):
|
||||||
@@ -1323,7 +1323,7 @@ class TestFilenameGeneration(DirectoriesMixin, TestCase):
|
|||||||
- Document without owner returns "none"
|
- Document without owner returns "none"
|
||||||
"""
|
"""
|
||||||
|
|
||||||
u1 = User.objects.create_user("user1")
|
u1 = UserFactory(username="user1")
|
||||||
|
|
||||||
owned_doc = Document.objects.create(
|
owned_doc = Document.objects.create(
|
||||||
title="The Title",
|
title="The Title",
|
||||||
|
|||||||
@@ -13,10 +13,10 @@ import pytest
|
|||||||
from documents.file_handling import generate_filename
|
from documents.file_handling import generate_filename
|
||||||
from documents.models import CustomField
|
from documents.models import CustomField
|
||||||
from documents.models import CustomFieldInstance
|
from documents.models import CustomFieldInstance
|
||||||
from documents.tests.factories import CorrespondentFactory
|
from paperless_testing.factories import CorrespondentFactory
|
||||||
from documents.tests.factories import DocumentFactory
|
from paperless_testing.factories import DocumentFactory
|
||||||
from documents.tests.factories import StoragePathFactory
|
from paperless_testing.factories import StoragePathFactory
|
||||||
from documents.tests.factories import TagFactory
|
from paperless_testing.factories import TagFactory
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.django_db
|
@pytest.mark.django_db
|
||||||
|
|||||||
@@ -20,8 +20,8 @@ if TYPE_CHECKING:
|
|||||||
from documents.file_handling import generate_filename
|
from documents.file_handling import generate_filename
|
||||||
from documents.models import Document
|
from documents.models import Document
|
||||||
from documents.tasks import update_document_content_maybe_archive_file
|
from documents.tasks import update_document_content_maybe_archive_file
|
||||||
from documents.tests.utils import DirectoriesMixin
|
from paperless_testing.assertions import FileSystemAssertsMixin
|
||||||
from documents.tests.utils import FileSystemAssertsMixin
|
from paperless_testing.dirs import DirectoriesMixin
|
||||||
|
|
||||||
sample_file: Path = Path(__file__).parent / "samples" / "simple.pdf"
|
sample_file: Path = Path(__file__).parent / "samples" / "simple.pdf"
|
||||||
|
|
||||||
|
|||||||
@@ -26,7 +26,6 @@ from django.test import override_settings
|
|||||||
from django.utils import timezone
|
from django.utils import timezone
|
||||||
from guardian.models import GroupObjectPermission
|
from guardian.models import GroupObjectPermission
|
||||||
from guardian.models import UserObjectPermission
|
from guardian.models import UserObjectPermission
|
||||||
from guardian.shortcuts import assign_perm
|
|
||||||
|
|
||||||
from documents.management.commands import document_exporter
|
from documents.management.commands import document_exporter
|
||||||
from documents.models import Correspondent
|
from documents.models import Correspondent
|
||||||
@@ -46,11 +45,12 @@ from documents.models import WorkflowTrigger
|
|||||||
from documents.sanity_checker import check_sanity
|
from documents.sanity_checker import check_sanity
|
||||||
from documents.settings import EXPORTER_FILE_NAME
|
from documents.settings import EXPORTER_FILE_NAME
|
||||||
from documents.settings import EXPORTER_SHARE_LINK_BUNDLE_NAME
|
from documents.settings import EXPORTER_SHARE_LINK_BUNDLE_NAME
|
||||||
from documents.tests.utils import DirectoriesMixin
|
|
||||||
from documents.tests.utils import FileSystemAssertsMixin
|
|
||||||
from documents.tests.utils import SampleDirMixin
|
from documents.tests.utils import SampleDirMixin
|
||||||
from documents.tests.utils import paperless_environment
|
|
||||||
from paperless_mail.models import MailAccount
|
from paperless_mail.models import MailAccount
|
||||||
|
from paperless_testing.assertions import FileSystemAssertsMixin
|
||||||
|
from paperless_testing.dirs import DirectoriesMixin
|
||||||
|
from paperless_testing.dirs import paperless_environment
|
||||||
|
from paperless_testing.permissions import grant_object
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.management
|
@pytest.mark.management
|
||||||
@@ -105,8 +105,8 @@ class TestExportImport(
|
|||||||
user=self.user,
|
user=self.user,
|
||||||
)
|
)
|
||||||
|
|
||||||
assign_perm("view_document", self.user2, self.d2)
|
grant_object(self.user2, self.d2, "view_document")
|
||||||
assign_perm("view_document", self.group1, self.d3)
|
grant_object(self.group1, self.d3, "view_document")
|
||||||
|
|
||||||
self.t1 = Tag.objects.create(name="t")
|
self.t1 = Tag.objects.create(name="t")
|
||||||
self.dt1 = DocumentType.objects.create(name="dt")
|
self.dt1 = DocumentType.objects.create(name="dt")
|
||||||
@@ -677,12 +677,13 @@ class TestExportImport(
|
|||||||
THEN:
|
THEN:
|
||||||
- Error is raised
|
- Error is raised
|
||||||
"""
|
"""
|
||||||
args = ["document_exporter", "/tmp/foo/bar"]
|
with tempfile.TemporaryDirectory() as tmp_dir:
|
||||||
|
args = ["document_exporter", str(Path(tmp_dir) / "does-not-exist")]
|
||||||
|
|
||||||
with self.assertRaises(CommandError) as e:
|
with self.assertRaises(CommandError) as e:
|
||||||
call_command(*args, skip_checks=True)
|
call_command(*args, skip_checks=True)
|
||||||
|
|
||||||
self.assertEqual("That path doesn't exist", str(e.exception))
|
self.assertEqual("That path doesn't exist", str(e.exception))
|
||||||
|
|
||||||
def test_export_target_exists_but_is_file(self) -> None:
|
def test_export_target_exists_but_is_file(self) -> None:
|
||||||
"""
|
"""
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ from django.core.management import call_command
|
|||||||
from django.test import TestCase
|
from django.test import TestCase
|
||||||
|
|
||||||
from documents.models import Document
|
from documents.models import Document
|
||||||
from documents.tests.factories import DocumentFactory
|
from paperless_testing.factories import DocumentFactory
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.management
|
@pytest.mark.management
|
||||||
@@ -123,14 +123,14 @@ class TestFuzzyMatchCommand(TestCase):
|
|||||||
- Output contains clickable links to the documents instead of titles
|
- Output contains clickable links to the documents instead of titles
|
||||||
"""
|
"""
|
||||||
# Content similarity is 86.667
|
# Content similarity is 86.667
|
||||||
Document.objects.create(
|
doc1 = Document.objects.create(
|
||||||
checksum="BEEFCAFE",
|
checksum="BEEFCAFE",
|
||||||
title="A",
|
title="A",
|
||||||
content="first document scanned by bob",
|
content="first document scanned by bob",
|
||||||
mime_type="application/pdf",
|
mime_type="application/pdf",
|
||||||
filename="test.pdf",
|
filename="test.pdf",
|
||||||
)
|
)
|
||||||
Document.objects.create(
|
doc2 = Document.objects.create(
|
||||||
checksum="DEADBEAF",
|
checksum="DEADBEAF",
|
||||||
title="A",
|
title="A",
|
||||||
content="first document scanned by alice",
|
content="first document scanned by alice",
|
||||||
@@ -145,8 +145,8 @@ class TestFuzzyMatchCommand(TestCase):
|
|||||||
"http://localhost:8000",
|
"http://localhost:8000",
|
||||||
)
|
)
|
||||||
self.assertIn("Found 1 matching pair(s)", stdout)
|
self.assertIn("Found 1 matching pair(s)", stdout)
|
||||||
self.assertIn("http://localhost:8000/documents/1/details", stdout)
|
self.assertIn(f"http://localhost:8000/documents/{doc1.pk}/details", stdout)
|
||||||
self.assertIn("http://localhost:8000/documents/2/details", stdout)
|
self.assertIn(f"http://localhost:8000/documents/{doc2.pk}/details", stdout)
|
||||||
|
|
||||||
def test_with_3_matches(self) -> None:
|
def test_with_3_matches(self) -> None:
|
||||||
"""
|
"""
|
||||||
@@ -198,14 +198,14 @@ class TestFuzzyMatchCommand(TestCase):
|
|||||||
- Documents 1 and 2 remain
|
- Documents 1 and 2 remain
|
||||||
"""
|
"""
|
||||||
# Content similarity is 86.667
|
# Content similarity is 86.667
|
||||||
Document.objects.create(
|
doc1 = Document.objects.create(
|
||||||
checksum="BEEFCAFE",
|
checksum="BEEFCAFE",
|
||||||
title="A",
|
title="A",
|
||||||
content="first document scanned by bob",
|
content="first document scanned by bob",
|
||||||
mime_type="application/pdf",
|
mime_type="application/pdf",
|
||||||
filename="test.pdf",
|
filename="test.pdf",
|
||||||
)
|
)
|
||||||
Document.objects.create(
|
doc2 = Document.objects.create(
|
||||||
checksum="DEADBEAF",
|
checksum="DEADBEAF",
|
||||||
title="A",
|
title="A",
|
||||||
content="second document scanned by alice",
|
content="second document scanned by alice",
|
||||||
@@ -235,8 +235,8 @@ class TestFuzzyMatchCommand(TestCase):
|
|||||||
self.assertIn("Deleting 1 document(s)", stdout)
|
self.assertIn("Deleting 1 document(s)", stdout)
|
||||||
|
|
||||||
self.assertEqual(Document.objects.count(), 2)
|
self.assertEqual(Document.objects.count(), 2)
|
||||||
self.assertIsNotNone(Document.objects.get(pk=1))
|
self.assertIsNotNone(Document.objects.get(pk=doc1.pk))
|
||||||
self.assertIsNotNone(Document.objects.get(pk=2))
|
self.assertIsNotNone(Document.objects.get(pk=doc2.pk))
|
||||||
|
|
||||||
def test_document_deletion_cancelled(self) -> None:
|
def test_document_deletion_cancelled(self) -> None:
|
||||||
"""
|
"""
|
||||||
|
|||||||
@@ -15,9 +15,9 @@ from documents.management.commands.document_importer import _deserialize_record
|
|||||||
from documents.models import Document
|
from documents.models import Document
|
||||||
from documents.settings import EXPORTER_ARCHIVE_NAME
|
from documents.settings import EXPORTER_ARCHIVE_NAME
|
||||||
from documents.settings import EXPORTER_FILE_NAME
|
from documents.settings import EXPORTER_FILE_NAME
|
||||||
from documents.tests.utils import DirectoriesMixin
|
|
||||||
from documents.tests.utils import FileSystemAssertsMixin
|
|
||||||
from documents.tests.utils import SampleDirMixin
|
from documents.tests.utils import SampleDirMixin
|
||||||
|
from paperless_testing.assertions import FileSystemAssertsMixin
|
||||||
|
from paperless_testing.dirs import DirectoriesMixin
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.management
|
@pytest.mark.management
|
||||||
|
|||||||
@@ -14,12 +14,12 @@ from documents.models import DocumentType
|
|||||||
from documents.models import MatchingModel
|
from documents.models import MatchingModel
|
||||||
from documents.models import StoragePath
|
from documents.models import StoragePath
|
||||||
from documents.models import Tag
|
from documents.models import Tag
|
||||||
from documents.tests.factories import CorrespondentFactory
|
from paperless_testing.dirs import DirectoriesMixin
|
||||||
from documents.tests.factories import DocumentFactory
|
from paperless_testing.factories import CorrespondentFactory
|
||||||
from documents.tests.factories import DocumentTypeFactory
|
from paperless_testing.factories import DocumentFactory
|
||||||
from documents.tests.factories import StoragePathFactory
|
from paperless_testing.factories import DocumentTypeFactory
|
||||||
from documents.tests.factories import TagFactory
|
from paperless_testing.factories import StoragePathFactory
|
||||||
from documents.tests.utils import DirectoriesMixin
|
from paperless_testing.factories import TagFactory
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Module-level type aliases
|
# Module-level type aliases
|
||||||
|
|||||||
@@ -7,14 +7,14 @@ from django.contrib.auth.models import User
|
|||||||
from django.core.management import call_command
|
from django.core.management import call_command
|
||||||
from django.test import TestCase
|
from django.test import TestCase
|
||||||
|
|
||||||
from documents.tests.utils import DirectoriesMixin
|
from paperless_testing.dirs import DirectoriesMixin
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.management
|
@pytest.mark.management
|
||||||
class TestManageSuperUser(DirectoriesMixin, TestCase):
|
class TestManageSuperUser(DirectoriesMixin, TestCase):
|
||||||
def call_command(self, environ):
|
def call_command(self, environ):
|
||||||
out = StringIO()
|
out = StringIO()
|
||||||
with mock.patch.dict(os.environ, environ):
|
with mock.patch.dict(os.environ, environ, clear=True):
|
||||||
call_command(
|
call_command(
|
||||||
"manage_superuser",
|
"manage_superuser",
|
||||||
"--no-color",
|
"--no-color",
|
||||||
|
|||||||
@@ -9,8 +9,8 @@ from django.test import TestCase
|
|||||||
from documents.management.commands.document_thumbnails import _process_document
|
from documents.management.commands.document_thumbnails import _process_document
|
||||||
from documents.models import Document
|
from documents.models import Document
|
||||||
from documents.parsers import get_default_thumbnail
|
from documents.parsers import get_default_thumbnail
|
||||||
from documents.tests.utils import DirectoriesMixin
|
from paperless_testing.assertions import FileSystemAssertsMixin
|
||||||
from documents.tests.utils import FileSystemAssertsMixin
|
from paperless_testing.dirs import DirectoriesMixin
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.management
|
@pytest.mark.management
|
||||||
|
|||||||
@@ -7,10 +7,10 @@ from documents import matching
|
|||||||
from documents.models import Document
|
from documents.models import Document
|
||||||
from documents.models import MatchingModel
|
from documents.models import MatchingModel
|
||||||
from documents.signals import document_consumption_finished
|
from documents.signals import document_consumption_finished
|
||||||
from documents.tests.factories import CorrespondentFactory
|
from paperless_testing.factories import CorrespondentFactory
|
||||||
from documents.tests.factories import DocumentFactory
|
from paperless_testing.factories import DocumentFactory
|
||||||
from documents.tests.factories import DocumentTypeFactory
|
from paperless_testing.factories import DocumentTypeFactory
|
||||||
from documents.tests.factories import TagFactory
|
from paperless_testing.factories import TagFactory
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture(
|
@pytest.fixture(
|
||||||
|
|||||||
@@ -2,8 +2,6 @@ import json
|
|||||||
from unittest import mock
|
from unittest import mock
|
||||||
|
|
||||||
from auditlog.models import LogEntry
|
from auditlog.models import LogEntry
|
||||||
from django.contrib.auth.models import Permission
|
|
||||||
from django.contrib.auth.models import User
|
|
||||||
from django.contrib.contenttypes.models import ContentType
|
from django.contrib.contenttypes.models import ContentType
|
||||||
from django.test import TestCase
|
from django.test import TestCase
|
||||||
from rest_framework import status
|
from rest_framework import status
|
||||||
@@ -12,6 +10,8 @@ from rest_framework.test import APITestCase
|
|||||||
from documents.bulk_edit import merge_as_versions
|
from documents.bulk_edit import merge_as_versions
|
||||||
from documents.models import Document
|
from documents.models import Document
|
||||||
from documents.serialisers import MergeDocumentsAsVersionsSerializer
|
from documents.serialisers import MergeDocumentsAsVersionsSerializer
|
||||||
|
from paperless_testing.factories import UserFactory
|
||||||
|
from paperless_testing.permissions import grant_global
|
||||||
|
|
||||||
|
|
||||||
class TestMergeDocumentsAsVersionsSerializer(TestCase):
|
class TestMergeDocumentsAsVersionsSerializer(TestCase):
|
||||||
@@ -294,7 +294,7 @@ class TestMergeDocumentsAsVersions(TestCase):
|
|||||||
@mock.patch("documents.bulk_edit.bulk_update_documents.apply_async")
|
@mock.patch("documents.bulk_edit.bulk_update_documents.apply_async")
|
||||||
@mock.patch("documents.search.get_backend")
|
@mock.patch("documents.search.get_backend")
|
||||||
def test_writes_audit_log_entry(self, *_mocks) -> None:
|
def test_writes_audit_log_entry(self, *_mocks) -> None:
|
||||||
user = User.objects.create_user(username="merger")
|
user = UserFactory(username="merger")
|
||||||
root = Document.objects.create(checksum="A", title="Root")
|
root = Document.objects.create(checksum="A", title="Root")
|
||||||
source = Document.objects.create(checksum="B", title="Source")
|
source = Document.objects.create(checksum="B", title="Source")
|
||||||
LogEntry.objects.all().delete()
|
LogEntry.objects.all().delete()
|
||||||
@@ -335,12 +335,8 @@ class TestMergeDocumentsAsVersions(TestCase):
|
|||||||
|
|
||||||
class TestMergeDocumentsAsVersionsAPI(APITestCase):
|
class TestMergeDocumentsAsVersionsAPI(APITestCase):
|
||||||
def setUp(self) -> None:
|
def setUp(self) -> None:
|
||||||
self.user = User.objects.create_user(username="user")
|
self.user = UserFactory(username="user")
|
||||||
self.user.user_permissions.add(
|
grant_global(self.user, "change_document", "view_document", "delete_document")
|
||||||
Permission.objects.get(codename="change_document"),
|
|
||||||
Permission.objects.get(codename="view_document"),
|
|
||||||
Permission.objects.get(codename="delete_document"),
|
|
||||||
)
|
|
||||||
self.doc1 = Document.objects.create(
|
self.doc1 = Document.objects.create(
|
||||||
checksum="A",
|
checksum="A",
|
||||||
title="A",
|
title="A",
|
||||||
@@ -382,7 +378,7 @@ class TestMergeDocumentsAsVersionsAPI(APITestCase):
|
|||||||
@mock.patch("documents.views.bulk_edit.merge_as_versions")
|
@mock.patch("documents.views.bulk_edit.merge_as_versions")
|
||||||
def test_requires_change_permission(self, merge_mock) -> None:
|
def test_requires_change_permission(self, merge_mock) -> None:
|
||||||
merge_mock.__name__ = "merge_as_versions"
|
merge_mock.__name__ = "merge_as_versions"
|
||||||
user = User.objects.create_user(username="no-change")
|
user = UserFactory(username="no-change")
|
||||||
self.doc1.owner = user
|
self.doc1.owner = user
|
||||||
self.doc1.save()
|
self.doc1.save()
|
||||||
self.doc2.owner = user
|
self.doc2.owner = user
|
||||||
@@ -405,11 +401,8 @@ class TestMergeDocumentsAsVersionsAPI(APITestCase):
|
|||||||
def test_requires_delete_permission(self, merge_mock) -> None:
|
def test_requires_delete_permission(self, merge_mock) -> None:
|
||||||
merge_mock.__name__ = "merge_as_versions"
|
merge_mock.__name__ = "merge_as_versions"
|
||||||
# Owns them and may change them, but may not make them stop being documents
|
# Owns them and may change them, but may not make them stop being documents
|
||||||
user = User.objects.create_user(username="no-delete")
|
user = UserFactory(username="no-delete")
|
||||||
user.user_permissions.add(
|
grant_global(user, "change_document", "view_document")
|
||||||
Permission.objects.get(codename="change_document"),
|
|
||||||
Permission.objects.get(codename="view_document"),
|
|
||||||
)
|
|
||||||
for doc in (self.doc1, self.doc2):
|
for doc in (self.doc1, self.doc2):
|
||||||
doc.owner = user
|
doc.owner = user
|
||||||
doc.save()
|
doc.save()
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
from documents.tests.utils import TestMigrations
|
from paperless_testing.migrations import TestMigrations
|
||||||
|
|
||||||
SAVED_VIEWS_KEY = "saved_views"
|
SAVED_VIEWS_KEY = "saved_views"
|
||||||
DASHBOARD_VIEWS_VISIBLE_IDS_KEY = "dashboard_views_visible_ids"
|
DASHBOARD_VIEWS_VISIBLE_IDS_KEY = "dashboard_views_visible_ids"
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ from django.conf import settings
|
|||||||
from django.db import connection
|
from django.db import connection
|
||||||
from django.test import override_settings
|
from django.test import override_settings
|
||||||
|
|
||||||
from documents.tests.utils import TestMigrations
|
from paperless_testing.migrations import TestMigrations
|
||||||
|
|
||||||
|
|
||||||
def _sha256(data: bytes) -> str:
|
def _sha256(data: bytes) -> str:
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
from documents.tests.utils import TestMigrations
|
from paperless_testing.migrations import TestMigrations
|
||||||
|
|
||||||
|
|
||||||
class TestMigrateShareLinkBundlePermissions(TestMigrations):
|
class TestMigrateShareLinkBundlePermissions(TestMigrations):
|
||||||
|
|||||||
@@ -2,8 +2,8 @@ import pytest
|
|||||||
|
|
||||||
from documents.models import Correspondent
|
from documents.models import Correspondent
|
||||||
from documents.models import Document
|
from documents.models import Document
|
||||||
from documents.tests.factories import CorrespondentFactory
|
from paperless_testing.factories import CorrespondentFactory
|
||||||
from documents.tests.factories import DocumentFactory
|
from paperless_testing.factories import DocumentFactory
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.django_db
|
@pytest.mark.django_db
|
||||||
|
|||||||
@@ -1,15 +1,13 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from http import HTTPStatus
|
from http import HTTPStatus
|
||||||
|
from typing import TYPE_CHECKING
|
||||||
from unittest.mock import patch
|
from unittest.mock import patch
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
from django.contrib.auth.models import AnonymousUser
|
from django.contrib.auth.models import AnonymousUser
|
||||||
from django.contrib.auth.models import Group
|
from django.contrib.auth.models import Group
|
||||||
from django.contrib.auth.models import Permission
|
|
||||||
from django.contrib.auth.models import User
|
|
||||||
from django.test import override_settings
|
from django.test import override_settings
|
||||||
from guardian.shortcuts import assign_perm
|
|
||||||
from rest_framework.test import APIClient
|
from rest_framework.test import APIClient
|
||||||
|
|
||||||
from documents.matching import match_correspondents
|
from documents.matching import match_correspondents
|
||||||
@@ -24,11 +22,17 @@ from documents.permissions import permitted_document_ids
|
|||||||
from documents.permissions import permitted_object_ids
|
from documents.permissions import permitted_object_ids
|
||||||
from documents.permissions import restrict_queryset_to_visible
|
from documents.permissions import restrict_queryset_to_visible
|
||||||
from documents.serialisers import _get_viewable_duplicates
|
from documents.serialisers import _get_viewable_duplicates
|
||||||
from documents.tests.factories import CorrespondentFactory
|
from paperless_testing.factories import CorrespondentFactory
|
||||||
from documents.tests.factories import DocumentFactory
|
from paperless_testing.factories import DocumentFactory
|
||||||
from documents.tests.factories import DocumentTypeFactory
|
from paperless_testing.factories import DocumentTypeFactory
|
||||||
from documents.tests.factories import StoragePathFactory
|
from paperless_testing.factories import StoragePathFactory
|
||||||
from documents.tests.factories import TagFactory
|
from paperless_testing.factories import TagFactory
|
||||||
|
from paperless_testing.factories import UserFactory
|
||||||
|
from paperless_testing.permissions import grant_global
|
||||||
|
from paperless_testing.permissions import grant_object
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from paperless_testing.dirs import PaperlessDirs
|
||||||
|
|
||||||
|
|
||||||
def assert_visible_document_ids(actual_ids, *, expected_visible, expected_hidden):
|
def assert_visible_document_ids(actual_ids, *, expected_visible, expected_hidden):
|
||||||
@@ -47,8 +51,8 @@ def assert_visible_document_ids(actual_ids, *, expected_visible, expected_hidden
|
|||||||
@pytest.mark.django_db
|
@pytest.mark.django_db
|
||||||
class TestPermittedDocumentIdsSecurity:
|
class TestPermittedDocumentIdsSecurity:
|
||||||
def test_owner_sees_own_document(self):
|
def test_owner_sees_own_document(self):
|
||||||
user = User.objects.create_user(username="alice")
|
user = UserFactory(username="alice")
|
||||||
stranger = User.objects.create_user(username="mallory")
|
stranger = UserFactory(username="mallory")
|
||||||
owned = DocumentFactory(owner=user)
|
owned = DocumentFactory(owner=user)
|
||||||
strangers_doc = DocumentFactory(owner=stranger)
|
strangers_doc = DocumentFactory(owner=stranger)
|
||||||
|
|
||||||
@@ -61,7 +65,7 @@ class TestPermittedDocumentIdsSecurity:
|
|||||||
)
|
)
|
||||||
|
|
||||||
def test_unowned_document_visible_to_everyone(self):
|
def test_unowned_document_visible_to_everyone(self):
|
||||||
user = User.objects.create_user(username="alice")
|
user = UserFactory(username="alice")
|
||||||
unowned = DocumentFactory(owner=None)
|
unowned = DocumentFactory(owner=None)
|
||||||
|
|
||||||
assert_visible_document_ids(
|
assert_visible_document_ids(
|
||||||
@@ -71,12 +75,12 @@ class TestPermittedDocumentIdsSecurity:
|
|||||||
)
|
)
|
||||||
|
|
||||||
def test_explicit_user_permission_grants_visibility(self):
|
def test_explicit_user_permission_grants_visibility(self):
|
||||||
grantee = User.objects.create_user(username="alice")
|
grantee = UserFactory(username="alice")
|
||||||
stranger = User.objects.create_user(username="mallory")
|
stranger = UserFactory(username="mallory")
|
||||||
owner = User.objects.create_user(username="owner")
|
owner = UserFactory(username="owner")
|
||||||
shared = DocumentFactory(owner=owner)
|
shared = DocumentFactory(owner=owner)
|
||||||
not_shared = DocumentFactory(owner=owner)
|
not_shared = DocumentFactory(owner=owner)
|
||||||
assign_perm("view_document", grantee, shared)
|
grant_object(grantee, shared, "view_document")
|
||||||
|
|
||||||
assert_visible_document_ids(
|
assert_visible_document_ids(
|
||||||
permitted_document_ids(grantee),
|
permitted_document_ids(grantee),
|
||||||
@@ -90,13 +94,13 @@ class TestPermittedDocumentIdsSecurity:
|
|||||||
)
|
)
|
||||||
|
|
||||||
def test_explicit_group_permission_grants_visibility_to_members_only(self):
|
def test_explicit_group_permission_grants_visibility_to_members_only(self):
|
||||||
owner = User.objects.create_user(username="owner")
|
owner = UserFactory(username="owner")
|
||||||
member = User.objects.create_user(username="member")
|
member = UserFactory(username="member")
|
||||||
non_member = User.objects.create_user(username="non_member")
|
non_member = UserFactory(username="non_member")
|
||||||
group = Group.objects.create(name="finance")
|
group = Group.objects.create(name="finance")
|
||||||
member.groups.add(group)
|
member.groups.add(group)
|
||||||
shared = DocumentFactory(owner=owner)
|
shared = DocumentFactory(owner=owner)
|
||||||
assign_perm("view_document", group, shared)
|
grant_object(group, shared, "view_document")
|
||||||
|
|
||||||
assert_visible_document_ids(
|
assert_visible_document_ids(
|
||||||
permitted_document_ids(member),
|
permitted_document_ids(member),
|
||||||
@@ -110,7 +114,7 @@ class TestPermittedDocumentIdsSecurity:
|
|||||||
)
|
)
|
||||||
|
|
||||||
def test_soft_deleted_document_excluded_by_default(self):
|
def test_soft_deleted_document_excluded_by_default(self):
|
||||||
owner = User.objects.create_user(username="owner")
|
owner = UserFactory(username="owner")
|
||||||
doc = DocumentFactory(owner=owner)
|
doc = DocumentFactory(owner=owner)
|
||||||
doc.delete() # soft delete
|
doc.delete() # soft delete
|
||||||
doc.refresh_from_db()
|
doc.refresh_from_db()
|
||||||
@@ -126,8 +130,8 @@ class TestPermittedDocumentIdsSecurity:
|
|||||||
)
|
)
|
||||||
|
|
||||||
def test_superuser_sees_everything_including_no_perm_documents(self):
|
def test_superuser_sees_everything_including_no_perm_documents(self):
|
||||||
superuser = User.objects.create_superuser(username="root")
|
superuser = UserFactory(username="root", superuser=True)
|
||||||
owner = User.objects.create_user(username="owner")
|
owner = UserFactory(username="owner")
|
||||||
doc = DocumentFactory(owner=owner)
|
doc = DocumentFactory(owner=owner)
|
||||||
|
|
||||||
assert_visible_document_ids(
|
assert_visible_document_ids(
|
||||||
@@ -137,7 +141,7 @@ class TestPermittedDocumentIdsSecurity:
|
|||||||
)
|
)
|
||||||
|
|
||||||
def test_anonymous_user_sees_only_unowned_documents(self):
|
def test_anonymous_user_sees_only_unowned_documents(self):
|
||||||
owner = User.objects.create_user(username="owner")
|
owner = UserFactory(username="owner")
|
||||||
owned = DocumentFactory(owner=owner)
|
owned = DocumentFactory(owner=owner)
|
||||||
unowned = DocumentFactory(owner=None)
|
unowned = DocumentFactory(owner=None)
|
||||||
|
|
||||||
@@ -151,7 +155,7 @@ class TestPermittedDocumentIdsSecurity:
|
|||||||
@pytest.mark.django_db
|
@pytest.mark.django_db
|
||||||
class TestPermittedDocumentIdsIncludeDeleted:
|
class TestPermittedDocumentIdsIncludeDeleted:
|
||||||
def test_include_deleted_true_reveals_soft_deleted_owned_document(self):
|
def test_include_deleted_true_reveals_soft_deleted_owned_document(self):
|
||||||
owner = User.objects.create_user(username="owner")
|
owner = UserFactory(username="owner")
|
||||||
doc = DocumentFactory(owner=owner)
|
doc = DocumentFactory(owner=owner)
|
||||||
doc.delete()
|
doc.delete()
|
||||||
|
|
||||||
@@ -162,8 +166,8 @@ class TestPermittedDocumentIdsIncludeDeleted:
|
|||||||
)
|
)
|
||||||
|
|
||||||
def test_include_deleted_true_still_respects_permission_boundary(self):
|
def test_include_deleted_true_still_respects_permission_boundary(self):
|
||||||
owner = User.objects.create_user(username="owner")
|
owner = UserFactory(username="owner")
|
||||||
stranger = User.objects.create_user(username="mallory")
|
stranger = UserFactory(username="mallory")
|
||||||
doc = DocumentFactory(owner=owner)
|
doc = DocumentFactory(owner=owner)
|
||||||
doc.delete()
|
doc.delete()
|
||||||
|
|
||||||
@@ -191,14 +195,12 @@ class TestAiChatAllDocumentsPermissionBoundary:
|
|||||||
def test_chat_all_documents_excludes_unshared_document(self, mock_stream_chat):
|
def test_chat_all_documents_excludes_unshared_document(self, mock_stream_chat):
|
||||||
mock_stream_chat.return_value = iter([b"data"])
|
mock_stream_chat.return_value = iter([b"data"])
|
||||||
|
|
||||||
owner = User.objects.create_user(username="owner")
|
owner = UserFactory(username="owner")
|
||||||
asker = User.objects.create_user(username="asker")
|
asker = UserFactory(username="asker")
|
||||||
asker.user_permissions.add(
|
grant_global(asker, "view_document")
|
||||||
*Permission.objects.filter(codename="view_document"),
|
|
||||||
)
|
|
||||||
shared = DocumentFactory(owner=owner)
|
shared = DocumentFactory(owner=owner)
|
||||||
not_shared = DocumentFactory(owner=owner)
|
not_shared = DocumentFactory(owner=owner)
|
||||||
assign_perm("view_document", asker, shared)
|
grant_object(asker, shared, "view_document")
|
||||||
|
|
||||||
client = APIClient()
|
client = APIClient()
|
||||||
client.force_authenticate(user=asker)
|
client.force_authenticate(user=asker)
|
||||||
@@ -219,13 +221,13 @@ class TestAiChatAllDocumentsPermissionBoundary:
|
|||||||
@pytest.mark.django_db
|
@pytest.mark.django_db
|
||||||
class TestDuplicateDocumentsPermissionBoundary:
|
class TestDuplicateDocumentsPermissionBoundary:
|
||||||
def test_get_viewable_duplicates_includes_soft_deleted_but_respects_perms(self):
|
def test_get_viewable_duplicates_includes_soft_deleted_but_respects_perms(self):
|
||||||
owner = User.objects.create_user(username="owner")
|
owner = UserFactory(username="owner")
|
||||||
stranger = User.objects.create_user(username="mallory")
|
stranger = UserFactory(username="mallory")
|
||||||
original = DocumentFactory(owner=owner, checksum="dupe-checksum")
|
original = DocumentFactory(owner=owner, checksum="dupe-checksum")
|
||||||
dup_visible = DocumentFactory(owner=owner, checksum="dupe-checksum")
|
dup_visible = DocumentFactory(owner=owner, checksum="dupe-checksum")
|
||||||
dup_hidden = DocumentFactory(owner=owner, checksum="dupe-checksum")
|
dup_hidden = DocumentFactory(owner=owner, checksum="dupe-checksum")
|
||||||
dup_hidden.delete() # soft delete, should still be found (include_deleted=True)
|
dup_hidden.delete() # soft delete, should still be found (include_deleted=True)
|
||||||
assign_perm("view_document", stranger, dup_visible)
|
grant_object(stranger, dup_visible, "view_document")
|
||||||
|
|
||||||
result_owner = _get_viewable_duplicates(original, owner)
|
result_owner = _get_viewable_duplicates(original, owner)
|
||||||
assert {d.pk for d in result_owner} == {dup_visible.pk, dup_hidden.pk}
|
assert {d.pk for d in result_owner} == {dup_visible.pk, dup_hidden.pk}
|
||||||
@@ -237,13 +239,13 @@ class TestDuplicateDocumentsPermissionBoundary:
|
|||||||
@pytest.mark.django_db
|
@pytest.mark.django_db
|
||||||
class TestPermittedDocumentIdsArbitraryPermission:
|
class TestPermittedDocumentIdsArbitraryPermission:
|
||||||
def test_change_document_permission_is_distinct_from_view(self):
|
def test_change_document_permission_is_distinct_from_view(self):
|
||||||
owner = User.objects.create_user(username="owner")
|
owner = UserFactory(username="owner")
|
||||||
viewer_only = User.objects.create_user(username="viewer")
|
viewer_only = UserFactory(username="viewer")
|
||||||
editor = User.objects.create_user(username="editor")
|
editor = UserFactory(username="editor")
|
||||||
doc = DocumentFactory(owner=owner)
|
doc = DocumentFactory(owner=owner)
|
||||||
assign_perm("view_document", viewer_only, doc)
|
grant_object(viewer_only, doc, "view_document")
|
||||||
assign_perm("change_document", editor, doc)
|
grant_object(editor, doc, "change_document")
|
||||||
assign_perm("view_document", editor, doc)
|
grant_object(editor, doc, "view_document")
|
||||||
|
|
||||||
assert_visible_document_ids(
|
assert_visible_document_ids(
|
||||||
permitted_document_ids(editor, perm="change_document"),
|
permitted_document_ids(editor, perm="change_document"),
|
||||||
@@ -257,10 +259,10 @@ class TestPermittedDocumentIdsArbitraryPermission:
|
|||||||
)
|
)
|
||||||
|
|
||||||
def test_qualified_permission_string_is_normalized_to_codename(self):
|
def test_qualified_permission_string_is_normalized_to_codename(self):
|
||||||
owner = User.objects.create_user(username="owner")
|
owner = UserFactory(username="owner")
|
||||||
editor = User.objects.create_user(username="editor")
|
editor = UserFactory(username="editor")
|
||||||
doc = DocumentFactory(owner=owner)
|
doc = DocumentFactory(owner=owner)
|
||||||
assign_perm("change_document", editor, doc)
|
grant_object(editor, doc, "change_document")
|
||||||
|
|
||||||
assert_visible_document_ids(
|
assert_visible_document_ids(
|
||||||
permitted_document_ids(editor, perm="documents.change_document"),
|
permitted_document_ids(editor, perm="documents.change_document"),
|
||||||
@@ -269,11 +271,11 @@ class TestPermittedDocumentIdsArbitraryPermission:
|
|||||||
)
|
)
|
||||||
|
|
||||||
def test_delete_permission_with_include_deleted_for_trash_restore(self):
|
def test_delete_permission_with_include_deleted_for_trash_restore(self):
|
||||||
owner = User.objects.create_user(username="owner")
|
owner = UserFactory(username="owner")
|
||||||
stranger = User.objects.create_user(username="mallory")
|
stranger = UserFactory(username="mallory")
|
||||||
view_only = User.objects.create_user(username="viewer")
|
view_only = UserFactory(username="viewer")
|
||||||
doc = DocumentFactory(owner=owner)
|
doc = DocumentFactory(owner=owner)
|
||||||
assign_perm("view_document", view_only, doc)
|
grant_object(view_only, doc, "view_document")
|
||||||
doc.delete()
|
doc.delete()
|
||||||
|
|
||||||
assert_visible_document_ids(
|
assert_visible_document_ids(
|
||||||
@@ -307,11 +309,9 @@ class TestEmailDocumentPermissionBoundary:
|
|||||||
self,
|
self,
|
||||||
rest_api_client,
|
rest_api_client,
|
||||||
):
|
):
|
||||||
owner = User.objects.create_user(username="owner")
|
owner = UserFactory(username="owner")
|
||||||
requester = User.objects.create_user(username="requester")
|
requester = UserFactory(username="requester")
|
||||||
requester.user_permissions.add(
|
grant_global(requester, "view_document")
|
||||||
Permission.objects.get(codename="view_document"),
|
|
||||||
)
|
|
||||||
rest_api_client.force_authenticate(user=requester)
|
rest_api_client.force_authenticate(user=requester)
|
||||||
hidden = DocumentFactory(owner=owner)
|
hidden = DocumentFactory(owner=owner)
|
||||||
|
|
||||||
@@ -339,19 +339,17 @@ class TestBulkEditChangePermissionBoundary:
|
|||||||
# permitted document must not be partially applied just because it
|
# permitted document must not be partially applied just because it
|
||||||
# was bundled with a forbidden one, proving the endpoint checks
|
# was bundled with a forbidden one, proving the endpoint checks
|
||||||
# every document in the batch rather than only the first/last.
|
# every document in the batch rather than only the first/last.
|
||||||
owner = User.objects.create_user(username="owner")
|
owner = UserFactory(username="owner")
|
||||||
requester = User.objects.create_user(username="requester")
|
requester = UserFactory(username="requester")
|
||||||
# grant the global change_document permission so the object-level
|
# grant the global change_document permission so the object-level
|
||||||
# check (not the global has_perm check) is what's under test
|
# check (not the global has_perm check) is what's under test
|
||||||
requester.user_permissions.add(
|
grant_global(requester, "change_document")
|
||||||
Permission.objects.get(codename="change_document"),
|
|
||||||
)
|
|
||||||
rest_api_client.force_authenticate(user=requester)
|
rest_api_client.force_authenticate(user=requester)
|
||||||
changeable = DocumentFactory(owner=owner)
|
changeable = DocumentFactory(owner=owner)
|
||||||
assign_perm("view_document", requester, changeable)
|
grant_object(requester, changeable, "view_document")
|
||||||
assign_perm("change_document", requester, changeable) # fully permitted
|
grant_object(requester, changeable, "change_document") # fully permitted
|
||||||
target = DocumentFactory(owner=owner)
|
target = DocumentFactory(owner=owner)
|
||||||
assign_perm("view_document", requester, target) # view only, NOT change
|
grant_object(requester, target, "view_document") # view only, NOT change
|
||||||
|
|
||||||
response = rest_api_client.post(
|
response = rest_api_client.post(
|
||||||
"/api/documents/bulk_edit/",
|
"/api/documents/bulk_edit/",
|
||||||
@@ -369,15 +367,14 @@ class TestBulkEditChangePermissionBoundary:
|
|||||||
class TestBulkDownloadPermissionChecksRootDocument:
|
class TestBulkDownloadPermissionChecksRootDocument:
|
||||||
def test_download_requires_global_view_permission(
|
def test_download_requires_global_view_permission(
|
||||||
self,
|
self,
|
||||||
rest_api_client,
|
rest_api_client: APIClient,
|
||||||
paperless_dirs,
|
paperless_dirs: PaperlessDirs,
|
||||||
_media_settings,
|
) -> None:
|
||||||
):
|
owner = UserFactory(username="owner")
|
||||||
owner = User.objects.create_user(username="owner")
|
requester = UserFactory(username="requester")
|
||||||
requester = User.objects.create_user(username="requester")
|
|
||||||
root = DocumentFactory(owner=owner)
|
root = DocumentFactory(owner=owner)
|
||||||
root.source_path.write_bytes(b"%PDF-1.4 test")
|
root.source_path.write_bytes(b"%PDF-1.4 test")
|
||||||
assign_perm("view_document", requester, root)
|
grant_object(requester, root, "view_document")
|
||||||
rest_api_client.force_authenticate(user=requester)
|
rest_api_client.force_authenticate(user=requester)
|
||||||
|
|
||||||
response = rest_api_client.post(
|
response = rest_api_client.post(
|
||||||
@@ -390,21 +387,18 @@ class TestBulkDownloadPermissionChecksRootDocument:
|
|||||||
|
|
||||||
def test_permission_checked_on_root_not_on_version(
|
def test_permission_checked_on_root_not_on_version(
|
||||||
self,
|
self,
|
||||||
rest_api_client,
|
rest_api_client: APIClient,
|
||||||
paperless_dirs,
|
paperless_dirs: PaperlessDirs,
|
||||||
_media_settings,
|
) -> None:
|
||||||
):
|
owner = UserFactory(username="owner")
|
||||||
owner = User.objects.create_user(username="owner")
|
requester = UserFactory(username="requester")
|
||||||
requester = User.objects.create_user(username="requester")
|
grant_global(requester, "view_document")
|
||||||
requester.user_permissions.add(
|
|
||||||
Permission.objects.get(codename="view_document"),
|
|
||||||
)
|
|
||||||
rest_api_client.force_authenticate(user=requester)
|
rest_api_client.force_authenticate(user=requester)
|
||||||
root = DocumentFactory(owner=owner)
|
root = DocumentFactory(owner=owner)
|
||||||
# a version of root that the requester has NOT been individually granted
|
# a version of root that the requester has NOT been individually granted
|
||||||
version = DocumentFactory(owner=owner, root_document=root, version_index=1)
|
version = DocumentFactory(owner=owner, root_document=root, version_index=1)
|
||||||
version.source_path.write_bytes(b"%PDF-1.4 test")
|
version.source_path.write_bytes(b"%PDF-1.4 test")
|
||||||
assign_perm("view_document", requester, root) # granted on ROOT only
|
grant_object(requester, root, "view_document") # granted on ROOT only
|
||||||
|
|
||||||
response = rest_api_client.post(
|
response = rest_api_client.post(
|
||||||
"/api/documents/bulk_download/",
|
"/api/documents/bulk_download/",
|
||||||
@@ -422,11 +416,9 @@ class TestBulkDownloadPermissionChecksRootDocument:
|
|||||||
# root-or-version bug; a user with no grant at all (the old
|
# root-or-version bug; a user with no grant at all (the old
|
||||||
# `stranger` case) can't tell the two apart, since they're denied
|
# `stranger` case) can't tell the two apart, since they're denied
|
||||||
# either way.
|
# either way.
|
||||||
version_only_grantee = User.objects.create_user(username="version_only_grantee")
|
version_only_grantee = UserFactory(username="version_only_grantee")
|
||||||
version_only_grantee.user_permissions.add(
|
grant_global(version_only_grantee, "view_document")
|
||||||
Permission.objects.get(codename="view_document"),
|
grant_object(version_only_grantee, version, "view_document")
|
||||||
)
|
|
||||||
assign_perm("view_document", version_only_grantee, version)
|
|
||||||
rest_api_client.force_authenticate(user=version_only_grantee)
|
rest_api_client.force_authenticate(user=version_only_grantee)
|
||||||
response = rest_api_client.post(
|
response = rest_api_client.post(
|
||||||
"/api/documents/bulk_download/",
|
"/api/documents/bulk_download/",
|
||||||
@@ -438,6 +430,53 @@ class TestBulkDownloadPermissionChecksRootDocument:
|
|||||||
) # version-only grant must not substitute for root permission
|
) # version-only grant must not substitute for root permission
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.django_db
|
||||||
|
class TestDocumentOperationPermissionChecksRootDocument:
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
("endpoint", "payload"),
|
||||||
|
[
|
||||||
|
pytest.param("/api/documents/merge/", {}, id="merge"),
|
||||||
|
pytest.param("/api/documents/rotate/", {"degrees": 90}, id="rotate"),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
@pytest.mark.parametrize("version_owner", ["none", "requester"])
|
||||||
|
def test_version_operation_acts_on_root(
|
||||||
|
self,
|
||||||
|
rest_api_client: APIClient,
|
||||||
|
endpoint: str,
|
||||||
|
payload: dict,
|
||||||
|
version_owner: str,
|
||||||
|
) -> None:
|
||||||
|
owner = UserFactory(username="owner")
|
||||||
|
requester = UserFactory(username="requester")
|
||||||
|
grant_global(requester, "change_document")
|
||||||
|
grant_global(requester, "add_document")
|
||||||
|
rest_api_client.force_authenticate(user=requester)
|
||||||
|
root = DocumentFactory(owner=owner)
|
||||||
|
# A version whose owner went stale, e.g. created before the root changed hands
|
||||||
|
version = DocumentFactory(
|
||||||
|
owner=requester if version_owner == "requester" else None,
|
||||||
|
root_document=root,
|
||||||
|
version_index=1,
|
||||||
|
)
|
||||||
|
|
||||||
|
with (
|
||||||
|
patch("documents.views.bulk_edit.merge") as mock_merge,
|
||||||
|
patch("documents.views.bulk_edit.rotate") as mock_rotate,
|
||||||
|
):
|
||||||
|
mock_merge.__name__ = "merge"
|
||||||
|
mock_rotate.__name__ = "rotate"
|
||||||
|
response = rest_api_client.post(
|
||||||
|
endpoint,
|
||||||
|
{"documents": [version.pk], **payload},
|
||||||
|
format="json",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == HTTPStatus.FORBIDDEN
|
||||||
|
mock_merge.assert_not_called()
|
||||||
|
mock_rotate.assert_not_called()
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.django_db
|
@pytest.mark.django_db
|
||||||
@pytest.mark.usefixtures("_search_index")
|
@pytest.mark.usefixtures("_search_index")
|
||||||
class TestTrashRestorePermissionBoundary:
|
class TestTrashRestorePermissionBoundary:
|
||||||
@@ -445,14 +484,12 @@ class TestTrashRestorePermissionBoundary:
|
|||||||
self,
|
self,
|
||||||
rest_api_client,
|
rest_api_client,
|
||||||
):
|
):
|
||||||
owner = User.objects.create_user(username="owner")
|
owner = UserFactory(username="owner")
|
||||||
requester = User.objects.create_user(username="requester")
|
requester = UserFactory(username="requester")
|
||||||
requester.user_permissions.add(
|
grant_global(requester, "delete_document")
|
||||||
Permission.objects.get(codename="delete_document"),
|
|
||||||
)
|
|
||||||
rest_api_client.force_authenticate(user=requester)
|
rest_api_client.force_authenticate(user=requester)
|
||||||
doc = DocumentFactory(owner=owner)
|
doc = DocumentFactory(owner=owner)
|
||||||
assign_perm("view_document", requester, doc) # view only, NOT delete
|
grant_object(requester, doc, "view_document") # view only, NOT delete
|
||||||
doc.delete()
|
doc.delete()
|
||||||
|
|
||||||
response = rest_api_client.post(
|
response = rest_api_client.post(
|
||||||
@@ -466,14 +503,12 @@ class TestTrashRestorePermissionBoundary:
|
|||||||
self,
|
self,
|
||||||
rest_api_client,
|
rest_api_client,
|
||||||
):
|
):
|
||||||
owner = User.objects.create_user(username="owner")
|
owner = UserFactory(username="owner")
|
||||||
requester = User.objects.create_user(username="requester")
|
requester = UserFactory(username="requester")
|
||||||
requester.user_permissions.add(
|
grant_global(requester, "delete_document")
|
||||||
Permission.objects.get(codename="delete_document"),
|
|
||||||
)
|
|
||||||
rest_api_client.force_authenticate(user=requester)
|
rest_api_client.force_authenticate(user=requester)
|
||||||
doc = DocumentFactory(owner=owner)
|
doc = DocumentFactory(owner=owner)
|
||||||
assign_perm("delete_document", requester, doc)
|
grant_object(requester, doc, "delete_document")
|
||||||
doc.delete()
|
doc.delete()
|
||||||
|
|
||||||
response = rest_api_client.post(
|
response = rest_api_client.post(
|
||||||
@@ -484,11 +519,11 @@ class TestTrashRestorePermissionBoundary:
|
|||||||
assert response.status_code == HTTPStatus.OK
|
assert response.status_code == HTTPStatus.OK
|
||||||
|
|
||||||
def test_restore_requires_global_delete_permission(self, rest_api_client):
|
def test_restore_requires_global_delete_permission(self, rest_api_client):
|
||||||
owner = User.objects.create_user(username="owner")
|
owner = UserFactory(username="owner")
|
||||||
requester = User.objects.create_user(username="requester")
|
requester = UserFactory(username="requester")
|
||||||
rest_api_client.force_authenticate(user=requester)
|
rest_api_client.force_authenticate(user=requester)
|
||||||
doc = DocumentFactory(owner=owner)
|
doc = DocumentFactory(owner=owner)
|
||||||
assign_perm("delete_document", requester, doc)
|
grant_object(requester, doc, "delete_document")
|
||||||
doc.delete()
|
doc.delete()
|
||||||
|
|
||||||
response = rest_api_client.post(
|
response = rest_api_client.post(
|
||||||
@@ -513,14 +548,12 @@ class TestTrashViewExcludesExplicitlyGrantedDocuments:
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
def test_explicit_grant_does_not_leak_trashed_document(self, rest_api_client):
|
def test_explicit_grant_does_not_leak_trashed_document(self, rest_api_client):
|
||||||
owner = User.objects.create_user(username="trash_owner")
|
owner = UserFactory(username="trash_owner")
|
||||||
grantee = User.objects.create_user(username="trash_grantee")
|
grantee = UserFactory(username="trash_grantee")
|
||||||
grantee.user_permissions.add(
|
grant_global(grantee, "view_document")
|
||||||
Permission.objects.get(codename="view_document"),
|
|
||||||
)
|
|
||||||
doc = DocumentFactory(owner=owner)
|
doc = DocumentFactory(owner=owner)
|
||||||
doc.delete() # soft delete
|
doc.delete() # soft delete
|
||||||
assign_perm("view_document", grantee, doc)
|
grant_object(grantee, doc, "view_document")
|
||||||
|
|
||||||
rest_api_client.force_authenticate(user=grantee)
|
rest_api_client.force_authenticate(user=grantee)
|
||||||
response = rest_api_client.get("/api/trash/")
|
response = rest_api_client.get("/api/trash/")
|
||||||
@@ -542,8 +575,8 @@ class TestTrashViewExcludesExplicitlyGrantedDocuments:
|
|||||||
)
|
)
|
||||||
class TestPermittedObjectIdsGenericModels:
|
class TestPermittedObjectIdsGenericModels:
|
||||||
def test_owner_sees_own_object(self, model, factory, perm):
|
def test_owner_sees_own_object(self, model, factory, perm):
|
||||||
owner = User.objects.create_user(username=f"owner_{model.__name__}")
|
owner = UserFactory(username=f"owner_{model.__name__}")
|
||||||
stranger = User.objects.create_user(username=f"stranger_{model.__name__}")
|
stranger = UserFactory(username=f"stranger_{model.__name__}")
|
||||||
owned = factory(owner=owner)
|
owned = factory(owner=owner)
|
||||||
strangers = factory(owner=stranger)
|
strangers = factory(owner=stranger)
|
||||||
|
|
||||||
@@ -556,14 +589,14 @@ class TestPermittedObjectIdsGenericModels:
|
|||||||
@pytest.mark.parametrize("is_superuser", [False, True])
|
@pytest.mark.parametrize("is_superuser", [False, True])
|
||||||
def test_inactive_user_sees_nothing(self, model, factory, perm, is_superuser):
|
def test_inactive_user_sees_nothing(self, model, factory, perm, is_superuser):
|
||||||
suffix = f"{model.__name__}_{is_superuser}"
|
suffix = f"{model.__name__}_{is_superuser}"
|
||||||
user = User.objects.create_user(
|
user = UserFactory(
|
||||||
username=f"inactive_{suffix}",
|
username=f"inactive_{suffix}",
|
||||||
is_active=False,
|
is_active=False,
|
||||||
is_superuser=is_superuser,
|
is_superuser=is_superuser,
|
||||||
)
|
)
|
||||||
other = User.objects.create_user(username=f"other_{suffix}")
|
other = UserFactory(username=f"other_{suffix}")
|
||||||
granted = factory(owner=other)
|
granted = factory(owner=other)
|
||||||
assign_perm(perm, user, granted)
|
grant_object(user, granted, perm)
|
||||||
|
|
||||||
assert_visible_document_ids(
|
assert_visible_document_ids(
|
||||||
permitted_object_ids(user, model, perm),
|
permitted_object_ids(user, model, perm),
|
||||||
@@ -576,7 +609,7 @@ class TestPermittedObjectIdsGenericModels:
|
|||||||
)
|
)
|
||||||
|
|
||||||
def test_unowned_object_visible_to_everyone(self, model, factory, perm):
|
def test_unowned_object_visible_to_everyone(self, model, factory, perm):
|
||||||
user = User.objects.create_user(username=f"user_{model.__name__}")
|
user = UserFactory(username=f"user_{model.__name__}")
|
||||||
unowned = factory(owner=None)
|
unowned = factory(owner=None)
|
||||||
|
|
||||||
assert_visible_document_ids(
|
assert_visible_document_ids(
|
||||||
@@ -586,12 +619,12 @@ class TestPermittedObjectIdsGenericModels:
|
|||||||
)
|
)
|
||||||
|
|
||||||
def test_explicit_permission_grants_visibility(self, model, factory, perm):
|
def test_explicit_permission_grants_visibility(self, model, factory, perm):
|
||||||
owner = User.objects.create_user(username=f"owner2_{model.__name__}")
|
owner = UserFactory(username=f"owner2_{model.__name__}")
|
||||||
grantee = User.objects.create_user(username=f"grantee_{model.__name__}")
|
grantee = UserFactory(username=f"grantee_{model.__name__}")
|
||||||
stranger = User.objects.create_user(username=f"stranger2_{model.__name__}")
|
stranger = UserFactory(username=f"stranger2_{model.__name__}")
|
||||||
shared = factory(owner=owner)
|
shared = factory(owner=owner)
|
||||||
not_shared = factory(owner=owner)
|
not_shared = factory(owner=owner)
|
||||||
assign_perm(perm, grantee, shared)
|
grant_object(grantee, shared, perm)
|
||||||
|
|
||||||
assert_visible_document_ids(
|
assert_visible_document_ids(
|
||||||
permitted_object_ids(grantee, model, perm),
|
permitted_object_ids(grantee, model, perm),
|
||||||
@@ -610,13 +643,13 @@ class TestPermittedObjectIdsGenericModels:
|
|||||||
factory,
|
factory,
|
||||||
perm,
|
perm,
|
||||||
):
|
):
|
||||||
owner = User.objects.create_user(username=f"owner3_{model.__name__}")
|
owner = UserFactory(username=f"owner3_{model.__name__}")
|
||||||
member = User.objects.create_user(username=f"member_{model.__name__}")
|
member = UserFactory(username=f"member_{model.__name__}")
|
||||||
non_member = User.objects.create_user(username=f"nonmember_{model.__name__}")
|
non_member = UserFactory(username=f"nonmember_{model.__name__}")
|
||||||
group = Group.objects.create(name=f"group_{model.__name__}")
|
group = Group.objects.create(name=f"group_{model.__name__}")
|
||||||
member.groups.add(group)
|
member.groups.add(group)
|
||||||
shared = factory(owner=owner)
|
shared = factory(owner=owner)
|
||||||
assign_perm(perm, group, shared)
|
grant_object(group, shared, perm)
|
||||||
|
|
||||||
assert_visible_document_ids(
|
assert_visible_document_ids(
|
||||||
permitted_object_ids(member, model, perm),
|
permitted_object_ids(member, model, perm),
|
||||||
@@ -630,8 +663,8 @@ class TestPermittedObjectIdsGenericModels:
|
|||||||
)
|
)
|
||||||
|
|
||||||
def test_superuser_sees_everything(self, model, factory, perm):
|
def test_superuser_sees_everything(self, model, factory, perm):
|
||||||
superuser = User.objects.create_superuser(username=f"root_{model.__name__}")
|
superuser = UserFactory(username=f"root_{model.__name__}", superuser=True)
|
||||||
owner = User.objects.create_user(username=f"owner4_{model.__name__}")
|
owner = UserFactory(username=f"owner4_{model.__name__}")
|
||||||
obj = factory(owner=owner)
|
obj = factory(owner=owner)
|
||||||
|
|
||||||
assert_visible_document_ids(
|
assert_visible_document_ids(
|
||||||
@@ -644,8 +677,8 @@ class TestPermittedObjectIdsGenericModels:
|
|||||||
@pytest.mark.django_db
|
@pytest.mark.django_db
|
||||||
class TestMatchingRespectsObjectPermissions:
|
class TestMatchingRespectsObjectPermissions:
|
||||||
def test_match_tags_only_considers_tags_visible_to_user(self):
|
def test_match_tags_only_considers_tags_visible_to_user(self):
|
||||||
owner = User.objects.create_user(username="tag_owner")
|
owner = UserFactory(username="tag_owner")
|
||||||
classifying_user = User.objects.create_user(username="classifier_user")
|
classifying_user = UserFactory(username="classifier_user")
|
||||||
visible_tag = TagFactory(
|
visible_tag = TagFactory(
|
||||||
owner=owner,
|
owner=owner,
|
||||||
match="invoice",
|
match="invoice",
|
||||||
@@ -656,7 +689,7 @@ class TestMatchingRespectsObjectPermissions:
|
|||||||
match="invoice",
|
match="invoice",
|
||||||
matching_algorithm=Tag.MATCH_LITERAL,
|
matching_algorithm=Tag.MATCH_LITERAL,
|
||||||
)
|
)
|
||||||
assign_perm("view_tag", classifying_user, visible_tag)
|
grant_object(classifying_user, visible_tag, "view_tag")
|
||||||
doc = DocumentFactory(owner=classifying_user, content="an invoice document")
|
doc = DocumentFactory(owner=classifying_user, content="an invoice document")
|
||||||
|
|
||||||
matched = match_tags(doc, classifier=None, user=classifying_user)
|
matched = match_tags(doc, classifier=None, user=classifying_user)
|
||||||
@@ -665,8 +698,8 @@ class TestMatchingRespectsObjectPermissions:
|
|||||||
assert hidden_tag.pk not in matched_ids
|
assert hidden_tag.pk not in matched_ids
|
||||||
|
|
||||||
def test_match_correspondents_only_considers_correspondents_visible_to_user(self):
|
def test_match_correspondents_only_considers_correspondents_visible_to_user(self):
|
||||||
owner = User.objects.create_user(username="correspondent_owner")
|
owner = UserFactory(username="correspondent_owner")
|
||||||
classifying_user = User.objects.create_user(username="classifier_user2")
|
classifying_user = UserFactory(username="classifier_user2")
|
||||||
visible_correspondent = CorrespondentFactory(
|
visible_correspondent = CorrespondentFactory(
|
||||||
owner=owner,
|
owner=owner,
|
||||||
match="invoice",
|
match="invoice",
|
||||||
@@ -677,7 +710,7 @@ class TestMatchingRespectsObjectPermissions:
|
|||||||
match="invoice",
|
match="invoice",
|
||||||
matching_algorithm=Correspondent.MATCH_LITERAL,
|
matching_algorithm=Correspondent.MATCH_LITERAL,
|
||||||
)
|
)
|
||||||
assign_perm("view_correspondent", classifying_user, visible_correspondent)
|
grant_object(classifying_user, visible_correspondent, "view_correspondent")
|
||||||
doc = DocumentFactory(owner=classifying_user, content="an invoice document")
|
doc = DocumentFactory(owner=classifying_user, content="an invoice document")
|
||||||
|
|
||||||
matched = match_correspondents(doc, classifier=None, user=classifying_user)
|
matched = match_correspondents(doc, classifier=None, user=classifying_user)
|
||||||
@@ -686,8 +719,8 @@ class TestMatchingRespectsObjectPermissions:
|
|||||||
assert hidden_correspondent.pk not in matched_ids
|
assert hidden_correspondent.pk not in matched_ids
|
||||||
|
|
||||||
def test_match_document_types_only_considers_document_types_visible_to_user(self):
|
def test_match_document_types_only_considers_document_types_visible_to_user(self):
|
||||||
owner = User.objects.create_user(username="document_type_owner")
|
owner = UserFactory(username="document_type_owner")
|
||||||
classifying_user = User.objects.create_user(username="classifier_user3")
|
classifying_user = UserFactory(username="classifier_user3")
|
||||||
visible_document_type = DocumentTypeFactory(
|
visible_document_type = DocumentTypeFactory(
|
||||||
owner=owner,
|
owner=owner,
|
||||||
match="invoice",
|
match="invoice",
|
||||||
@@ -698,7 +731,7 @@ class TestMatchingRespectsObjectPermissions:
|
|||||||
match="invoice",
|
match="invoice",
|
||||||
matching_algorithm=DocumentType.MATCH_LITERAL,
|
matching_algorithm=DocumentType.MATCH_LITERAL,
|
||||||
)
|
)
|
||||||
assign_perm("view_documenttype", classifying_user, visible_document_type)
|
grant_object(classifying_user, visible_document_type, "view_documenttype")
|
||||||
doc = DocumentFactory(owner=classifying_user, content="an invoice document")
|
doc = DocumentFactory(owner=classifying_user, content="an invoice document")
|
||||||
|
|
||||||
matched = match_document_types(doc, classifier=None, user=classifying_user)
|
matched = match_document_types(doc, classifier=None, user=classifying_user)
|
||||||
@@ -707,8 +740,8 @@ class TestMatchingRespectsObjectPermissions:
|
|||||||
assert hidden_document_type.pk not in matched_ids
|
assert hidden_document_type.pk not in matched_ids
|
||||||
|
|
||||||
def test_match_storage_paths_only_considers_storage_paths_visible_to_user(self):
|
def test_match_storage_paths_only_considers_storage_paths_visible_to_user(self):
|
||||||
owner = User.objects.create_user(username="storage_path_owner")
|
owner = UserFactory(username="storage_path_owner")
|
||||||
classifying_user = User.objects.create_user(username="classifier_user4")
|
classifying_user = UserFactory(username="classifier_user4")
|
||||||
visible_storage_path = StoragePathFactory(
|
visible_storage_path = StoragePathFactory(
|
||||||
owner=owner,
|
owner=owner,
|
||||||
match="invoice",
|
match="invoice",
|
||||||
@@ -719,7 +752,7 @@ class TestMatchingRespectsObjectPermissions:
|
|||||||
match="invoice",
|
match="invoice",
|
||||||
matching_algorithm=StoragePath.MATCH_LITERAL,
|
matching_algorithm=StoragePath.MATCH_LITERAL,
|
||||||
)
|
)
|
||||||
assign_perm("view_storagepath", classifying_user, visible_storage_path)
|
grant_object(classifying_user, visible_storage_path, "view_storagepath")
|
||||||
doc = DocumentFactory(owner=classifying_user, content="an invoice document")
|
doc = DocumentFactory(owner=classifying_user, content="an invoice document")
|
||||||
|
|
||||||
matched = match_storage_paths(doc, classifier=None, user=classifying_user)
|
matched = match_storage_paths(doc, classifier=None, user=classifying_user)
|
||||||
@@ -731,14 +764,12 @@ class TestMatchingRespectsObjectPermissions:
|
|||||||
@pytest.mark.django_db
|
@pytest.mark.django_db
|
||||||
class TestBulkEditObjectsApplyToAllPermissionBoundary:
|
class TestBulkEditObjectsApplyToAllPermissionBoundary:
|
||||||
def test_apply_to_all_tags_excludes_unpermitted_tag(self, rest_api_client):
|
def test_apply_to_all_tags_excludes_unpermitted_tag(self, rest_api_client):
|
||||||
owner = User.objects.create_user(username="tags_owner")
|
owner = UserFactory(username="tags_owner")
|
||||||
requester = User.objects.create_user(username="tags_requester")
|
requester = UserFactory(username="tags_requester")
|
||||||
new_owner = User.objects.create_user(username="tags_new_owner")
|
new_owner = UserFactory(username="tags_new_owner")
|
||||||
# grant the global change_tag permission so the object-level
|
# grant the global change_tag permission so the object-level
|
||||||
# filtering (not the global has_perm check) is what's under test
|
# filtering (not the global has_perm check) is what's under test
|
||||||
requester.user_permissions.add(
|
grant_global(requester, "change_tag")
|
||||||
Permission.objects.get(codename="change_tag"),
|
|
||||||
)
|
|
||||||
rest_api_client.force_authenticate(user=requester)
|
rest_api_client.force_authenticate(user=requester)
|
||||||
visible = TagFactory(owner=requester)
|
visible = TagFactory(owner=requester)
|
||||||
hidden = TagFactory(owner=owner)
|
hidden = TagFactory(owner=owner)
|
||||||
@@ -771,16 +802,14 @@ class TestBulkEditObjectsApplyToAllPermissionBoundary:
|
|||||||
request rather than being silently skipped. Editing permissions is
|
request rather than being silently skipped. Editing permissions is
|
||||||
limited to the owner, same as documents.
|
limited to the owner, same as documents.
|
||||||
"""
|
"""
|
||||||
owner = User.objects.create_user(username="shared_tags_owner")
|
owner = UserFactory(username="shared_tags_owner")
|
||||||
requester = User.objects.create_user(username="shared_tags_requester")
|
requester = UserFactory(username="shared_tags_requester")
|
||||||
requester.user_permissions.add(
|
grant_global(requester, "change_tag")
|
||||||
Permission.objects.get(codename="change_tag"),
|
|
||||||
)
|
|
||||||
rest_api_client.force_authenticate(user=requester)
|
rest_api_client.force_authenticate(user=requester)
|
||||||
owned = TagFactory(owner=requester)
|
owned = TagFactory(owner=requester)
|
||||||
shared = TagFactory(owner=owner)
|
shared = TagFactory(owner=owner)
|
||||||
assign_perm("view_tag", requester, shared)
|
grant_object(requester, shared, "view_tag")
|
||||||
assign_perm("change_tag", requester, shared)
|
grant_object(requester, shared, "change_tag")
|
||||||
|
|
||||||
response = rest_api_client.post(
|
response = rest_api_client.post(
|
||||||
"/api/bulk_edit_objects/",
|
"/api/bulk_edit_objects/",
|
||||||
@@ -831,14 +860,12 @@ class TestBulkEditObjectsTagDescendantPartialPermission:
|
|||||||
would pass/fail based on FK cascade behavior, not on whether the
|
would pass/fail based on FK cascade behavior, not on whether the
|
||||||
descendant-expansion logic itself respected per-object permissions.
|
descendant-expansion logic itself respected per-object permissions.
|
||||||
"""
|
"""
|
||||||
owner = User.objects.create_user(username="tag_hierarchy_owner")
|
owner = UserFactory(username="tag_hierarchy_owner")
|
||||||
requester = User.objects.create_user(username="tag_hierarchy_requester")
|
requester = UserFactory(username="tag_hierarchy_requester")
|
||||||
new_owner = User.objects.create_user(username="tag_hierarchy_new_owner")
|
new_owner = UserFactory(username="tag_hierarchy_new_owner")
|
||||||
# global change_tag permission so the has_perm() gate passes and the
|
# global change_tag permission so the has_perm() gate passes and the
|
||||||
# object-level permitted_object_ids filtering is what's under test
|
# object-level permitted_object_ids filtering is what's under test
|
||||||
requester.user_permissions.add(
|
grant_global(requester, "change_tag")
|
||||||
Permission.objects.get(codename="change_tag"),
|
|
||||||
)
|
|
||||||
rest_api_client.force_authenticate(user=requester)
|
rest_api_client.force_authenticate(user=requester)
|
||||||
|
|
||||||
parent = TagFactory(owner=requester, name="parent-tag")
|
parent = TagFactory(owner=requester, name="parent-tag")
|
||||||
@@ -890,7 +917,7 @@ class TestRestrictQuerysetToVisible:
|
|||||||
- The queryset is returned unfiltered, rather than
|
- The queryset is returned unfiltered, rather than
|
||||||
permitted_object_ids(None, ...)'s narrower "unowned rows only"
|
permitted_object_ids(None, ...)'s narrower "unowned rows only"
|
||||||
"""
|
"""
|
||||||
owner = User.objects.create_user(username="vis_none_owner")
|
owner = UserFactory(username="vis_none_owner")
|
||||||
tag = TagFactory(owner=owner)
|
tag = TagFactory(owner=owner)
|
||||||
|
|
||||||
visible = restrict_queryset_to_visible(Tag.objects.all(), None, "view_tag")
|
visible = restrict_queryset_to_visible(Tag.objects.all(), None, "view_tag")
|
||||||
@@ -907,8 +934,8 @@ class TestRestrictQuerysetToVisible:
|
|||||||
- The queryset is returned unfiltered, skipping the permission
|
- The queryset is returned unfiltered, skipping the permission
|
||||||
lookup entirely
|
lookup entirely
|
||||||
"""
|
"""
|
||||||
superuser = User.objects.create_superuser(username="vis_active_super")
|
superuser = UserFactory(username="vis_active_super", superuser=True)
|
||||||
owner = User.objects.create_user(username="vis_active_super_owner")
|
owner = UserFactory(username="vis_active_super_owner")
|
||||||
tag = TagFactory(owner=owner)
|
tag = TagFactory(owner=owner)
|
||||||
|
|
||||||
visible = restrict_queryset_to_visible(
|
visible = restrict_queryset_to_visible(
|
||||||
@@ -930,7 +957,7 @@ class TestRestrictQuerysetToVisible:
|
|||||||
deactivation has to win over the superuser shortcut, matching
|
deactivation has to win over the superuser shortcut, matching
|
||||||
permitted_object_ids's own ordering
|
permitted_object_ids's own ordering
|
||||||
"""
|
"""
|
||||||
user = User.objects.create_user(
|
user = UserFactory(
|
||||||
username="vis_inactive_super",
|
username="vis_inactive_super",
|
||||||
is_active=False,
|
is_active=False,
|
||||||
is_superuser=True,
|
is_superuser=True,
|
||||||
@@ -951,8 +978,8 @@ class TestRestrictQuerysetToVisible:
|
|||||||
THEN:
|
THEN:
|
||||||
- Only the rows permitted_object_ids() reports are visible
|
- Only the rows permitted_object_ids() reports are visible
|
||||||
"""
|
"""
|
||||||
user = User.objects.create_user(username="vis_regular")
|
user = UserFactory(username="vis_regular")
|
||||||
other = User.objects.create_user(username="vis_regular_other")
|
other = UserFactory(username="vis_regular_other")
|
||||||
own = TagFactory(owner=user)
|
own = TagFactory(owner=user)
|
||||||
hidden = TagFactory(owner=other)
|
hidden = TagFactory(owner=other)
|
||||||
|
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
import pytest
|
import pytest
|
||||||
from django.contrib.auth.models import User
|
|
||||||
from guardian.shortcuts import assign_perm
|
|
||||||
from rest_framework.test import APIRequestFactory
|
from rest_framework.test import APIRequestFactory
|
||||||
|
|
||||||
from documents.filters import PermittedObjectsFilter
|
from documents.filters import PermittedObjectsFilter
|
||||||
from documents.models import Tag
|
from documents.models import Tag
|
||||||
from documents.tests.factories import TagFactory
|
from paperless_testing.factories import TagFactory
|
||||||
|
from paperless_testing.factories import UserFactory
|
||||||
|
from paperless_testing.permissions import grant_object
|
||||||
|
|
||||||
|
|
||||||
class _DummyView:
|
class _DummyView:
|
||||||
@@ -15,8 +15,8 @@ class _DummyView:
|
|||||||
@pytest.mark.django_db
|
@pytest.mark.django_db
|
||||||
class TestPermittedObjectsFilter:
|
class TestPermittedObjectsFilter:
|
||||||
def test_superuser_bypasses_filtering_entirely(self):
|
def test_superuser_bypasses_filtering_entirely(self):
|
||||||
superuser = User.objects.create_superuser(username="root")
|
superuser = UserFactory(username="root", superuser=True)
|
||||||
owner = User.objects.create_user(username="owner")
|
owner = UserFactory(username="owner")
|
||||||
TagFactory(owner=owner)
|
TagFactory(owner=owner)
|
||||||
request = APIRequestFactory().get("/")
|
request = APIRequestFactory().get("/")
|
||||||
request.user = superuser
|
request.user = superuser
|
||||||
@@ -29,13 +29,13 @@ class TestPermittedObjectsFilter:
|
|||||||
assert result.count() == Tag.objects.count()
|
assert result.count() == Tag.objects.count()
|
||||||
|
|
||||||
def test_non_superuser_sees_only_owned_unowned_and_granted(self):
|
def test_non_superuser_sees_only_owned_unowned_and_granted(self):
|
||||||
owner = User.objects.create_user(username="owner")
|
owner = UserFactory(username="owner")
|
||||||
grantee = User.objects.create_user(username="grantee")
|
grantee = UserFactory(username="grantee")
|
||||||
owned = TagFactory(owner=grantee)
|
owned = TagFactory(owner=grantee)
|
||||||
unowned = TagFactory(owner=None)
|
unowned = TagFactory(owner=None)
|
||||||
granted = TagFactory(owner=owner)
|
granted = TagFactory(owner=owner)
|
||||||
hidden = TagFactory(owner=owner)
|
hidden = TagFactory(owner=owner)
|
||||||
assign_perm("view_tag", grantee, granted)
|
grant_object(grantee, granted, "view_tag")
|
||||||
request = APIRequestFactory().get("/")
|
request = APIRequestFactory().get("/")
|
||||||
request.user = grantee
|
request.user = grantee
|
||||||
|
|
||||||
@@ -49,11 +49,11 @@ class TestPermittedObjectsFilter:
|
|||||||
assert hidden.pk not in visible_ids
|
assert hidden.pk not in visible_ids
|
||||||
|
|
||||||
def test_include_granted_false_excludes_explicitly_shared_objects(self):
|
def test_include_granted_false_excludes_explicitly_shared_objects(self):
|
||||||
owner = User.objects.create_user(username="owner2")
|
owner = UserFactory(username="owner2")
|
||||||
grantee = User.objects.create_user(username="grantee2")
|
grantee = UserFactory(username="grantee2")
|
||||||
owned = TagFactory(owner=grantee)
|
owned = TagFactory(owner=grantee)
|
||||||
granted = TagFactory(owner=owner)
|
granted = TagFactory(owner=owner)
|
||||||
assign_perm("view_tag", grantee, granted)
|
grant_object(grantee, granted, "view_tag")
|
||||||
request = APIRequestFactory().get("/")
|
request = APIRequestFactory().get("/")
|
||||||
request.user = grantee
|
request.user = grantee
|
||||||
|
|
||||||
@@ -74,15 +74,15 @@ class TestPermittedObjectsFilter:
|
|||||||
[("inactive", False), ("inactive_super", True)],
|
[("inactive", False), ("inactive_super", True)],
|
||||||
)
|
)
|
||||||
def test_inactive_user_sees_nothing(self, username: str, *, is_superuser: bool):
|
def test_inactive_user_sees_nothing(self, username: str, *, is_superuser: bool):
|
||||||
user = User.objects.create_user(
|
user = UserFactory(
|
||||||
username=username,
|
username=username,
|
||||||
is_active=False,
|
is_active=False,
|
||||||
is_superuser=is_superuser,
|
is_superuser=is_superuser,
|
||||||
)
|
)
|
||||||
TagFactory(owner=None)
|
TagFactory(owner=None)
|
||||||
TagFactory(owner=user)
|
TagFactory(owner=user)
|
||||||
granted = TagFactory(owner=User.objects.create_user(username=f"o_{username}"))
|
granted = TagFactory(owner=UserFactory(username=f"o_{username}"))
|
||||||
assign_perm("view_tag", user, granted)
|
grant_object(user, granted, "view_tag")
|
||||||
request = APIRequestFactory().get("/")
|
request = APIRequestFactory().get("/")
|
||||||
request.user = user
|
request.user = user
|
||||||
|
|
||||||
@@ -94,7 +94,7 @@ class TestPermittedObjectsFilter:
|
|||||||
assert result.count() == 0
|
assert result.count() == 0
|
||||||
|
|
||||||
def test_inactive_user_sees_nothing_with_include_granted_false(self):
|
def test_inactive_user_sees_nothing_with_include_granted_false(self):
|
||||||
user = User.objects.create_user(username="inactive_owner", is_active=False)
|
user = UserFactory(username="inactive_owner", is_active=False)
|
||||||
TagFactory(owner=user)
|
TagFactory(owner=user)
|
||||||
TagFactory(owner=None)
|
TagFactory(owner=None)
|
||||||
request = APIRequestFactory().get("/")
|
request = APIRequestFactory().get("/")
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ if TYPE_CHECKING:
|
|||||||
from collections.abc import Iterable
|
from collections.abc import Iterable
|
||||||
|
|
||||||
from documents.models import Document
|
from documents.models import Document
|
||||||
from documents.tests.conftest import PaperlessDirs
|
from paperless_testing.dirs import PaperlessDirs
|
||||||
|
|
||||||
|
|
||||||
class TestSanityCheckMessages:
|
class TestSanityCheckMessages:
|
||||||
@@ -46,14 +46,14 @@ class TestSanityCheckMessages:
|
|||||||
class TestCheckSanityNoDocuments:
|
class TestCheckSanityNoDocuments:
|
||||||
"""Sanity checks against an empty archive."""
|
"""Sanity checks against an empty archive."""
|
||||||
|
|
||||||
@pytest.mark.usefixtures("_media_settings")
|
@pytest.mark.usefixtures("paperless_dirs")
|
||||||
def test_no_documents(self) -> None:
|
def test_no_documents(self) -> None:
|
||||||
messages = check_sanity()
|
messages = check_sanity()
|
||||||
assert not messages.has_error
|
assert not messages.has_error
|
||||||
assert not messages.has_warning
|
assert not messages.has_warning
|
||||||
assert messages.total_issue_count == 0
|
assert messages.total_issue_count == 0
|
||||||
|
|
||||||
@pytest.mark.usefixtures("_media_settings")
|
@pytest.mark.usefixtures("paperless_dirs")
|
||||||
def test_no_issues_logs_clean(self, caplog: pytest.LogCaptureFixture) -> None:
|
def test_no_issues_logs_clean(self, caplog: pytest.LogCaptureFixture) -> None:
|
||||||
messages = check_sanity()
|
messages = check_sanity()
|
||||||
with caplog.at_level(logging.INFO, logger="paperless.sanity_checker"):
|
with caplog.at_level(logging.INFO, logger="paperless.sanity_checker"):
|
||||||
@@ -214,18 +214,17 @@ class TestCheckSanityOrphans:
|
|||||||
sample_doc: Document,
|
sample_doc: Document,
|
||||||
paperless_dirs: PaperlessDirs,
|
paperless_dirs: PaperlessDirs,
|
||||||
) -> None:
|
) -> None:
|
||||||
(paperless_dirs.originals / "orphan.pdf").touch()
|
(paperless_dirs.originals_dir / "orphan.pdf").touch()
|
||||||
messages = check_sanity()
|
messages = check_sanity()
|
||||||
assert messages.has_warning
|
assert messages.has_warning
|
||||||
assert any("Orphaned file" in m["message"] for m in messages[None])
|
assert any("Orphaned file" in m["message"] for m in messages[None])
|
||||||
|
|
||||||
@pytest.mark.usefixtures("_media_settings")
|
|
||||||
def test_ignorable_files_not_flagged(
|
def test_ignorable_files_not_flagged(
|
||||||
self,
|
self,
|
||||||
paperless_dirs: PaperlessDirs,
|
paperless_dirs: PaperlessDirs,
|
||||||
) -> None:
|
) -> None:
|
||||||
(paperless_dirs.media / ".DS_Store").touch()
|
(paperless_dirs.media_dir / ".DS_Store").touch()
|
||||||
(paperless_dirs.media / "desktop.ini").touch()
|
(paperless_dirs.media_dir / "desktop.ini").touch()
|
||||||
messages = check_sanity()
|
messages = check_sanity()
|
||||||
assert not messages.has_warning
|
assert not messages.has_warning
|
||||||
|
|
||||||
@@ -269,13 +268,13 @@ class TestCheckSanityLogMessages:
|
|||||||
paperless_dirs: PaperlessDirs,
|
paperless_dirs: PaperlessDirs,
|
||||||
caplog: pytest.LogCaptureFixture,
|
caplog: pytest.LogCaptureFixture,
|
||||||
) -> None:
|
) -> None:
|
||||||
(paperless_dirs.originals / "orphan.pdf").touch()
|
(paperless_dirs.originals_dir / "orphan.pdf").touch()
|
||||||
messages = check_sanity()
|
messages = check_sanity()
|
||||||
with caplog.at_level(logging.WARNING, logger="paperless.sanity_checker"):
|
with caplog.at_level(logging.WARNING, logger="paperless.sanity_checker"):
|
||||||
messages.log_messages()
|
messages.log_messages()
|
||||||
assert "Orphaned file" in caplog.text
|
assert "Orphaned file" in caplog.text
|
||||||
|
|
||||||
@pytest.mark.usefixtures("_media_settings")
|
@pytest.mark.usefixtures("paperless_dirs")
|
||||||
def test_logs_unknown_doc_pk(self, caplog: pytest.LogCaptureFixture) -> None:
|
def test_logs_unknown_doc_pk(self, caplog: pytest.LogCaptureFixture) -> None:
|
||||||
"""A doc PK not in the DB logs 'Unknown' as the title."""
|
"""A doc PK not in the DB logs 'Unknown' as the title."""
|
||||||
messages = check_sanity()
|
messages = check_sanity()
|
||||||
|
|||||||
@@ -6,10 +6,8 @@ from pathlib import Path
|
|||||||
from unittest import mock
|
from unittest import mock
|
||||||
|
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.contrib.auth.models import Permission
|
|
||||||
from django.contrib.auth.models import User
|
from django.contrib.auth.models import User
|
||||||
from django.utils import timezone
|
from django.utils import timezone
|
||||||
from guardian.shortcuts import assign_perm
|
|
||||||
from rest_framework import serializers
|
from rest_framework import serializers
|
||||||
from rest_framework import status
|
from rest_framework import status
|
||||||
from rest_framework.test import APITestCase
|
from rest_framework.test import APITestCase
|
||||||
@@ -20,8 +18,11 @@ from documents.models import ShareLinkBundle
|
|||||||
from documents.serialisers import ShareLinkBundleSerializer
|
from documents.serialisers import ShareLinkBundleSerializer
|
||||||
from documents.tasks import build_share_link_bundle
|
from documents.tasks import build_share_link_bundle
|
||||||
from documents.tasks import cleanup_expired_share_link_bundles
|
from documents.tasks import cleanup_expired_share_link_bundles
|
||||||
from documents.tests.factories import DocumentFactory
|
from paperless_testing.dirs import DirectoriesMixin
|
||||||
from documents.tests.utils import DirectoriesMixin
|
from paperless_testing.factories import DocumentFactory
|
||||||
|
from paperless_testing.factories import UserFactory
|
||||||
|
from paperless_testing.permissions import grant_global
|
||||||
|
from paperless_testing.permissions import grant_object
|
||||||
|
|
||||||
|
|
||||||
class ShareLinkBundleAPITests(DirectoriesMixin, APITestCase):
|
class ShareLinkBundleAPITests(DirectoriesMixin, APITestCase):
|
||||||
@@ -29,7 +30,7 @@ class ShareLinkBundleAPITests(DirectoriesMixin, APITestCase):
|
|||||||
|
|
||||||
def setUp(self) -> None:
|
def setUp(self) -> None:
|
||||||
super().setUp()
|
super().setUp()
|
||||||
self.user = User.objects.create_superuser(username="bundle_admin")
|
self.user = UserFactory(username="bundle_admin", superuser=True)
|
||||||
self.client.force_authenticate(self.user)
|
self.client.force_authenticate(self.user)
|
||||||
self.document = DocumentFactory.create()
|
self.document = DocumentFactory.create()
|
||||||
|
|
||||||
@@ -55,13 +56,11 @@ class ShareLinkBundleAPITests(DirectoriesMixin, APITestCase):
|
|||||||
self,
|
self,
|
||||||
delay_mock,
|
delay_mock,
|
||||||
) -> None:
|
) -> None:
|
||||||
owner = User.objects.create_user(username="document_owner")
|
owner = UserFactory(username="document_owner")
|
||||||
requester = User.objects.create_user(username="bundle_creator")
|
requester = UserFactory(username="bundle_creator")
|
||||||
requester.user_permissions.add(
|
grant_global(requester, "add_sharelinkbundle")
|
||||||
Permission.objects.get(codename="add_sharelinkbundle"),
|
|
||||||
)
|
|
||||||
document = DocumentFactory.create(owner=owner)
|
document = DocumentFactory.create(owner=owner)
|
||||||
assign_perm("view_document", requester, document)
|
grant_object(requester, document, "view_document")
|
||||||
self.client.force_authenticate(requester)
|
self.client.force_authenticate(requester)
|
||||||
payload = {
|
payload = {
|
||||||
"document_ids": [document.pk],
|
"document_ids": [document.pk],
|
||||||
@@ -72,9 +71,7 @@ class ShareLinkBundleAPITests(DirectoriesMixin, APITestCase):
|
|||||||
response = self.client.post(self.ENDPOINT, payload, format="json")
|
response = self.client.post(self.ENDPOINT, payload, format="json")
|
||||||
self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN)
|
self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN)
|
||||||
|
|
||||||
requester.user_permissions.add(
|
grant_global(requester, "view_document")
|
||||||
Permission.objects.get(codename="view_document"),
|
|
||||||
)
|
|
||||||
requester = User.objects.get(pk=requester.pk)
|
requester = User.objects.get(pk=requester.pk)
|
||||||
self.client.force_authenticate(requester)
|
self.client.force_authenticate(requester)
|
||||||
response = self.client.post(self.ENDPOINT, payload, format="json")
|
response = self.client.post(self.ENDPOINT, payload, format="json")
|
||||||
@@ -342,15 +339,6 @@ class ShareLinkBundleBuildTaskTests(DirectoriesMixin, APITestCase):
|
|||||||
)
|
)
|
||||||
self.document.archive_checksum = ""
|
self.document.archive_checksum = ""
|
||||||
self.document.save()
|
self.document.save()
|
||||||
self.addCleanup(
|
|
||||||
setattr,
|
|
||||||
settings,
|
|
||||||
"SHARE_LINK_BUNDLE_DIR",
|
|
||||||
settings.SHARE_LINK_BUNDLE_DIR,
|
|
||||||
)
|
|
||||||
settings.SHARE_LINK_BUNDLE_DIR = (
|
|
||||||
Path(settings.MEDIA_ROOT) / "documents" / "share_link_bundles"
|
|
||||||
)
|
|
||||||
|
|
||||||
def _write_document_file(self, *, archive: bool, content: bytes) -> Path:
|
def _write_document_file(self, *, archive: bool, content: bytes) -> Path:
|
||||||
if archive:
|
if archive:
|
||||||
|
|||||||
@@ -1,7 +1,5 @@
|
|||||||
from unittest import mock
|
from unittest import mock
|
||||||
|
|
||||||
from django.contrib.auth.models import Permission
|
|
||||||
from django.contrib.auth.models import User
|
|
||||||
from rest_framework import status
|
from rest_framework import status
|
||||||
from rest_framework.test import APITestCase
|
from rest_framework.test import APITestCase
|
||||||
|
|
||||||
@@ -13,16 +11,16 @@ from documents.models import WorkflowAction
|
|||||||
from documents.models import WorkflowTrigger
|
from documents.models import WorkflowTrigger
|
||||||
from documents.serialisers import TagSerializer
|
from documents.serialisers import TagSerializer
|
||||||
from documents.signals.handlers import run_workflows
|
from documents.signals.handlers import run_workflows
|
||||||
from documents.tests.utils import DirectoriesMixin
|
from paperless_testing.dirs import DirectoriesMixin
|
||||||
|
from paperless_testing.factories import UserFactory
|
||||||
|
from paperless_testing.permissions import grant_global
|
||||||
|
|
||||||
|
|
||||||
class TestTagHierarchyPermissions(APITestCase):
|
class TestTagHierarchyPermissions(APITestCase):
|
||||||
def test_children_only_include_visible_tags(self) -> None:
|
def test_children_only_include_visible_tags(self) -> None:
|
||||||
owner = User.objects.create_user(username="owner")
|
owner = UserFactory(username="owner")
|
||||||
requester = User.objects.create_user(username="requester")
|
requester = UserFactory(username="requester")
|
||||||
requester.user_permissions.add(
|
grant_global(requester, "view_tag")
|
||||||
Permission.objects.get(codename="view_tag"),
|
|
||||||
)
|
|
||||||
parent = Tag.objects.create(name="Visible parent", owner=requester)
|
parent = Tag.objects.create(name="Visible parent", owner=requester)
|
||||||
hidden_child = Tag.objects.create(
|
hidden_child = Tag.objects.create(
|
||||||
name="Hidden child",
|
name="Hidden child",
|
||||||
@@ -49,7 +47,7 @@ class TestTagHierarchyPermissions(APITestCase):
|
|||||||
class TestTagHierarchy(DirectoriesMixin, APITestCase):
|
class TestTagHierarchy(DirectoriesMixin, APITestCase):
|
||||||
def setUp(self) -> None:
|
def setUp(self) -> None:
|
||||||
super().setUp()
|
super().setUp()
|
||||||
self.user = User.objects.create_superuser(username="admin")
|
self.user = UserFactory(username="admin", superuser=True)
|
||||||
self.client.force_authenticate(user=self.user)
|
self.client.force_authenticate(user=self.user)
|
||||||
|
|
||||||
self.parent = Tag.objects.create(name="Parent")
|
self.parent = Tag.objects.create(name="Parent")
|
||||||
|
|||||||
@@ -17,7 +17,8 @@ from documents.signals.handlers import task_failure_handler
|
|||||||
from documents.signals.handlers import task_postrun_handler
|
from documents.signals.handlers import task_postrun_handler
|
||||||
from documents.signals.handlers import task_prerun_handler
|
from documents.signals.handlers import task_prerun_handler
|
||||||
from documents.signals.handlers import task_revoked_handler
|
from documents.signals.handlers import task_revoked_handler
|
||||||
from documents.tests.factories import PaperlessTaskFactory
|
from paperless_testing.factories import PaperlessTaskFactory
|
||||||
|
from paperless_testing.factories import UserFactory
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
@@ -34,8 +35,8 @@ def consume_input_doc():
|
|||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
def consume_overrides(django_user_model):
|
def consume_overrides():
|
||||||
user = django_user_model.objects.create_user(username="testuser")
|
user = UserFactory(username="testuser")
|
||||||
overrides = mock.MagicMock(spec=DocumentMetadataOverrides)
|
overrides = mock.MagicMock(spec=DocumentMetadataOverrides)
|
||||||
overrides.owner_id = user.id
|
overrides.owner_id = user.id
|
||||||
return overrides
|
return overrides
|
||||||
|
|||||||
@@ -17,9 +17,9 @@ from documents.models import Tag
|
|||||||
from documents.models import WorkflowAction
|
from documents.models import WorkflowAction
|
||||||
from documents.sanity_checker import SanityCheckFailedException
|
from documents.sanity_checker import SanityCheckFailedException
|
||||||
from documents.sanity_checker import SanityCheckMessages
|
from documents.sanity_checker import SanityCheckMessages
|
||||||
from documents.tests.test_classifier import dummy_preprocess
|
from documents.tests.helpers import dummy_preprocess
|
||||||
from documents.tests.utils import DirectoriesMixin
|
from paperless_testing.assertions import FileSystemAssertsMixin
|
||||||
from documents.tests.utils import FileSystemAssertsMixin
|
from paperless_testing.dirs import DirectoriesMixin
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.django_db
|
@pytest.mark.django_db
|
||||||
|
|||||||
@@ -14,8 +14,8 @@ from documents.conditionals import preview_etag
|
|||||||
from documents.conditionals import thumbnail_etag
|
from documents.conditionals import thumbnail_etag
|
||||||
from documents.conditionals import thumbnail_last_modified
|
from documents.conditionals import thumbnail_last_modified
|
||||||
from documents.models import Document
|
from documents.models import Document
|
||||||
from documents.tests.utils import DirectoriesMixin
|
|
||||||
from documents.versioning import resolve_effective_document_by_pk
|
from documents.versioning import resolve_effective_document_by_pk
|
||||||
|
from paperless_testing.dirs import DirectoriesMixin
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from rest_framework.request import Request
|
from rest_framework.request import Request
|
||||||
|
|||||||
@@ -15,7 +15,6 @@ from django.test import TestCase
|
|||||||
from django.test import override_settings
|
from django.test import override_settings
|
||||||
from django.test.utils import CaptureQueriesContext
|
from django.test.utils import CaptureQueriesContext
|
||||||
from django.utils import timezone
|
from django.utils import timezone
|
||||||
from guardian.shortcuts import assign_perm
|
|
||||||
from rest_framework import status
|
from rest_framework import status
|
||||||
|
|
||||||
from documents.caching import get_llm_suggestion_cache
|
from documents.caching import get_llm_suggestion_cache
|
||||||
@@ -29,11 +28,14 @@ from documents.models import StoragePath
|
|||||||
from documents.models import Tag
|
from documents.models import Tag
|
||||||
from documents.models import UiSettings
|
from documents.models import UiSettings
|
||||||
from documents.signals.handlers import update_llm_suggestions_cache
|
from documents.signals.handlers import update_llm_suggestions_cache
|
||||||
from documents.tests.utils import DirectoriesMixin
|
|
||||||
from documents.tests.utils import read_streaming_response
|
|
||||||
from paperless.models import ApplicationConfiguration
|
from paperless.models import ApplicationConfiguration
|
||||||
from paperless_ai.exceptions import LLMProviderError
|
from paperless_ai.exceptions import LLMProviderError
|
||||||
from paperless_ai.exceptions import LLMTimeoutError
|
from paperless_ai.exceptions import LLMTimeoutError
|
||||||
|
from paperless_testing.dirs import DirectoriesMixin
|
||||||
|
from paperless_testing.factories import UserFactory
|
||||||
|
from paperless_testing.http import read_streaming_response
|
||||||
|
from paperless_testing.permissions import grant_global
|
||||||
|
from paperless_testing.permissions import grant_object
|
||||||
|
|
||||||
|
|
||||||
class TestViews(DirectoriesMixin, TestCase):
|
class TestViews(DirectoriesMixin, TestCase):
|
||||||
@@ -43,7 +45,7 @@ class TestViews(DirectoriesMixin, TestCase):
|
|||||||
ApplicationConfiguration.objects.get_or_create()
|
ApplicationConfiguration.objects.get_or_create()
|
||||||
|
|
||||||
def setUp(self) -> None:
|
def setUp(self) -> None:
|
||||||
self.user = User.objects.create_user("testuser")
|
self.user = UserFactory(username="testuser")
|
||||||
super().setUp()
|
super().setUp()
|
||||||
|
|
||||||
def test_login_redirect(self) -> None:
|
def test_login_redirect(self) -> None:
|
||||||
@@ -141,9 +143,7 @@ class TestViews(DirectoriesMixin, TestCase):
|
|||||||
codename__contains="sharelink",
|
codename__contains="sharelink",
|
||||||
)
|
)
|
||||||
self.user.user_permissions.add(*sharelink_permissions)
|
self.user.user_permissions.add(*sharelink_permissions)
|
||||||
self.user.user_permissions.add(
|
grant_global(self.user, "view_document")
|
||||||
Permission.objects.get(codename="view_document"),
|
|
||||||
)
|
|
||||||
self.user.save()
|
self.user.save()
|
||||||
|
|
||||||
self.client.force_login(self.user)
|
self.client.force_login(self.user)
|
||||||
@@ -205,9 +205,7 @@ class TestViews(DirectoriesMixin, TestCase):
|
|||||||
codename__contains="sharelink",
|
codename__contains="sharelink",
|
||||||
)
|
)
|
||||||
self.user.user_permissions.add(*sharelink_permissions)
|
self.user.user_permissions.add(*sharelink_permissions)
|
||||||
self.user.user_permissions.add(
|
grant_global(self.user, "view_document")
|
||||||
Permission.objects.get(codename="view_document"),
|
|
||||||
)
|
|
||||||
self.client.force_login(self.user)
|
self.client.force_login(self.user)
|
||||||
|
|
||||||
create_response = self.client.post(
|
create_response = self.client.post(
|
||||||
@@ -240,16 +238,16 @@ class TestViews(DirectoriesMixin, TestCase):
|
|||||||
group2 = Group.objects.create(name="group2")
|
group2 = Group.objects.create(name="group2")
|
||||||
group3 = Group.objects.create(name="group3")
|
group3 = Group.objects.create(name="group3")
|
||||||
t1 = Tag.objects.create(name="invoice", pk=1)
|
t1 = Tag.objects.create(name="invoice", pk=1)
|
||||||
assign_perm("view_tag", self.user, t1)
|
grant_object(self.user, t1, "view_tag")
|
||||||
assign_perm("view_tag", user2, t1)
|
grant_object(user2, t1, "view_tag")
|
||||||
assign_perm("view_tag", user3, t1)
|
grant_object(user3, t1, "view_tag")
|
||||||
assign_perm("view_tag", group1, t1)
|
grant_object(group1, t1, "view_tag")
|
||||||
assign_perm("view_tag", group2, t1)
|
grant_object(group2, t1, "view_tag")
|
||||||
assign_perm("view_tag", group3, t1)
|
grant_object(group3, t1, "view_tag")
|
||||||
assign_perm("change_tag", self.user, t1)
|
grant_object(self.user, t1, "change_tag")
|
||||||
assign_perm("change_tag", user2, t1)
|
grant_object(user2, t1, "change_tag")
|
||||||
assign_perm("change_tag", group1, t1)
|
grant_object(group1, t1, "change_tag")
|
||||||
assign_perm("change_tag", group2, t1)
|
grant_object(group2, t1, "change_tag")
|
||||||
|
|
||||||
Tag.objects.create(name="bank statement", pk=2)
|
Tag.objects.create(name="bank statement", pk=2)
|
||||||
d1 = Document.objects.create(
|
d1 = Document.objects.create(
|
||||||
@@ -338,7 +336,7 @@ class TestViews(DirectoriesMixin, TestCase):
|
|||||||
|
|
||||||
class TestAISuggestions(DirectoriesMixin, TestCase):
|
class TestAISuggestions(DirectoriesMixin, TestCase):
|
||||||
def setUp(self) -> None:
|
def setUp(self) -> None:
|
||||||
self.user = User.objects.create_superuser(username="testuser")
|
self.user = UserFactory(username="testuser", superuser=True)
|
||||||
self.document = Document.objects.create(
|
self.document = Document.objects.create(
|
||||||
title="Test Document",
|
title="Test Document",
|
||||||
filename="test.pdf",
|
filename="test.pdf",
|
||||||
@@ -425,14 +423,10 @@ class TestAISuggestions(DirectoriesMixin, TestCase):
|
|||||||
requester; the invisible tag id does not leak into either the
|
requester; the invisible tag id does not leak into either the
|
||||||
matched or suggested tags
|
matched or suggested tags
|
||||||
"""
|
"""
|
||||||
tag_owner = User.objects.create_user(username="cache_tag_owner")
|
tag_owner = UserFactory(username="cache_tag_owner")
|
||||||
invisible_tag = Tag.objects.create(name="cache_restricted", owner=tag_owner)
|
invisible_tag = Tag.objects.create(name="cache_restricted", owner=tag_owner)
|
||||||
requester = User.objects.create_user(username="cache_requester")
|
requester = UserFactory(username="cache_requester")
|
||||||
requester.user_permissions.add(
|
grant_global(requester, "view_document", "change_document", "view_tag")
|
||||||
*Permission.objects.filter(
|
|
||||||
codename__in=["view_document", "change_document", "view_tag"],
|
|
||||||
),
|
|
||||||
)
|
|
||||||
mock_get_cache.return_value = MagicMock(
|
mock_get_cache.return_value = MagicMock(
|
||||||
suggestions={
|
suggestions={
|
||||||
"title": "Untitled",
|
"title": "Untitled",
|
||||||
@@ -637,7 +631,7 @@ class TestAISuggestions(DirectoriesMixin, TestCase):
|
|||||||
- The classification runs with the second user's visibility
|
- The classification runs with the second user's visibility
|
||||||
context without evicting the first user's result
|
context without evicting the first user's result
|
||||||
"""
|
"""
|
||||||
second_user = User.objects.create_superuser(username="second_user")
|
second_user = UserFactory(username="second_user", superuser=True)
|
||||||
empty_choices = {
|
empty_choices = {
|
||||||
"tags": {"existing_ids": [], "new_names": []},
|
"tags": {"existing_ids": [], "new_names": []},
|
||||||
"correspondents": {"existing_ids": [], "new_names": []},
|
"correspondents": {"existing_ids": [], "new_names": []},
|
||||||
@@ -875,14 +869,10 @@ class TestAISuggestions(DirectoriesMixin, TestCase):
|
|||||||
permission filtering survives the full request path
|
permission filtering survives the full request path
|
||||||
- it does not appear in either the matched or suggested tags
|
- it does not appear in either the matched or suggested tags
|
||||||
"""
|
"""
|
||||||
tag_owner = User.objects.create_user(username="tagowner")
|
tag_owner = UserFactory(username="tagowner")
|
||||||
invisible_tag = Tag.objects.create(name="restricted", owner=tag_owner)
|
invisible_tag = Tag.objects.create(name="restricted", owner=tag_owner)
|
||||||
requester = User.objects.create_user(username="requester")
|
requester = UserFactory(username="requester")
|
||||||
requester.user_permissions.add(
|
grant_global(requester, "view_document", "change_document", "view_tag")
|
||||||
*Permission.objects.filter(
|
|
||||||
codename__in=["view_document", "change_document", "view_tag"],
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
mock_get_ai_classification.return_value = {
|
mock_get_ai_classification.return_value = {
|
||||||
"title": "Untitled",
|
"title": "Untitled",
|
||||||
@@ -956,7 +946,7 @@ class TestAIChatStreamingView(DirectoriesMixin, TestCase):
|
|||||||
ENDPOINT = "/api/documents/chat/"
|
ENDPOINT = "/api/documents/chat/"
|
||||||
|
|
||||||
def setUp(self) -> None:
|
def setUp(self) -> None:
|
||||||
self.user = User.objects.create_user(username="testuser", password="pass")
|
self.user = UserFactory(username="testuser", password="pass")
|
||||||
self.client.force_login(user=self.user)
|
self.client.force_login(user=self.user)
|
||||||
self.document = Document.objects.create(
|
self.document = Document.objects.create(
|
||||||
title="Test Document",
|
title="Test Document",
|
||||||
@@ -966,9 +956,7 @@ class TestAIChatStreamingView(DirectoriesMixin, TestCase):
|
|||||||
super().setUp()
|
super().setUp()
|
||||||
|
|
||||||
def grant_view_document_permission(self) -> None:
|
def grant_view_document_permission(self) -> None:
|
||||||
self.user.user_permissions.add(
|
grant_global(self.user, "view_document")
|
||||||
*Permission.objects.filter(codename="view_document"),
|
|
||||||
)
|
|
||||||
|
|
||||||
@override_settings(AI_ENABLED=False)
|
@override_settings(AI_ENABLED=False)
|
||||||
def test_post_ai_disabled(self) -> None:
|
def test_post_ai_disabled(self) -> None:
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user