mirror of
https://github.com/paperless-ngx/paperless-ngx.git
synced 2026-09-28 20:30:33 +00:00
Compare commits
44
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
61f36eb253 | ||
|
|
e50bcc2efb | ||
|
|
88000aab45 | ||
|
|
01b4ab392c | ||
|
|
52aa8758e3 | ||
|
|
4b7cc1c0c3 | ||
|
|
f02d7455be | ||
|
|
a61c35fcd8 | ||
|
|
64552d5d5b | ||
|
|
a59edf3e5b | ||
|
|
6a5d06ee26 | ||
|
|
126ec414a8 | ||
|
|
773744d04b | ||
|
|
7b98085b31 | ||
|
|
b3bf220f56 | ||
|
|
d4429c3cc7 | ||
|
|
4f777d7438 | ||
|
|
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 |
@@ -59,7 +59,6 @@ updates:
|
|||||||
- "drf-*"
|
- "drf-*"
|
||||||
- "djangorestframework"
|
- "djangorestframework"
|
||||||
- "whitenoise"
|
- "whitenoise"
|
||||||
- "bleach"
|
|
||||||
- "jinja2"
|
- "jinja2"
|
||||||
# Async, Task Queuing & Caching
|
# Async, Task Queuing & Caching
|
||||||
async-tasks:
|
async-tasks:
|
||||||
|
|||||||
+706
-456
File diff suppressed because it is too large
Load Diff
+4804
-12941
File diff suppressed because one or more lines are too long
+3
-1
@@ -171,7 +171,9 @@ RUN set -eux \
|
|||||||
&& cp /etc/ImageMagick-6/paperless-policy.xml /etc/ImageMagick-6/policy.xml \
|
&& cp /etc/ImageMagick-6/paperless-policy.xml /etc/ImageMagick-6/policy.xml \
|
||||||
&& echo "Cleaning up image layer" \
|
&& echo "Cleaning up image layer" \
|
||||||
&& rm --force --verbose *.deb \
|
&& rm --force --verbose *.deb \
|
||||||
&& rm --recursive --force --verbose /var/lib/apt/lists/*
|
&& rm --recursive --force --verbose /var/lib/apt/lists/* \
|
||||||
|
&& echo "Configuring interactive shells to source the s6 container environment" \
|
||||||
|
&& echo '. /etc/profile.d/contenv.sh' >> /etc/bash.bashrc
|
||||||
|
|
||||||
WORKDIR /usr/src/paperless/src/
|
WORKDIR /usr/src/paperless/src/
|
||||||
|
|
||||||
|
|||||||
Executable
+18
@@ -0,0 +1,18 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
# Source s6 container environment for interactive shells.
|
||||||
|
# Ensures variables resolved from *_FILE secret injection are visible
|
||||||
|
# when using 'docker exec bash'. Does not affect s6 services (those
|
||||||
|
# use with-contenv directly). Has no effect in non-container contexts
|
||||||
|
# because the directory will not exist.
|
||||||
|
# Note: sh/dash shells opened via 'docker exec sh' are not covered;
|
||||||
|
# only bash-based sessions benefit from this file.
|
||||||
|
_pngx_contenv="/run/s6/container_environment"
|
||||||
|
if [ -d "${_pngx_contenv}" ]; then
|
||||||
|
for _pngx_f in "${_pngx_contenv}"/*; do
|
||||||
|
[ -f "${_pngx_f}" ] || continue
|
||||||
|
_pngx_name=$(basename "${_pngx_f}")
|
||||||
|
_pngx_val=$(cat "${_pngx_f}")
|
||||||
|
export "${_pngx_name}=${_pngx_val}"
|
||||||
|
done
|
||||||
|
fi
|
||||||
|
unset _pngx_contenv _pngx_f _pngx_name _pngx_val
|
||||||
@@ -153,8 +153,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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
+8
-2
@@ -19,7 +19,6 @@ classifiers = [
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"azure-ai-documentintelligence>=1.0.2",
|
"azure-ai-documentintelligence>=1.0.2",
|
||||||
"babel>=2.17",
|
"babel>=2.17",
|
||||||
"bleach~=6.4.0",
|
|
||||||
"celery[redis]~=5.6.2",
|
"celery[redis]~=5.6.2",
|
||||||
"channels~=4.2",
|
"channels~=4.2",
|
||||||
"channels-redis~=4.2",
|
"channels-redis~=4.2",
|
||||||
@@ -77,6 +76,7 @@ dependencies = [
|
|||||||
"tantivy~=0.26.0",
|
"tantivy~=0.26.0",
|
||||||
"tika-client[httpx]~=1.0",
|
"tika-client[httpx]~=1.0",
|
||||||
"torch>=2.13,<2.15",
|
"torch>=2.13,<2.15",
|
||||||
|
"turbohtml~=1.10.0",
|
||||||
"watchfiles>=1.2",
|
"watchfiles>=1.2",
|
||||||
"whitenoise~=6.11",
|
"whitenoise~=6.11",
|
||||||
"whoosh-compat[tantivy]==0.3",
|
"whoosh-compat[tantivy]==0.3",
|
||||||
@@ -136,7 +136,6 @@ typing = [
|
|||||||
"mypy",
|
"mypy",
|
||||||
"mypy-baseline",
|
"mypy-baseline",
|
||||||
"pyrefly",
|
"pyrefly",
|
||||||
"types-bleach",
|
|
||||||
"types-channels",
|
"types-channels",
|
||||||
"types-colorama",
|
"types-colorama",
|
||||||
"types-dateparser",
|
"types-dateparser",
|
||||||
@@ -151,6 +150,7 @@ typing = [
|
|||||||
|
|
||||||
[tool.uv]
|
[tool.uv]
|
||||||
required-version = ">=0.9.0"
|
required-version = ">=0.9.0"
|
||||||
|
prerelease = "disallow"
|
||||||
environments = [
|
environments = [
|
||||||
"sys_platform == 'darwin'",
|
"sys_platform == 'darwin'",
|
||||||
"sys_platform == 'linux'",
|
"sys_platform == 'linux'",
|
||||||
@@ -247,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]
|
||||||
@@ -329,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 = [
|
||||||
|
|||||||
+70
-56
@@ -5,14 +5,14 @@
|
|||||||
<trans-unit id="ngb.alert.close" datatype="html">
|
<trans-unit id="ngb.alert.close" datatype="html">
|
||||||
<source>Close</source>
|
<source>Close</source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@21.0.0_@angular+common@22.1.6_@angular+core@22.1.6_@angular+_0f0da7e9cfc35b268da39b83c54e3258/node_modules/src/alert/alert.ts</context>
|
<context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@21.0.0_@angular+common@22.1.7_@angular+core@22.1.7_@angular+_bdd9adf606c7b6a0170d9f35c5973e0f/node_modules/src/alert/alert.ts</context>
|
||||||
<context context-type="linenumber">39</context>
|
<context context-type="linenumber">39</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="ngb.carousel.slide-number" datatype="html">
|
<trans-unit id="ngb.carousel.slide-number" datatype="html">
|
||||||
<source> Slide <x id="INTERPOLATION"/> of <x id="INTERPOLATION_1"/> </source>
|
<source> Slide <x id="INTERPOLATION"/> of <x id="INTERPOLATION_1"/> </source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@21.0.0_@angular+common@22.1.6_@angular+core@22.1.6_@angular+_0f0da7e9cfc35b268da39b83c54e3258/node_modules/src/carousel/carousel.ts</context>
|
<context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@21.0.0_@angular+common@22.1.7_@angular+core@22.1.7_@angular+_bdd9adf606c7b6a0170d9f35c5973e0f/node_modules/src/carousel/carousel.ts</context>
|
||||||
<context context-type="linenumber">129</context>
|
<context context-type="linenumber">129</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
<note priority="1" from="description">Currently selected slide number read by screen reader</note>
|
<note priority="1" from="description">Currently selected slide number read by screen reader</note>
|
||||||
@@ -20,219 +20,219 @@
|
|||||||
<trans-unit id="ngb.carousel.previous" datatype="html">
|
<trans-unit id="ngb.carousel.previous" datatype="html">
|
||||||
<source>Previous</source>
|
<source>Previous</source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@21.0.0_@angular+common@22.1.6_@angular+core@22.1.6_@angular+_0f0da7e9cfc35b268da39b83c54e3258/node_modules/src/carousel/carousel.ts</context>
|
<context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@21.0.0_@angular+common@22.1.7_@angular+core@22.1.7_@angular+_bdd9adf606c7b6a0170d9f35c5973e0f/node_modules/src/carousel/carousel.ts</context>
|
||||||
<context context-type="linenumber">147,148</context>
|
<context context-type="linenumber">147,148</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="ngb.carousel.next" datatype="html">
|
<trans-unit id="ngb.carousel.next" datatype="html">
|
||||||
<source>Next</source>
|
<source>Next</source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@21.0.0_@angular+common@22.1.6_@angular+core@22.1.6_@angular+_0f0da7e9cfc35b268da39b83c54e3258/node_modules/src/carousel/carousel.ts</context>
|
<context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@21.0.0_@angular+common@22.1.7_@angular+core@22.1.7_@angular+_bdd9adf606c7b6a0170d9f35c5973e0f/node_modules/src/carousel/carousel.ts</context>
|
||||||
<context context-type="linenumber">187</context>
|
<context context-type="linenumber">187</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="ngb.datepicker.select-month" datatype="html">
|
<trans-unit id="ngb.datepicker.select-month" datatype="html">
|
||||||
<source>Select month</source>
|
<source>Select month</source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@21.0.0_@angular+common@22.1.6_@angular+core@22.1.6_@angular+_0f0da7e9cfc35b268da39b83c54e3258/node_modules/src/datepicker/datepicker-navigation-select.ts</context>
|
<context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@21.0.0_@angular+common@22.1.7_@angular+core@22.1.7_@angular+_bdd9adf606c7b6a0170d9f35c5973e0f/node_modules/src/datepicker/datepicker-navigation-select.ts</context>
|
||||||
<context context-type="linenumber">89</context>
|
<context context-type="linenumber">89</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@21.0.0_@angular+common@22.1.6_@angular+core@22.1.6_@angular+_0f0da7e9cfc35b268da39b83c54e3258/node_modules/src/datepicker/datepicker-navigation-select.ts</context>
|
<context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@21.0.0_@angular+common@22.1.7_@angular+core@22.1.7_@angular+_bdd9adf606c7b6a0170d9f35c5973e0f/node_modules/src/datepicker/datepicker-navigation-select.ts</context>
|
||||||
<context context-type="linenumber">89</context>
|
<context context-type="linenumber">89</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="ngb.datepicker.select-year" datatype="html">
|
<trans-unit id="ngb.datepicker.select-year" datatype="html">
|
||||||
<source>Select year</source>
|
<source>Select year</source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@21.0.0_@angular+common@22.1.6_@angular+core@22.1.6_@angular+_0f0da7e9cfc35b268da39b83c54e3258/node_modules/src/datepicker/datepicker-navigation-select.ts</context>
|
<context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@21.0.0_@angular+common@22.1.7_@angular+core@22.1.7_@angular+_bdd9adf606c7b6a0170d9f35c5973e0f/node_modules/src/datepicker/datepicker-navigation-select.ts</context>
|
||||||
<context context-type="linenumber">89</context>
|
<context context-type="linenumber">89</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@21.0.0_@angular+common@22.1.6_@angular+core@22.1.6_@angular+_0f0da7e9cfc35b268da39b83c54e3258/node_modules/src/datepicker/datepicker-navigation-select.ts</context>
|
<context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@21.0.0_@angular+common@22.1.7_@angular+core@22.1.7_@angular+_bdd9adf606c7b6a0170d9f35c5973e0f/node_modules/src/datepicker/datepicker-navigation-select.ts</context>
|
||||||
<context context-type="linenumber">89</context>
|
<context context-type="linenumber">89</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="ngb.datepicker.previous-month" datatype="html">
|
<trans-unit id="ngb.datepicker.previous-month" datatype="html">
|
||||||
<source>Previous month</source>
|
<source>Previous month</source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@21.0.0_@angular+common@22.1.6_@angular+core@22.1.6_@angular+_0f0da7e9cfc35b268da39b83c54e3258/node_modules/src/datepicker/datepicker-navigation.ts</context>
|
<context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@21.0.0_@angular+common@22.1.7_@angular+core@22.1.7_@angular+_bdd9adf606c7b6a0170d9f35c5973e0f/node_modules/src/datepicker/datepicker-navigation.ts</context>
|
||||||
<context context-type="linenumber">74,76</context>
|
<context context-type="linenumber">74,76</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@21.0.0_@angular+common@22.1.6_@angular+core@22.1.6_@angular+_0f0da7e9cfc35b268da39b83c54e3258/node_modules/src/datepicker/datepicker-navigation.ts</context>
|
<context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@21.0.0_@angular+common@22.1.7_@angular+core@22.1.7_@angular+_bdd9adf606c7b6a0170d9f35c5973e0f/node_modules/src/datepicker/datepicker-navigation.ts</context>
|
||||||
<context context-type="linenumber">103</context>
|
<context context-type="linenumber">103</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="ngb.datepicker.next-month" datatype="html">
|
<trans-unit id="ngb.datepicker.next-month" datatype="html">
|
||||||
<source>Next month</source>
|
<source>Next month</source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@21.0.0_@angular+common@22.1.6_@angular+core@22.1.6_@angular+_0f0da7e9cfc35b268da39b83c54e3258/node_modules/src/datepicker/datepicker-navigation.ts</context>
|
<context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@21.0.0_@angular+common@22.1.7_@angular+core@22.1.7_@angular+_bdd9adf606c7b6a0170d9f35c5973e0f/node_modules/src/datepicker/datepicker-navigation.ts</context>
|
||||||
<context context-type="linenumber">103</context>
|
<context context-type="linenumber">103</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@21.0.0_@angular+common@22.1.6_@angular+core@22.1.6_@angular+_0f0da7e9cfc35b268da39b83c54e3258/node_modules/src/datepicker/datepicker-navigation.ts</context>
|
<context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@21.0.0_@angular+common@22.1.7_@angular+core@22.1.7_@angular+_bdd9adf606c7b6a0170d9f35c5973e0f/node_modules/src/datepicker/datepicker-navigation.ts</context>
|
||||||
<context context-type="linenumber">103</context>
|
<context context-type="linenumber">103</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="ngb.pagination.first" datatype="html">
|
<trans-unit id="ngb.pagination.first" datatype="html">
|
||||||
<source>««</source>
|
<source>««</source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@21.0.0_@angular+common@22.1.6_@angular+core@22.1.6_@angular+_0f0da7e9cfc35b268da39b83c54e3258/node_modules/src/pagination/pagination.ts</context>
|
<context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@21.0.0_@angular+common@22.1.7_@angular+core@22.1.7_@angular+_bdd9adf606c7b6a0170d9f35c5973e0f/node_modules/src/pagination/pagination.ts</context>
|
||||||
<context context-type="linenumber">297</context>
|
<context context-type="linenumber">297</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="ngb.pagination.previous" datatype="html">
|
<trans-unit id="ngb.pagination.previous" datatype="html">
|
||||||
<source>«</source>
|
<source>«</source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@21.0.0_@angular+common@22.1.6_@angular+core@22.1.6_@angular+_0f0da7e9cfc35b268da39b83c54e3258/node_modules/src/pagination/pagination.ts</context>
|
<context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@21.0.0_@angular+common@22.1.7_@angular+core@22.1.7_@angular+_bdd9adf606c7b6a0170d9f35c5973e0f/node_modules/src/pagination/pagination.ts</context>
|
||||||
<context context-type="linenumber">322</context>
|
<context context-type="linenumber">322</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="ngb.pagination.next" datatype="html">
|
<trans-unit id="ngb.pagination.next" datatype="html">
|
||||||
<source>»</source>
|
<source>»</source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@21.0.0_@angular+common@22.1.6_@angular+core@22.1.6_@angular+_0f0da7e9cfc35b268da39b83c54e3258/node_modules/src/pagination/pagination.ts</context>
|
<context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@21.0.0_@angular+common@22.1.7_@angular+core@22.1.7_@angular+_bdd9adf606c7b6a0170d9f35c5973e0f/node_modules/src/pagination/pagination.ts</context>
|
||||||
<context context-type="linenumber">358</context>
|
<context context-type="linenumber">358</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="ngb.pagination.last" datatype="html">
|
<trans-unit id="ngb.pagination.last" datatype="html">
|
||||||
<source>»»</source>
|
<source>»»</source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@21.0.0_@angular+common@22.1.6_@angular+core@22.1.6_@angular+_0f0da7e9cfc35b268da39b83c54e3258/node_modules/src/pagination/pagination.ts</context>
|
<context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@21.0.0_@angular+common@22.1.7_@angular+core@22.1.7_@angular+_bdd9adf606c7b6a0170d9f35c5973e0f/node_modules/src/pagination/pagination.ts</context>
|
||||||
<context context-type="linenumber">396</context>
|
<context context-type="linenumber">396</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="ngb.pagination.first-aria" datatype="html">
|
<trans-unit id="ngb.pagination.first-aria" datatype="html">
|
||||||
<source>First</source>
|
<source>First</source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@21.0.0_@angular+common@22.1.6_@angular+core@22.1.6_@angular+_0f0da7e9cfc35b268da39b83c54e3258/node_modules/src/pagination/pagination.ts</context>
|
<context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@21.0.0_@angular+common@22.1.7_@angular+core@22.1.7_@angular+_bdd9adf606c7b6a0170d9f35c5973e0f/node_modules/src/pagination/pagination.ts</context>
|
||||||
<context context-type="linenumber">414,415</context>
|
<context context-type="linenumber">414,415</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="ngb.pagination.previous-aria" datatype="html">
|
<trans-unit id="ngb.pagination.previous-aria" datatype="html">
|
||||||
<source>Previous</source>
|
<source>Previous</source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@21.0.0_@angular+common@22.1.6_@angular+core@22.1.6_@angular+_0f0da7e9cfc35b268da39b83c54e3258/node_modules/src/pagination/pagination.ts</context>
|
<context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@21.0.0_@angular+common@22.1.7_@angular+core@22.1.7_@angular+_bdd9adf606c7b6a0170d9f35c5973e0f/node_modules/src/pagination/pagination.ts</context>
|
||||||
<context context-type="linenumber">444,445</context>
|
<context context-type="linenumber">444,445</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="ngb.pagination.next-aria" datatype="html">
|
<trans-unit id="ngb.pagination.next-aria" datatype="html">
|
||||||
<source>Next</source>
|
<source>Next</source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@21.0.0_@angular+common@22.1.6_@angular+core@22.1.6_@angular+_0f0da7e9cfc35b268da39b83c54e3258/node_modules/src/pagination/pagination.ts</context>
|
<context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@21.0.0_@angular+common@22.1.7_@angular+core@22.1.7_@angular+_bdd9adf606c7b6a0170d9f35c5973e0f/node_modules/src/pagination/pagination.ts</context>
|
||||||
<context context-type="linenumber">479,480</context>
|
<context context-type="linenumber">479,480</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="ngb.pagination.last-aria" datatype="html">
|
<trans-unit id="ngb.pagination.last-aria" datatype="html">
|
||||||
<source>Last</source>
|
<source>Last</source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@21.0.0_@angular+common@22.1.6_@angular+core@22.1.6_@angular+_0f0da7e9cfc35b268da39b83c54e3258/node_modules/src/pagination/pagination.ts</context>
|
<context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@21.0.0_@angular+common@22.1.7_@angular+core@22.1.7_@angular+_bdd9adf606c7b6a0170d9f35c5973e0f/node_modules/src/pagination/pagination.ts</context>
|
||||||
<context context-type="linenumber">506</context>
|
<context context-type="linenumber">506</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="ngb.progressbar.value" datatype="html">
|
<trans-unit id="ngb.progressbar.value" datatype="html">
|
||||||
<source><x id="INTERPOLATION"/></source>
|
<source><x id="INTERPOLATION"/></source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@21.0.0_@angular+common@22.1.6_@angular+core@22.1.6_@angular+_0f0da7e9cfc35b268da39b83c54e3258/node_modules/src/progressbar/progressbar.ts</context>
|
<context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@21.0.0_@angular+common@22.1.7_@angular+core@22.1.7_@angular+_bdd9adf606c7b6a0170d9f35c5973e0f/node_modules/src/progressbar/progressbar.ts</context>
|
||||||
<context context-type="linenumber">40</context>
|
<context context-type="linenumber">40</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="ngb.timepicker.HH" datatype="html">
|
<trans-unit id="ngb.timepicker.HH" datatype="html">
|
||||||
<source>HH</source>
|
<source>HH</source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@21.0.0_@angular+common@22.1.6_@angular+core@22.1.6_@angular+_0f0da7e9cfc35b268da39b83c54e3258/node_modules/src/timepicker/timepicker.ts</context>
|
<context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@21.0.0_@angular+common@22.1.7_@angular+core@22.1.7_@angular+_bdd9adf606c7b6a0170d9f35c5973e0f/node_modules/src/timepicker/timepicker.ts</context>
|
||||||
<context context-type="linenumber">256,259</context>
|
<context context-type="linenumber">256,259</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="ngb.timepicker.hours" datatype="html">
|
<trans-unit id="ngb.timepicker.hours" datatype="html">
|
||||||
<source>Hours</source>
|
<source>Hours</source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@21.0.0_@angular+common@22.1.6_@angular+core@22.1.6_@angular+_0f0da7e9cfc35b268da39b83c54e3258/node_modules/src/timepicker/timepicker.ts</context>
|
<context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@21.0.0_@angular+common@22.1.7_@angular+core@22.1.7_@angular+_bdd9adf606c7b6a0170d9f35c5973e0f/node_modules/src/timepicker/timepicker.ts</context>
|
||||||
<context context-type="linenumber">293,295</context>
|
<context context-type="linenumber">293,295</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="ngb.timepicker.MM" datatype="html">
|
<trans-unit id="ngb.timepicker.MM" datatype="html">
|
||||||
<source>MM</source>
|
<source>MM</source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@21.0.0_@angular+common@22.1.6_@angular+core@22.1.6_@angular+_0f0da7e9cfc35b268da39b83c54e3258/node_modules/src/timepicker/timepicker.ts</context>
|
<context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@21.0.0_@angular+common@22.1.7_@angular+core@22.1.7_@angular+_bdd9adf606c7b6a0170d9f35c5973e0f/node_modules/src/timepicker/timepicker.ts</context>
|
||||||
<context context-type="linenumber">322</context>
|
<context context-type="linenumber">322</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="ngb.timepicker.minutes" datatype="html">
|
<trans-unit id="ngb.timepicker.minutes" datatype="html">
|
||||||
<source>Minutes</source>
|
<source>Minutes</source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@21.0.0_@angular+common@22.1.6_@angular+core@22.1.6_@angular+_0f0da7e9cfc35b268da39b83c54e3258/node_modules/src/timepicker/timepicker.ts</context>
|
<context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@21.0.0_@angular+common@22.1.7_@angular+core@22.1.7_@angular+_bdd9adf606c7b6a0170d9f35c5973e0f/node_modules/src/timepicker/timepicker.ts</context>
|
||||||
<context context-type="linenumber">360</context>
|
<context context-type="linenumber">360</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="ngb.timepicker.increment-hours" datatype="html">
|
<trans-unit id="ngb.timepicker.increment-hours" datatype="html">
|
||||||
<source>Increment hours</source>
|
<source>Increment hours</source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@21.0.0_@angular+common@22.1.6_@angular+core@22.1.6_@angular+_0f0da7e9cfc35b268da39b83c54e3258/node_modules/src/timepicker/timepicker.ts</context>
|
<context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@21.0.0_@angular+common@22.1.7_@angular+core@22.1.7_@angular+_bdd9adf606c7b6a0170d9f35c5973e0f/node_modules/src/timepicker/timepicker.ts</context>
|
||||||
<context context-type="linenumber">397,399</context>
|
<context context-type="linenumber">397,399</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="ngb.timepicker.decrement-hours" datatype="html">
|
<trans-unit id="ngb.timepicker.decrement-hours" datatype="html">
|
||||||
<source>Decrement hours</source>
|
<source>Decrement hours</source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@21.0.0_@angular+common@22.1.6_@angular+core@22.1.6_@angular+_0f0da7e9cfc35b268da39b83c54e3258/node_modules/src/timepicker/timepicker.ts</context>
|
<context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@21.0.0_@angular+common@22.1.7_@angular+core@22.1.7_@angular+_bdd9adf606c7b6a0170d9f35c5973e0f/node_modules/src/timepicker/timepicker.ts</context>
|
||||||
<context context-type="linenumber">435,438</context>
|
<context context-type="linenumber">435,438</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="ngb.timepicker.increment-minutes" datatype="html">
|
<trans-unit id="ngb.timepicker.increment-minutes" datatype="html">
|
||||||
<source>Increment minutes</source>
|
<source>Increment minutes</source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@21.0.0_@angular+common@22.1.6_@angular+core@22.1.6_@angular+_0f0da7e9cfc35b268da39b83c54e3258/node_modules/src/timepicker/timepicker.ts</context>
|
<context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@21.0.0_@angular+common@22.1.7_@angular+core@22.1.7_@angular+_bdd9adf606c7b6a0170d9f35c5973e0f/node_modules/src/timepicker/timepicker.ts</context>
|
||||||
<context context-type="linenumber">442</context>
|
<context context-type="linenumber">442</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="ngb.timepicker.decrement-minutes" datatype="html">
|
<trans-unit id="ngb.timepicker.decrement-minutes" datatype="html">
|
||||||
<source>Decrement minutes</source>
|
<source>Decrement minutes</source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@21.0.0_@angular+common@22.1.6_@angular+core@22.1.6_@angular+_0f0da7e9cfc35b268da39b83c54e3258/node_modules/src/timepicker/timepicker.ts</context>
|
<context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@21.0.0_@angular+common@22.1.7_@angular+core@22.1.7_@angular+_bdd9adf606c7b6a0170d9f35c5973e0f/node_modules/src/timepicker/timepicker.ts</context>
|
||||||
<context context-type="linenumber">442</context>
|
<context context-type="linenumber">442</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="ngb.timepicker.SS" datatype="html">
|
<trans-unit id="ngb.timepicker.SS" datatype="html">
|
||||||
<source>SS</source>
|
<source>SS</source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@21.0.0_@angular+common@22.1.6_@angular+core@22.1.6_@angular+_0f0da7e9cfc35b268da39b83c54e3258/node_modules/src/timepicker/timepicker.ts</context>
|
<context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@21.0.0_@angular+common@22.1.7_@angular+core@22.1.7_@angular+_bdd9adf606c7b6a0170d9f35c5973e0f/node_modules/src/timepicker/timepicker.ts</context>
|
||||||
<context context-type="linenumber">442</context>
|
<context context-type="linenumber">442</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="ngb.timepicker.seconds" datatype="html">
|
<trans-unit id="ngb.timepicker.seconds" datatype="html">
|
||||||
<source>Seconds</source>
|
<source>Seconds</source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@21.0.0_@angular+common@22.1.6_@angular+core@22.1.6_@angular+_0f0da7e9cfc35b268da39b83c54e3258/node_modules/src/timepicker/timepicker.ts</context>
|
<context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@21.0.0_@angular+common@22.1.7_@angular+core@22.1.7_@angular+_bdd9adf606c7b6a0170d9f35c5973e0f/node_modules/src/timepicker/timepicker.ts</context>
|
||||||
<context context-type="linenumber">442</context>
|
<context context-type="linenumber">442</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="ngb.timepicker.increment-seconds" datatype="html">
|
<trans-unit id="ngb.timepicker.increment-seconds" datatype="html">
|
||||||
<source>Increment seconds</source>
|
<source>Increment seconds</source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@21.0.0_@angular+common@22.1.6_@angular+core@22.1.6_@angular+_0f0da7e9cfc35b268da39b83c54e3258/node_modules/src/timepicker/timepicker.ts</context>
|
<context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@21.0.0_@angular+common@22.1.7_@angular+core@22.1.7_@angular+_bdd9adf606c7b6a0170d9f35c5973e0f/node_modules/src/timepicker/timepicker.ts</context>
|
||||||
<context context-type="linenumber">442</context>
|
<context context-type="linenumber">442</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="ngb.timepicker.decrement-seconds" datatype="html">
|
<trans-unit id="ngb.timepicker.decrement-seconds" datatype="html">
|
||||||
<source>Decrement seconds</source>
|
<source>Decrement seconds</source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@21.0.0_@angular+common@22.1.6_@angular+core@22.1.6_@angular+_0f0da7e9cfc35b268da39b83c54e3258/node_modules/src/timepicker/timepicker.ts</context>
|
<context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@21.0.0_@angular+common@22.1.7_@angular+core@22.1.7_@angular+_bdd9adf606c7b6a0170d9f35c5973e0f/node_modules/src/timepicker/timepicker.ts</context>
|
||||||
<context context-type="linenumber">442</context>
|
<context context-type="linenumber">442</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="ngb.timepicker.PM" datatype="html">
|
<trans-unit id="ngb.timepicker.PM" datatype="html">
|
||||||
<source><x id="INTERPOLATION"/></source>
|
<source><x id="INTERPOLATION"/></source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@21.0.0_@angular+common@22.1.6_@angular+core@22.1.6_@angular+_0f0da7e9cfc35b268da39b83c54e3258/node_modules/src/timepicker/timepicker.ts</context>
|
<context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@21.0.0_@angular+common@22.1.7_@angular+core@22.1.7_@angular+_bdd9adf606c7b6a0170d9f35c5973e0f/node_modules/src/timepicker/timepicker.ts</context>
|
||||||
<context context-type="linenumber">442</context>
|
<context context-type="linenumber">442</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="ngb.toast.close-aria" datatype="html">
|
<trans-unit id="ngb.toast.close-aria" datatype="html">
|
||||||
<source>Close</source>
|
<source>Close</source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@21.0.0_@angular+common@22.1.6_@angular+core@22.1.6_@angular+_0f0da7e9cfc35b268da39b83c54e3258/node_modules/src/toast/toast.ts</context>
|
<context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@21.0.0_@angular+common@22.1.7_@angular+core@22.1.7_@angular+_bdd9adf606c7b6a0170d9f35c5973e0f/node_modules/src/toast/toast.ts</context>
|
||||||
<context context-type="linenumber">110,112</context>
|
<context context-type="linenumber">110,112</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
@@ -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">
|
||||||
|
|||||||
+15
-15
@@ -15,16 +15,16 @@
|
|||||||
},
|
},
|
||||||
"private": true,
|
"private": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@angular/cdk": "^22.1.6",
|
"@angular/cdk": "^22.1.7",
|
||||||
"@angular/common": "~22.1.6",
|
"@angular/common": "~22.1.7",
|
||||||
"@angular/compiler": "~22.1.6",
|
"@angular/compiler": "~22.1.7",
|
||||||
"@angular/core": "~22.1.6",
|
"@angular/core": "~22.1.7",
|
||||||
"@angular/forms": "~22.1.6",
|
"@angular/forms": "~22.1.7",
|
||||||
"@angular/localize": "~22.1.6",
|
"@angular/localize": "~22.1.7",
|
||||||
"@angular/platform-browser": "~22.1.6",
|
"@angular/platform-browser": "~22.1.7",
|
||||||
"@angular/router": "~22.1.6",
|
"@angular/router": "~22.1.7",
|
||||||
"@ng-bootstrap/ng-bootstrap": "^21.0.0",
|
"@ng-bootstrap/ng-bootstrap": "^21.0.0",
|
||||||
"@ng-select/ng-select": "~24.1.1",
|
"@ng-select/ng-select": "~24.1.2",
|
||||||
"@ngneat/dirty-check-forms": "^3.0.3",
|
"@ngneat/dirty-check-forms": "^3.0.3",
|
||||||
"@popperjs/core": "^2.11.8",
|
"@popperjs/core": "^2.11.8",
|
||||||
"bootstrap": "^5.3.8",
|
"bootstrap": "^5.3.8",
|
||||||
@@ -54,20 +54,20 @@
|
|||||||
"@angular-eslint/template-parser": "22.5.0",
|
"@angular-eslint/template-parser": "22.5.0",
|
||||||
"@angular/build": "22.1.8",
|
"@angular/build": "22.1.8",
|
||||||
"@angular/cli": "22.1.8",
|
"@angular/cli": "22.1.8",
|
||||||
"@angular/compiler-cli": "~22.1.6",
|
"@angular/compiler-cli": "~22.1.7",
|
||||||
"@playwright/test": "^1.62.1",
|
"@playwright/test": "^1.62.1",
|
||||||
"@types/jest": "^30.0.0",
|
"@types/jest": "^30.0.0",
|
||||||
"@types/node": "^26.5.0",
|
"@types/node": "^26.6.2",
|
||||||
"@typescript-eslint/eslint-plugin": "^8.70.0",
|
"@typescript-eslint/eslint-plugin": "^8.70.0",
|
||||||
"@typescript-eslint/parser": "^8.70.0",
|
"@typescript-eslint/parser": "^8.70.0",
|
||||||
"@typescript-eslint/utils": "^8.70.0",
|
"@typescript-eslint/utils": "^8.70.0",
|
||||||
"eslint": "^10.10.0",
|
"eslint": "^10.11.0",
|
||||||
"jest": "30.5.1",
|
"jest": "30.5.2",
|
||||||
"jest-environment-jsdom": "^30.5.1",
|
"jest-environment-jsdom": "^30.5.2",
|
||||||
"jest-junit": "^17.0.0",
|
"jest-junit": "^17.0.0",
|
||||||
"jest-preset-angular": "^17.0.0",
|
"jest-preset-angular": "^17.0.0",
|
||||||
"jest-websocket-mock": "^2.5.0",
|
"jest-websocket-mock": "^2.5.0",
|
||||||
"prettier": "^3.9.6",
|
"prettier": "^3.9.8",
|
||||||
"prettier-plugin-organize-imports": "^4.3.0",
|
"prettier-plugin-organize-imports": "^4.3.0",
|
||||||
"ts-node": "~10.9.2",
|
"ts-node": "~10.9.2",
|
||||||
"typescript": "^6.0.3"
|
"typescript": "^6.0.3"
|
||||||
|
|||||||
Generated
+531
-518
File diff suppressed because it is too large
Load Diff
@@ -154,11 +154,29 @@
|
|||||||
& section {
|
& section {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
text-align: initial;
|
text-align: initial;
|
||||||
|
pointer-events: auto;
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
transform-origin: 0 0;
|
transform-origin: 0 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
& :is(.linkAnnotation, .buttonWidgetAnnotation.pushButton) > a {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
font-size: 1em;
|
||||||
|
transition: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
& :is(.linkAnnotation, .buttonWidgetAnnotation.pushButton):not(.hasBorder)
|
||||||
|
> a:hover {
|
||||||
|
opacity: 0.2;
|
||||||
|
background-color: rgb(255 255 0);
|
||||||
|
}
|
||||||
|
|
||||||
& .annotationTextContent {
|
& .annotationTextContent {
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
:host ::ng-deep .textLayer.selecting ~ .annotationLayer section {
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,7 +1,11 @@
|
|||||||
import { SimpleChange } from '@angular/core'
|
import { SimpleChange } from '@angular/core'
|
||||||
import { ComponentFixture, TestBed } from '@angular/core/testing'
|
import { ComponentFixture, TestBed } from '@angular/core/testing'
|
||||||
import * as pdfjs from 'pdfjs-dist/legacy/build/pdf.mjs'
|
import * as pdfjs from 'pdfjs-dist/legacy/build/pdf.mjs'
|
||||||
import { PDFSinglePageViewer, PDFViewer } from 'pdfjs-dist/web/pdf_viewer.mjs'
|
import {
|
||||||
|
LinkTarget,
|
||||||
|
PDFSinglePageViewer,
|
||||||
|
PDFViewer,
|
||||||
|
} from 'pdfjs-dist/web/pdf_viewer.mjs'
|
||||||
import { PngxPdfViewerComponent } from './pdf-viewer.component'
|
import { PngxPdfViewerComponent } from './pdf-viewer.component'
|
||||||
import { PdfRenderMode, PdfZoomLevel, PdfZoomScale } from './pdf-viewer.types'
|
import { PdfRenderMode, PdfZoomLevel, PdfZoomScale } from './pdf-viewer.types'
|
||||||
|
|
||||||
@@ -58,6 +62,16 @@ describe('PngxPdfViewerComponent', () => {
|
|||||||
expect((component as any).pdfViewer).toBeInstanceOf(PDFViewer)
|
expect((component as any).pdfViewer).toBeInstanceOf(PDFViewer)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('opens external links in a new tab', () => {
|
||||||
|
const linkService = (component as any).linkService
|
||||||
|
expect(linkService.options).toEqual(
|
||||||
|
expect.objectContaining({
|
||||||
|
externalLinkTarget: LinkTarget.BLANK,
|
||||||
|
externalLinkRel: 'noopener noreferrer nofollow',
|
||||||
|
})
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
it('resolves the worker source relative to the document base URI', async () => {
|
it('resolves the worker source relative to the document base URI', async () => {
|
||||||
setBaseHref('/paperless/')
|
setBaseHref('/paperless/')
|
||||||
const getDocumentSpy = jest.spyOn(pdfjs, 'getDocument')
|
const getDocumentSpy = jest.spyOn(pdfjs, 'getDocument')
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ import {
|
|||||||
} from 'pdfjs-dist/legacy/build/pdf.mjs'
|
} from 'pdfjs-dist/legacy/build/pdf.mjs'
|
||||||
import {
|
import {
|
||||||
EventBus,
|
EventBus,
|
||||||
|
LinkTarget,
|
||||||
PDFFindController,
|
PDFFindController,
|
||||||
PDFLinkService,
|
PDFLinkService,
|
||||||
PDFSinglePageViewer,
|
PDFSinglePageViewer,
|
||||||
@@ -75,7 +76,11 @@ export class PngxPdfViewerComponent
|
|||||||
private lastViewerPage?: number
|
private lastViewerPage?: number
|
||||||
|
|
||||||
private readonly eventBus = new EventBus()
|
private readonly eventBus = new EventBus()
|
||||||
private readonly linkService = new PDFLinkService({ eventBus: this.eventBus })
|
private readonly linkService = new PDFLinkService({
|
||||||
|
eventBus: this.eventBus,
|
||||||
|
externalLinkTarget: LinkTarget.BLANK,
|
||||||
|
externalLinkRel: 'noopener noreferrer nofollow',
|
||||||
|
})
|
||||||
private readonly findController = new PDFFindController({
|
private readonly findController = new PDFFindController({
|
||||||
eventBus: this.eventBus,
|
eventBus: this.eventBus,
|
||||||
linkService: this.linkService,
|
linkService: this.linkService,
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -25,10 +25,20 @@ export class PDFFindController {
|
|||||||
onIsPageVisible?: () => boolean
|
onIsPageVisible?: () => boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const LinkTarget = {
|
||||||
|
NONE: 0,
|
||||||
|
SELF: 1,
|
||||||
|
BLANK: 2,
|
||||||
|
PARENT: 3,
|
||||||
|
TOP: 4,
|
||||||
|
}
|
||||||
|
|
||||||
export class PDFLinkService {
|
export class PDFLinkService {
|
||||||
private document?: unknown
|
private document?: unknown
|
||||||
private viewer?: unknown
|
private viewer?: unknown
|
||||||
|
|
||||||
|
constructor(readonly options: Record<string, unknown> = {}) {}
|
||||||
|
|
||||||
setDocument(document: unknown): void {
|
setDocument(document: unknown): void {
|
||||||
this.document = document
|
this.document = document
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ if TYPE_CHECKING:
|
|||||||
from rest_framework.test import APIClient
|
from rest_framework.test import APIClient
|
||||||
|
|
||||||
from paperless_testing.dirs import PaperlessDirs
|
from paperless_testing.dirs import PaperlessDirs
|
||||||
|
from paperless_testing.fakes.progress import FakeProgressManager
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture(scope="session", autouse=True)
|
@pytest.fixture(scope="session", autouse=True)
|
||||||
@@ -31,6 +32,18 @@ def faker_session_locale() -> str:
|
|||||||
return "en_US"
|
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)
|
@pytest.fixture(autouse=True)
|
||||||
def _clear_content_type_caches() -> None:
|
def _clear_content_type_caches() -> None:
|
||||||
"""Clear Django's ContentType cache and guardian's lru_cache before each test.
|
"""Clear Django's ContentType cache and guardian's lru_cache before each test.
|
||||||
@@ -124,3 +137,15 @@ def user_client(rest_api_client: APIClient, regular_user: User) -> APIClient:
|
|||||||
rest_api_client.force_authenticate(user=regular_user)
|
rest_api_client.force_authenticate(user=regular_user)
|
||||||
rest_api_client.credentials(HTTP_ACCEPT="application/json; version=10")
|
rest_api_client.credentials(HTTP_ACCEPT="application/json; version=10")
|
||||||
return rest_api_client
|
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
|
||||||
|
|||||||
@@ -1079,6 +1079,16 @@ class DocumentSerializer(
|
|||||||
)
|
)
|
||||||
|
|
||||||
def get_page_count(self, obj) -> int | None:
|
def get_page_count(self, obj) -> int | None:
|
||||||
|
# Like content versions get their own page count from the newest version,
|
||||||
|
# use the prefetched versions cache to avoid an extra query
|
||||||
|
prefetched_cache = getattr(obj, "_prefetched_objects_cache", None)
|
||||||
|
prefetched_versions = (
|
||||||
|
prefetched_cache.get("versions")
|
||||||
|
if isinstance(prefetched_cache, dict)
|
||||||
|
else None
|
||||||
|
)
|
||||||
|
if obj.root_document_id is None and prefetched_versions:
|
||||||
|
return sort_versions_newest_first(prefetched_versions)[0].page_count
|
||||||
return obj.page_count
|
return obj.page_count
|
||||||
|
|
||||||
@extend_schema_field(DuplicateDocumentSummarySerializer(many=True))
|
@extend_schema_field(DuplicateDocumentSummarySerializer(many=True))
|
||||||
@@ -2098,6 +2108,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 +2119,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 "
|
||||||
|
|||||||
@@ -1,11 +1,9 @@
|
|||||||
import shutil
|
import shutil
|
||||||
from collections.abc import Generator
|
|
||||||
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 pytest_django.fixtures import Settings
|
|
||||||
|
|
||||||
from paperless_testing.factories import DocumentFactory
|
from paperless_testing.factories import DocumentFactory
|
||||||
|
|
||||||
@@ -15,7 +13,7 @@ if TYPE_CHECKING:
|
|||||||
|
|
||||||
|
|
||||||
@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"
|
||||||
|
|
||||||
@@ -23,20 +21,20 @@ def samples_dir() -> Path:
|
|||||||
@pytest.fixture()
|
@pytest.fixture()
|
||||||
def sample_doc(
|
def sample_doc(
|
||||||
paperless_dirs: "PaperlessDirs",
|
paperless_dirs: "PaperlessDirs",
|
||||||
samples_dir: Path,
|
document_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_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_dir / "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_dir / "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.thumbnail_dir / "0000001.webp",
|
paperless_dirs.thumbnail_dir / "0000001.webp",
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -52,28 +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
|
@pytest.fixture
|
||||||
def indexed_document(_search_index: None) -> "Document":
|
def searchable_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
|
||||||
@@ -14,24 +14,16 @@ 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
|
||||||
|
|||||||
@@ -947,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()
|
||||||
|
|
||||||
@@ -964,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()
|
||||||
|
|||||||
@@ -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"),
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
@@ -8,7 +8,6 @@ pytestmark = pytest.mark.search
|
|||||||
class TestMigrateFulltextQueryFieldPrefixes(TestMigrations):
|
class TestMigrateFulltextQueryFieldPrefixes(TestMigrations):
|
||||||
migrate_from = "0016_sha256_checksums"
|
migrate_from = "0016_sha256_checksums"
|
||||||
migrate_to = "0017_migrate_fulltext_query_field_prefixes"
|
migrate_to = "0017_migrate_fulltext_query_field_prefixes"
|
||||||
migrate_once = True
|
|
||||||
|
|
||||||
def setUpBeforeMigration(self, apps) -> None:
|
def setUpBeforeMigration(self, apps) -> None:
|
||||||
User = apps.get_model("auth", "User")
|
User = apps.get_model("auth", "User")
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -10,11 +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 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.dirs import DirectoriesMixin
|
||||||
from paperless_testing.factories import UserFactory
|
from paperless_testing.factories import UserFactory
|
||||||
|
from paperless_testing.http import read_streaming_response
|
||||||
|
|
||||||
|
|
||||||
class TestApiAppConfig(DirectoriesMixin, APITestCase):
|
class TestApiAppConfig(DirectoriesMixin, APITestCase):
|
||||||
@@ -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,
|
||||||
@@ -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:
|
||||||
|
|||||||
@@ -13,9 +13,9 @@ 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 SampleDirMixin
|
from documents.tests.utils import SampleDirMixin
|
||||||
from documents.tests.utils import read_streaming_response
|
|
||||||
from paperless_testing.dirs import DirectoriesMixin
|
from paperless_testing.dirs import DirectoriesMixin
|
||||||
from paperless_testing.factories import UserFactory
|
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_global
|
||||||
|
|
||||||
|
|
||||||
@@ -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:
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ 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
|
||||||
@@ -1784,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:
|
||||||
"""
|
"""
|
||||||
@@ -2495,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(
|
||||||
@@ -2511,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:
|
||||||
@@ -2519,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],
|
||||||
@@ -2540,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/",
|
||||||
@@ -2569,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:
|
||||||
"""
|
"""
|
||||||
|
|||||||
@@ -16,11 +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 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.dirs import DirectoriesMixin
|
||||||
|
from paperless_testing.factories import DocumentFactory
|
||||||
from paperless_testing.factories import UserFactory
|
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_global
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
@@ -821,6 +822,26 @@ class TestDocumentVersioningApi(DirectoriesMixin, APITestCase):
|
|||||||
self.assertEqual(resp.status_code, status.HTTP_200_OK)
|
self.assertEqual(resp.status_code, status.HTTP_200_OK)
|
||||||
self.assertEqual(resp.data["content"], "v1-content")
|
self.assertEqual(resp.data["content"], "v1-content")
|
||||||
|
|
||||||
|
def test_page_count_resolves_to_latest_version(self) -> None:
|
||||||
|
root = DocumentFactory(page_count=2)
|
||||||
|
DocumentFactory(root_document=root, version_index=1, page_count=1)
|
||||||
|
unversioned = DocumentFactory(page_count=5)
|
||||||
|
|
||||||
|
resp = self.client.get("/api/documents/?fields=id,page_count")
|
||||||
|
self.assertEqual(resp.status_code, status.HTTP_200_OK)
|
||||||
|
self.assertEqual(
|
||||||
|
{doc["id"]: doc["page_count"] for doc in resp.data["results"]},
|
||||||
|
{root.id: 1, unversioned.id: 5},
|
||||||
|
)
|
||||||
|
|
||||||
|
resp = self.client.get(f"/api/documents/{root.id}/")
|
||||||
|
self.assertEqual(resp.status_code, status.HTTP_200_OK)
|
||||||
|
self.assertEqual(resp.data["page_count"], 1)
|
||||||
|
|
||||||
|
resp = self.client.get(f"/api/documents/{root.id}/?version={root.id}")
|
||||||
|
self.assertEqual(resp.status_code, status.HTTP_200_OK)
|
||||||
|
self.assertEqual(resp.data["page_count"], 2)
|
||||||
|
|
||||||
def _make_root_with_out_of_order_versions(self) -> tuple[Document, ...]:
|
def _make_root_with_out_of_order_versions(self) -> tuple[Document, ...]:
|
||||||
"""
|
"""
|
||||||
A root whose newest version has a *lower* id than an older one, which is
|
A root whose newest version has a *lower* id than an older one, which is
|
||||||
|
|||||||
@@ -48,11 +48,11 @@ 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.utils import ConsumeTaskMixin
|
from documents.tests.utils import ConsumeTaskMixin
|
||||||
from documents.tests.utils import read_streaming_response
|
|
||||||
from paperless_testing.dirs import DirectoriesMixin
|
from paperless_testing.dirs import DirectoriesMixin
|
||||||
from paperless_testing.factories import DocumentFactory
|
from paperless_testing.factories import DocumentFactory
|
||||||
from paperless_testing.factories import TagFactory
|
from paperless_testing.factories import TagFactory
|
||||||
from paperless_testing.factories import UserFactory
|
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_all_global
|
||||||
from paperless_testing.permissions import grant_global
|
from paperless_testing.permissions import grant_global
|
||||||
from paperless_testing.permissions import grant_object
|
from paperless_testing.permissions import grant_object
|
||||||
|
|||||||
@@ -64,16 +64,15 @@ class TestApiProfile(DirectoriesMixin, APITestCase):
|
|||||||
)
|
)
|
||||||
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:
|
||||||
"""
|
"""
|
||||||
@@ -111,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,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -135,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",
|
||||||
},
|
},
|
||||||
@@ -152,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()
|
||||||
@@ -165,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",
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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 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.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,
|
||||||
|
|||||||
@@ -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,6 +27,7 @@ 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.helpers import dummy_preprocess
|
||||||
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
|
||||||
@@ -36,15 +36,6 @@ from paperless_testing.factories import DocumentFactory
|
|||||||
from paperless_testing.factories import TagFactory
|
from paperless_testing.factories import TagFactory
|
||||||
|
|
||||||
|
|
||||||
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
|
|
||||||
|
|
||||||
|
|
||||||
class TestClassifier(DirectoriesMixin, TestCase):
|
class TestClassifier(DirectoriesMixin, TestCase):
|
||||||
def setUp(self) -> None:
|
def setUp(self) -> None:
|
||||||
super().setUp()
|
super().setUp()
|
||||||
|
|||||||
@@ -30,12 +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 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.dirs import DirectoriesMixin
|
||||||
from paperless_testing.factories import UserFactory
|
from paperless_testing.factories import UserFactory
|
||||||
|
from paperless_testing.fakes.progress import FakeProgressManager
|
||||||
|
|
||||||
|
|
||||||
class _BaseNewStyleParser:
|
class _BaseNewStyleParser:
|
||||||
@@ -777,7 +777,7 @@ class TestConsumer(
|
|||||||
)
|
)
|
||||||
|
|
||||||
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,
|
||||||
@@ -840,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,
|
||||||
@@ -897,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,
|
||||||
|
|||||||
@@ -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,18 +15,22 @@ 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 DummyProgressManager
|
from documents.tests.utils import SampleDirMixin
|
||||||
from documents.tests.utils import FileSystemAssertsMixin
|
from paperless_testing.assertions import FileSystemAssertsMixin
|
||||||
from paperless_testing.dirs import DirectoriesMixin
|
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.double_sided_dir = self.dirs.consumption_dir / "double-sided"
|
self.double_sided_dir = self.dirs.consumption_dir / "double-sided"
|
||||||
@@ -42,17 +46,13 @@ class TestDoubleSided(DirectoriesMixin, FileSystemAssertsMixin, TestCase):
|
|||||||
dst = self.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
|
||||||
|
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ 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.utils import FileSystemAssertsMixin
|
from paperless_testing.assertions import FileSystemAssertsMixin
|
||||||
from paperless_testing.dirs import DirectoriesMixin
|
from paperless_testing.dirs import DirectoriesMixin
|
||||||
from paperless_testing.factories import DocumentFactory
|
from paperless_testing.factories import DocumentFactory
|
||||||
from paperless_testing.factories import UserFactory
|
from paperless_testing.factories import UserFactory
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ 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 FileSystemAssertsMixin
|
from paperless_testing.assertions import FileSystemAssertsMixin
|
||||||
from paperless_testing.dirs import DirectoriesMixin
|
from paperless_testing.dirs import DirectoriesMixin
|
||||||
|
|
||||||
sample_file: Path = Path(__file__).parent / "samples" / "simple.pdf"
|
sample_file: Path = Path(__file__).parent / "samples" / "simple.pdf"
|
||||||
|
|||||||
@@ -45,9 +45,9 @@ 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 FileSystemAssertsMixin
|
|
||||||
from documents.tests.utils import SampleDirMixin
|
from documents.tests.utils import SampleDirMixin
|
||||||
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 DirectoriesMixin
|
||||||
from paperless_testing.dirs import paperless_environment
|
from paperless_testing.dirs import paperless_environment
|
||||||
from paperless_testing.permissions import grant_object
|
from paperless_testing.permissions import grant_object
|
||||||
@@ -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:
|
||||||
"""
|
"""
|
||||||
|
|||||||
@@ -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,8 +15,8 @@ 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 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
|
from paperless_testing.dirs import DirectoriesMixin
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ from paperless_testing.dirs import DirectoriesMixin
|
|||||||
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,7 +9,7 @@ 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 FileSystemAssertsMixin
|
from paperless_testing.assertions import FileSystemAssertsMixin
|
||||||
from paperless_testing.dirs import DirectoriesMixin
|
from paperless_testing.dirs import DirectoriesMixin
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import hashlib
|
import hashlib
|
||||||
|
import shutil
|
||||||
import tempfile
|
import tempfile
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
@@ -6,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:
|
||||||
@@ -18,22 +19,19 @@ class TestSha256ChecksumDataMigration(TestMigrations):
|
|||||||
|
|
||||||
migrate_from = "0015_document_version_index_and_more"
|
migrate_from = "0015_document_version_index_and_more"
|
||||||
migrate_to = "0016_sha256_checksums"
|
migrate_to = "0016_sha256_checksums"
|
||||||
migrate_once = True
|
|
||||||
reset_sequences = True
|
reset_sequences = True
|
||||||
|
|
||||||
ORIGINAL_CONTENT = b"original file content for sha256 migration test"
|
ORIGINAL_CONTENT = b"original file content for sha256 migration test"
|
||||||
ARCHIVE_CONTENT = b"archive file content for sha256 migration test"
|
ARCHIVE_CONTENT = b"archive file content for sha256 migration test"
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def setUpClass(cls) -> None:
|
|
||||||
super().setUpClass()
|
|
||||||
originals_dir = Path(cls.enterClassContext(tempfile.TemporaryDirectory()))
|
|
||||||
archive_dir = Path(cls.enterClassContext(tempfile.TemporaryDirectory()))
|
|
||||||
cls.enterClassContext(
|
|
||||||
override_settings(ORIGINALS_DIR=originals_dir, ARCHIVE_DIR=archive_dir),
|
|
||||||
)
|
|
||||||
|
|
||||||
def setUpBeforeMigration(self, apps) -> None:
|
def setUpBeforeMigration(self, apps) -> None:
|
||||||
|
self._originals_dir = Path(tempfile.mkdtemp())
|
||||||
|
self._archive_dir = Path(tempfile.mkdtemp())
|
||||||
|
self._settings_override = override_settings(
|
||||||
|
ORIGINALS_DIR=self._originals_dir,
|
||||||
|
ARCHIVE_DIR=self._archive_dir,
|
||||||
|
)
|
||||||
|
self._settings_override.enable()
|
||||||
Document = apps.get_model("documents", "Document")
|
Document = apps.get_model("documents", "Document")
|
||||||
|
|
||||||
# doc1: original file present, no archive
|
# doc1: original file present, no archive
|
||||||
@@ -87,9 +85,8 @@ class TestSha256ChecksumDataMigration(TestMigrations):
|
|||||||
archive_checksum=None,
|
archive_checksum=None,
|
||||||
).pk
|
).pk
|
||||||
|
|
||||||
@classmethod
|
def _fixture_teardown(self) -> None:
|
||||||
def tearDownClass(cls) -> None:
|
super()._fixture_teardown()
|
||||||
super().tearDownClass()
|
|
||||||
# Django's SQLite backend returns [] from sequence_reset_sql(), so
|
# Django's SQLite backend returns [] from sequence_reset_sql(), so
|
||||||
# reset_sequences=True flushes rows but never clears sqlite_sequence.
|
# reset_sequences=True flushes rows but never clears sqlite_sequence.
|
||||||
# Explicitly delete the entry so subsequent tests start from pk=1.
|
# Explicitly delete the entry so subsequent tests start from pk=1.
|
||||||
@@ -99,6 +96,12 @@ class TestSha256ChecksumDataMigration(TestMigrations):
|
|||||||
"DELETE FROM sqlite_sequence WHERE name='documents_document'",
|
"DELETE FROM sqlite_sequence WHERE name='documents_document'",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def tearDown(self) -> None:
|
||||||
|
super().tearDown()
|
||||||
|
self._settings_override.disable()
|
||||||
|
shutil.rmtree(self._originals_dir, ignore_errors=True)
|
||||||
|
shutil.rmtree(self._archive_dir, ignore_errors=True)
|
||||||
|
|
||||||
def test_original_checksum_updated_to_sha256_when_file_exists(self) -> None:
|
def test_original_checksum_updated_to_sha256_when_file_exists(self) -> None:
|
||||||
Document = self.apps.get_model("documents", "Document")
|
Document = self.apps.get_model("documents", "Document")
|
||||||
doc = Document.objects.get(pk=self.doc1_id)
|
doc = Document.objects.get(pk=self.doc1_id)
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
from documents.tests.utils import TestMigrations
|
from paperless_testing.migrations import TestMigrations
|
||||||
|
|
||||||
|
|
||||||
class TestMigrateShareLinkBundlePermissions(TestMigrations):
|
class TestMigrateShareLinkBundlePermissions(TestMigrations):
|
||||||
|
|||||||
@@ -430,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:
|
||||||
|
|||||||
@@ -339,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:
|
||||||
|
|||||||
@@ -17,8 +17,8 @@ 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 FileSystemAssertsMixin
|
from paperless_testing.assertions import FileSystemAssertsMixin
|
||||||
from paperless_testing.dirs import DirectoriesMixin
|
from paperless_testing.dirs import DirectoriesMixin
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -28,12 +28,12 @@ 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 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.dirs import DirectoriesMixin
|
||||||
from paperless_testing.factories import UserFactory
|
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_global
|
||||||
from paperless_testing.permissions import grant_object
|
from paperless_testing.permissions import grant_object
|
||||||
|
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -1,126 +1,16 @@
|
|||||||
import time
|
|
||||||
import warnings
|
|
||||||
from collections.abc import Callable
|
|
||||||
from collections.abc import Generator
|
from collections.abc import Generator
|
||||||
from collections.abc import Iterator
|
from collections.abc import Iterator
|
||||||
from contextlib import contextmanager
|
from contextlib import contextmanager
|
||||||
from os import PathLike
|
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any
|
|
||||||
from unittest import mock
|
from unittest import mock
|
||||||
|
|
||||||
import httpx
|
|
||||||
import pytest
|
|
||||||
from django.apps import apps
|
|
||||||
from django.db import connection
|
|
||||||
from django.db.migrations.executor import MigrationExecutor
|
|
||||||
from django.http import StreamingHttpResponse
|
|
||||||
from django.test import TransactionTestCase
|
|
||||||
|
|
||||||
from documents.consumer import AsnCheckPlugin
|
from documents.consumer import AsnCheckPlugin
|
||||||
from documents.consumer import ConsumerPlugin
|
from documents.consumer import ConsumerPlugin
|
||||||
from documents.consumer import ConsumerPreflightPlugin
|
from documents.consumer import ConsumerPreflightPlugin
|
||||||
from documents.data_models import ConsumableDocument
|
from documents.data_models import ConsumableDocument
|
||||||
from documents.data_models import DocumentMetadataOverrides
|
from documents.data_models import DocumentMetadataOverrides
|
||||||
from documents.data_models import DocumentSource
|
from documents.data_models import DocumentSource
|
||||||
from documents.parsers import ParseError
|
from paperless_testing.fakes.progress import FakeProgressManager
|
||||||
from documents.plugins.helpers import ProgressStatusOptions
|
|
||||||
|
|
||||||
|
|
||||||
def util_call_with_backoff(
|
|
||||||
method_or_callable: Callable,
|
|
||||||
args: list | tuple,
|
|
||||||
*,
|
|
||||||
skip_on_50x_err=True,
|
|
||||||
) -> tuple[bool, Any]:
|
|
||||||
"""
|
|
||||||
For whatever reason, the images started during the test pipeline like to
|
|
||||||
segfault sometimes, crash and otherwise fail randomly, when run with the
|
|
||||||
exact files that usually pass.
|
|
||||||
|
|
||||||
So, this function will retry the given method/function up to 3 times, with larger backoff
|
|
||||||
periods between each attempt, in hopes the issue resolves itself during
|
|
||||||
one attempt to parse.
|
|
||||||
|
|
||||||
This will wait the following:
|
|
||||||
- Attempt 1 - 20s following failure
|
|
||||||
- Attempt 2 - 40s following failure
|
|
||||||
- Attempt 3 - 80s following failure
|
|
||||||
|
|
||||||
"""
|
|
||||||
result = None
|
|
||||||
succeeded = False
|
|
||||||
retry_time = 20.0
|
|
||||||
retry_count = 0
|
|
||||||
status_codes = []
|
|
||||||
max_retry_count = 3
|
|
||||||
|
|
||||||
while retry_count < max_retry_count and not succeeded:
|
|
||||||
try:
|
|
||||||
result = method_or_callable(*args)
|
|
||||||
|
|
||||||
succeeded = True
|
|
||||||
except ParseError as e: # pragma: no cover
|
|
||||||
cause_exec = e.__cause__
|
|
||||||
if cause_exec is not None and isinstance(cause_exec, httpx.HTTPStatusError):
|
|
||||||
status_codes.append(cause_exec.response.status_code)
|
|
||||||
warnings.warn(
|
|
||||||
f"HTTP Exception for {cause_exec.request.url} - {cause_exec}",
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
warnings.warn(f"Unexpected error: {e}")
|
|
||||||
except Exception as e: # pragma: no cover
|
|
||||||
warnings.warn(f"Unexpected error: {e}")
|
|
||||||
|
|
||||||
retry_count = retry_count + 1
|
|
||||||
|
|
||||||
time.sleep(retry_time)
|
|
||||||
retry_time = retry_time * 2.0
|
|
||||||
|
|
||||||
if (
|
|
||||||
not succeeded
|
|
||||||
and status_codes
|
|
||||||
and skip_on_50x_err
|
|
||||||
and all(httpx.codes.is_server_error(code) for code in status_codes)
|
|
||||||
):
|
|
||||||
pytest.skip("Repeated HTTP 50x for service") # pragma: no cover
|
|
||||||
|
|
||||||
return succeeded, result
|
|
||||||
|
|
||||||
|
|
||||||
def read_streaming_response(response: StreamingHttpResponse) -> bytes:
|
|
||||||
"""Consume a StreamingHttpResponse/FileResponse and close it."""
|
|
||||||
content = b"".join(response.streaming_content)
|
|
||||||
response.close()
|
|
||||||
return content
|
|
||||||
|
|
||||||
|
|
||||||
class FileSystemAssertsMixin:
|
|
||||||
"""
|
|
||||||
Utilities for checks various state information of the file system
|
|
||||||
"""
|
|
||||||
|
|
||||||
def assertIsFile(self, path: PathLike[str] | str) -> None:
|
|
||||||
self.assertTrue(Path(path).resolve().is_file(), f"File does not exist: {path}")
|
|
||||||
|
|
||||||
def assertIsNotFile(self, path: PathLike[str] | str) -> None:
|
|
||||||
self.assertFalse(Path(path).resolve().is_file(), f"File does exist: {path}")
|
|
||||||
|
|
||||||
def assertIsDir(self, path: PathLike[str] | str) -> None:
|
|
||||||
self.assertTrue(Path(path).resolve().is_dir(), f"Dir does not exist: {path}")
|
|
||||||
|
|
||||||
def assertIsNotDir(self, path: PathLike[str] | str) -> None:
|
|
||||||
self.assertFalse(Path(path).resolve().is_dir(), f"Dir does exist: {path}")
|
|
||||||
|
|
||||||
def assertFileCountInDir(self, path: PathLike[str] | str, count: int) -> None:
|
|
||||||
path = Path(path).resolve()
|
|
||||||
self.assertTrue(path.is_dir(), f"Path {path} is not a directory")
|
|
||||||
files = [x for x in path.iterdir() if x.is_file()]
|
|
||||||
self.assertEqual(
|
|
||||||
len(files),
|
|
||||||
count,
|
|
||||||
f"Path {path} contains {len(files)} files instead of {count} files",
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class ConsumeTaskMixin:
|
class ConsumeTaskMixin:
|
||||||
@@ -158,123 +48,6 @@ class ConsumeTaskMixin:
|
|||||||
yield (task_kwargs["input_doc"], task_kwargs["overrides"])
|
yield (task_kwargs["input_doc"], task_kwargs["overrides"])
|
||||||
|
|
||||||
|
|
||||||
class TestMigrations(TransactionTestCase):
|
|
||||||
"""Run a migration on seeded data, then let the tests inspect the result.
|
|
||||||
|
|
||||||
By default every test migrates back, seeds, migrates forward and returns to
|
|
||||||
the latest migration, which costs several seconds. A class whose tests only
|
|
||||||
read the migrated data can set ``migrate_once`` to pay that once per class:
|
|
||||||
the migration runs for the first test, the database is left alone between
|
|
||||||
tests, and it is restored and flushed when the class finishes. Such tests
|
|
||||||
must not write to the database.
|
|
||||||
"""
|
|
||||||
|
|
||||||
@property
|
|
||||||
def app(self):
|
|
||||||
return apps.get_containing_app_config(type(self).__module__).name
|
|
||||||
|
|
||||||
migrate_from = None
|
|
||||||
dependencies = None
|
|
||||||
migrate_to = None
|
|
||||||
migrate_once = False
|
|
||||||
|
|
||||||
_once_owner: "TestMigrations | None" = None
|
|
||||||
_once_state: dict[str, Any] | None = None
|
|
||||||
_once_finishing = False
|
|
||||||
|
|
||||||
def setUp(self) -> None:
|
|
||||||
super().setUp()
|
|
||||||
|
|
||||||
cls = type(self)
|
|
||||||
if self.migrate_once:
|
|
||||||
if cls._once_state is not None:
|
|
||||||
vars(self).update(cls._once_state)
|
|
||||||
return
|
|
||||||
if cls._once_owner is not None:
|
|
||||||
raise RuntimeError(
|
|
||||||
f"The migration in '{cls.__name__}' failed for an earlier test",
|
|
||||||
)
|
|
||||||
# Recorded before migrating so a failed migration is still restored
|
|
||||||
cls._once_owner = self
|
|
||||||
before = dict(vars(self))
|
|
||||||
|
|
||||||
self._migrate()
|
|
||||||
|
|
||||||
if self.migrate_once:
|
|
||||||
cls._once_state = {
|
|
||||||
name: value
|
|
||||||
for name, value in vars(self).items()
|
|
||||||
if name not in before or before[name] is not value
|
|
||||||
}
|
|
||||||
|
|
||||||
def _migrate(self) -> None:
|
|
||||||
assert self.migrate_from and self.migrate_to, (
|
|
||||||
f"TestCase '{type(self).__name__}' must define migrate_from and migrate_to properties"
|
|
||||||
)
|
|
||||||
self.migrate_from = [(self.app, self.migrate_from)]
|
|
||||||
if self.dependencies is not None:
|
|
||||||
self.migrate_from.extend(self.dependencies)
|
|
||||||
self.migrate_to = [(self.app, self.migrate_to)]
|
|
||||||
executor = MigrationExecutor(connection)
|
|
||||||
old_apps = executor.loader.project_state(self.migrate_from).apps
|
|
||||||
|
|
||||||
# Reverse to the original migration
|
|
||||||
executor.migrate(self.migrate_from)
|
|
||||||
|
|
||||||
self.setUpBeforeMigration(old_apps)
|
|
||||||
|
|
||||||
self.apps = old_apps
|
|
||||||
|
|
||||||
# Run the migration to test
|
|
||||||
executor = MigrationExecutor(connection)
|
|
||||||
executor.loader.build_graph() # reload.
|
|
||||||
executor.migrate(self.migrate_to)
|
|
||||||
|
|
||||||
self.apps = executor.loader.project_state(self.migrate_to).apps
|
|
||||||
|
|
||||||
def setUpBeforeMigration(self, apps) -> None:
|
|
||||||
pass
|
|
||||||
|
|
||||||
def _migrate_to_latest(self) -> None:
|
|
||||||
executor = MigrationExecutor(connection)
|
|
||||||
executor.loader.build_graph()
|
|
||||||
targets = executor.loader.graph.leaf_nodes()
|
|
||||||
executor.migrate(targets)
|
|
||||||
|
|
||||||
def tearDown(self) -> None:
|
|
||||||
"""
|
|
||||||
Ensure the database schema is restored to the latest migration after
|
|
||||||
each migration test, so subsequent tests run against HEAD.
|
|
||||||
"""
|
|
||||||
if self.migrate_once and not self._once_finishing:
|
|
||||||
return
|
|
||||||
try:
|
|
||||||
self._migrate_to_latest()
|
|
||||||
finally:
|
|
||||||
super().tearDown()
|
|
||||||
|
|
||||||
def _fixture_teardown(self) -> None:
|
|
||||||
# Django flushes every table after each test, which would discard the
|
|
||||||
# data the remaining tests of a migrate_once class still need
|
|
||||||
if self.migrate_once and not self._once_finishing:
|
|
||||||
return
|
|
||||||
super()._fixture_teardown()
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def tearDownClass(cls) -> None:
|
|
||||||
owner = cls._once_owner
|
|
||||||
try:
|
|
||||||
if owner is not None:
|
|
||||||
cls._once_finishing = True
|
|
||||||
owner.tearDown()
|
|
||||||
owner._fixture_teardown()
|
|
||||||
finally:
|
|
||||||
cls._once_owner = None
|
|
||||||
cls._once_state = None
|
|
||||||
cls._once_finishing = False
|
|
||||||
super().tearDownClass()
|
|
||||||
|
|
||||||
|
|
||||||
class SampleDirMixin:
|
class SampleDirMixin:
|
||||||
SAMPLE_DIR = Path(__file__).parent / "samples"
|
SAMPLE_DIR = Path(__file__).parent / "samples"
|
||||||
|
|
||||||
@@ -291,7 +64,7 @@ class GetConsumerMixin:
|
|||||||
mailrule_id: int | None = None,
|
mailrule_id: int | None = None,
|
||||||
) -> Generator[ConsumerPlugin, None, None]:
|
) -> Generator[ConsumerPlugin, None, None]:
|
||||||
# Store this for verification
|
# Store this for verification
|
||||||
self.status = DummyProgressManager(filepath.name, None)
|
self.status = FakeProgressManager(filepath.name, None)
|
||||||
doc = ConsumableDocument(
|
doc = ConsumableDocument(
|
||||||
source,
|
source,
|
||||||
original_file=filepath,
|
original_file=filepath,
|
||||||
@@ -327,63 +100,3 @@ class GetConsumerMixin:
|
|||||||
yield reader
|
yield reader
|
||||||
finally:
|
finally:
|
||||||
reader.cleanup()
|
reader.cleanup()
|
||||||
|
|
||||||
|
|
||||||
class DummyProgressManager:
|
|
||||||
"""
|
|
||||||
A dummy handler for progress management that doesn't actually try to
|
|
||||||
connect to Redis. Payloads are stored for test assertions if needed.
|
|
||||||
|
|
||||||
Use it with
|
|
||||||
mock.patch("documents.tasks.ProgressManager", DummyProgressManager)
|
|
||||||
"""
|
|
||||||
|
|
||||||
def __init__(self, filename: str, task_id: str | None = None) -> None:
|
|
||||||
self.filename = filename
|
|
||||||
self.task_id = task_id
|
|
||||||
self.payloads = []
|
|
||||||
|
|
||||||
def __enter__(self):
|
|
||||||
self.open()
|
|
||||||
return self
|
|
||||||
|
|
||||||
def __exit__(self, exc_type, exc_val, exc_tb) -> None:
|
|
||||||
self.close()
|
|
||||||
|
|
||||||
def open(self) -> None:
|
|
||||||
pass
|
|
||||||
|
|
||||||
def close(self) -> None:
|
|
||||||
pass
|
|
||||||
|
|
||||||
def send_progress(
|
|
||||||
self,
|
|
||||||
status: ProgressStatusOptions,
|
|
||||||
message: str,
|
|
||||||
current_progress: int,
|
|
||||||
max_progress: int,
|
|
||||||
*,
|
|
||||||
document_id: int | None = None,
|
|
||||||
owner_id: int | None = None,
|
|
||||||
users_can_view: list[int] | None = None,
|
|
||||||
groups_can_view: list[int] | None = None,
|
|
||||||
) -> None:
|
|
||||||
# Ensure the layer is open
|
|
||||||
self.open()
|
|
||||||
|
|
||||||
payload = {
|
|
||||||
"type": "status_update",
|
|
||||||
"data": {
|
|
||||||
"filename": self.filename,
|
|
||||||
"task_id": self.task_id,
|
|
||||||
"current_progress": current_progress,
|
|
||||||
"max_progress": max_progress,
|
|
||||||
"status": status,
|
|
||||||
"message": message,
|
|
||||||
"document_id": document_id,
|
|
||||||
"owner_id": owner_id,
|
|
||||||
"users_can_view": users_can_view or [],
|
|
||||||
"groups_can_view": groups_can_view or [],
|
|
||||||
},
|
|
||||||
}
|
|
||||||
self.payloads.append(payload)
|
|
||||||
|
|||||||
+57
-36
@@ -49,7 +49,6 @@ from django.db.models import Sum
|
|||||||
from django.db.models import When
|
from django.db.models import When
|
||||||
from django.db.models.functions import Coalesce
|
from django.db.models.functions import Coalesce
|
||||||
from django.db.models.functions import Lower
|
from django.db.models.functions import Lower
|
||||||
from django.db.models.manager import Manager
|
|
||||||
from django.http import FileResponse
|
from django.http import FileResponse
|
||||||
from django.http import Http404
|
from django.http import Http404
|
||||||
from django.http import HttpRequest
|
from django.http import HttpRequest
|
||||||
@@ -1193,6 +1192,7 @@ class DocumentViewSet(
|
|||||||
"version_label",
|
"version_label",
|
||||||
"root_document_id",
|
"root_document_id",
|
||||||
"version_index",
|
"version_index",
|
||||||
|
"page_count",
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
"tags",
|
"tags",
|
||||||
@@ -1272,13 +1272,16 @@ class DocumentViewSet(
|
|||||||
if (
|
if (
|
||||||
"version" not in request.query_params
|
"version" not in request.query_params
|
||||||
or not isinstance(response.data, dict)
|
or not isinstance(response.data, dict)
|
||||||
or "content" not in response.data
|
or not ({"content", "page_count"} & response.data.keys())
|
||||||
):
|
):
|
||||||
return response
|
return response
|
||||||
|
|
||||||
root_doc = self.get_object()
|
root_doc = self.get_object()
|
||||||
content_doc = self._resolve_file_doc(root_doc, request)
|
content_doc = self._resolve_file_doc(root_doc, request)
|
||||||
response.data["content"] = content_doc.content or ""
|
if "content" in response.data:
|
||||||
|
response.data["content"] = content_doc.content or ""
|
||||||
|
if "page_count" in response.data:
|
||||||
|
response.data["page_count"] = content_doc.page_count
|
||||||
return response
|
return response
|
||||||
|
|
||||||
def update(self, request, *args, **kwargs):
|
def update(self, request, *args, **kwargs):
|
||||||
@@ -2968,11 +2971,15 @@ class DocumentOperationPermissionMixin(PassUserMixin, DocumentSelectionMixin):
|
|||||||
if user.is_superuser:
|
if user.is_superuser:
|
||||||
return True
|
return True
|
||||||
|
|
||||||
document_objs = Document.objects.select_related("owner").filter(
|
root_docs = {
|
||||||
pk__in=documents,
|
get_root_document(doc)
|
||||||
)
|
for doc in Document.objects.select_related(
|
||||||
|
"owner",
|
||||||
|
"root_document__owner",
|
||||||
|
).filter(pk__in=documents)
|
||||||
|
}
|
||||||
user_is_owner_of_all_documents = all(
|
user_is_owner_of_all_documents = all(
|
||||||
(doc.owner == user or doc.owner is None) for doc in document_objs
|
(doc.owner == user or doc.owner is None) for doc in root_docs
|
||||||
)
|
)
|
||||||
|
|
||||||
# check global and object permissions for all documents
|
# check global and object permissions for all documents
|
||||||
@@ -2980,9 +2987,13 @@ class DocumentOperationPermissionMixin(PassUserMixin, DocumentSelectionMixin):
|
|||||||
user.has_perm(
|
user.has_perm(
|
||||||
"documents.change_document",
|
"documents.change_document",
|
||||||
)
|
)
|
||||||
and not document_objs.exclude(
|
and not Document.global_objects.filter(
|
||||||
|
pk__in=[doc.pk for doc in root_docs],
|
||||||
|
)
|
||||||
|
.exclude(
|
||||||
pk__in=permitted_document_ids(user, perm="change_document"),
|
pk__in=permitted_document_ids(user, perm="change_document"),
|
||||||
).exists()
|
)
|
||||||
|
.exists()
|
||||||
)
|
)
|
||||||
|
|
||||||
# check ownership for methods that change original document
|
# check ownership for methods that change original document
|
||||||
@@ -3141,6 +3152,38 @@ class BulkEditView(DocumentOperationPermissionMixin):
|
|||||||
|
|
||||||
serializer_class = BulkEditSerializer
|
serializer_class = BulkEditSerializer
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _snapshot_field(doc_ids: list[int], field: str) -> dict[int, Any]:
|
||||||
|
"""
|
||||||
|
Returns each document's current value of field, for the audit log.
|
||||||
|
|
||||||
|
Tags and custom fields are one row per value, so they are gathered
|
||||||
|
into a sorted list of pks per document (empty when there are none).
|
||||||
|
Reading them through Document.values() instead would join those rows
|
||||||
|
and return one arbitrary value per document.
|
||||||
|
"""
|
||||||
|
if field == "tags":
|
||||||
|
rows = (
|
||||||
|
Document.tags.through.objects.filter(document_id__in=doc_ids)
|
||||||
|
.order_by("tag_id")
|
||||||
|
.values_list("document_id", "tag_id")
|
||||||
|
)
|
||||||
|
elif field == "custom_fields":
|
||||||
|
rows = (
|
||||||
|
CustomFieldInstance.objects.filter(document_id__in=doc_ids)
|
||||||
|
.order_by("pk")
|
||||||
|
.values_list("document_id", "pk")
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
return dict(
|
||||||
|
Document.objects.filter(pk__in=doc_ids).values_list("pk", field),
|
||||||
|
)
|
||||||
|
|
||||||
|
values: dict[int, list[int]] = {doc_id: [] for doc_id in doc_ids}
|
||||||
|
for doc_id, pk in rows:
|
||||||
|
values[doc_id].append(pk)
|
||||||
|
return values
|
||||||
|
|
||||||
def post(self, request, *args, **kwargs):
|
def post(self, request, *args, **kwargs):
|
||||||
request_method = request.data.get("method")
|
request_method = request.data.get("method")
|
||||||
api_version = int(request.version or settings.REST_FRAMEWORK["DEFAULT_VERSION"])
|
api_version = int(request.version or settings.REST_FRAMEWORK["DEFAULT_VERSION"])
|
||||||
@@ -3187,41 +3230,19 @@ class BulkEditView(DocumentOperationPermissionMixin):
|
|||||||
try:
|
try:
|
||||||
modified_field = self.MODIFIED_FIELD_BY_METHOD.get(method.__name__, None)
|
modified_field = self.MODIFIED_FIELD_BY_METHOD.get(method.__name__, None)
|
||||||
if settings.AUDIT_LOG_ENABLED and modified_field:
|
if settings.AUDIT_LOG_ENABLED and modified_field:
|
||||||
old_documents = {
|
old_values = self._snapshot_field(documents, modified_field)
|
||||||
obj["pk"]: obj
|
|
||||||
for obj in Document.objects.filter(pk__in=documents).values(
|
|
||||||
"pk",
|
|
||||||
"correspondent",
|
|
||||||
"document_type",
|
|
||||||
"storage_path",
|
|
||||||
"tags",
|
|
||||||
"custom_fields",
|
|
||||||
"deleted_at",
|
|
||||||
"checksum",
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
result = method(documents, **parameters)
|
result = method(documents, **parameters)
|
||||||
|
|
||||||
if settings.AUDIT_LOG_ENABLED and modified_field:
|
if settings.AUDIT_LOG_ENABLED and modified_field:
|
||||||
new_documents = Document.objects.filter(pk__in=documents)
|
new_values = self._snapshot_field(documents, modified_field)
|
||||||
for doc in new_documents:
|
for doc in Document.objects.filter(pk__in=documents):
|
||||||
old_value = old_documents[doc.pk][modified_field]
|
|
||||||
new_value = getattr(doc, modified_field)
|
|
||||||
|
|
||||||
if isinstance(new_value, Model):
|
|
||||||
# correspondent, document type, etc.
|
|
||||||
new_value = new_value.pk
|
|
||||||
elif isinstance(new_value, Manager):
|
|
||||||
# tags, custom fields
|
|
||||||
new_value = list(new_value.values_list("pk", flat=True))
|
|
||||||
|
|
||||||
LogEntry.objects.log_create(
|
LogEntry.objects.log_create(
|
||||||
instance=doc,
|
instance=doc,
|
||||||
changes={
|
changes={
|
||||||
modified_field: [
|
modified_field: [
|
||||||
old_value,
|
old_values[doc.pk],
|
||||||
new_value,
|
new_values[doc.pk],
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
action=LogEntry.Action.UPDATE,
|
action=LogEntry.Action.UPDATE,
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ msgid ""
|
|||||||
msgstr ""
|
msgstr ""
|
||||||
"Project-Id-Version: paperless-ngx\n"
|
"Project-Id-Version: paperless-ngx\n"
|
||||||
"Report-Msgid-Bugs-To: \n"
|
"Report-Msgid-Bugs-To: \n"
|
||||||
"POT-Creation-Date: 2026-09-18 01:29+0000\n"
|
"POT-Creation-Date: 2026-09-28 06:26+0000\n"
|
||||||
"PO-Revision-Date: 2022-02-17 04:17\n"
|
"PO-Revision-Date: 2022-02-17 04:17\n"
|
||||||
"Last-Translator: \n"
|
"Last-Translator: \n"
|
||||||
"Language-Team: English\n"
|
"Language-Team: English\n"
|
||||||
@@ -1632,7 +1632,7 @@ msgid "workflow runs"
|
|||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: documents/serialisers.py:514 documents/serialisers.py:871
|
#: documents/serialisers.py:514 documents/serialisers.py:871
|
||||||
#: documents/serialisers.py:2883 documents/views.py:343 documents/views.py:2726
|
#: documents/serialisers.py:2895 documents/views.py:342 documents/views.py:2729
|
||||||
#: paperless_mail/serialisers.py:156
|
#: paperless_mail/serialisers.py:156
|
||||||
msgid "Insufficient permissions."
|
msgid "Insufficient permissions."
|
||||||
msgstr ""
|
msgstr ""
|
||||||
@@ -1641,39 +1641,39 @@ msgstr ""
|
|||||||
msgid "Invalid color."
|
msgid "Invalid color."
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: documents/serialisers.py:2350
|
#: documents/serialisers.py:2362
|
||||||
#, python-format
|
#, python-format
|
||||||
msgid "File type %(type)s not supported"
|
msgid "File type %(type)s not supported"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: documents/serialisers.py:2394
|
#: documents/serialisers.py:2406
|
||||||
#, python-format
|
#, python-format
|
||||||
msgid "Custom field id must be an integer: %(id)s"
|
msgid "Custom field id must be an integer: %(id)s"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: documents/serialisers.py:2401
|
#: documents/serialisers.py:2413
|
||||||
#, python-format
|
#, python-format
|
||||||
msgid "Custom field with id %(id)s does not exist"
|
msgid "Custom field with id %(id)s does not exist"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: documents/serialisers.py:2418 documents/serialisers.py:2428
|
#: documents/serialisers.py:2430 documents/serialisers.py:2440
|
||||||
msgid ""
|
msgid ""
|
||||||
"Custom fields must be a list of integers or an object mapping ids to values."
|
"Custom fields must be a list of integers or an object mapping ids to values."
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: documents/serialisers.py:2423
|
#: documents/serialisers.py:2435
|
||||||
msgid "Some custom fields don't exist or were specified twice."
|
msgid "Some custom fields don't exist or were specified twice."
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: documents/serialisers.py:2570
|
#: documents/serialisers.py:2582
|
||||||
msgid "Invalid variable detected."
|
msgid "Invalid variable detected."
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: documents/serialisers.py:2939
|
#: documents/serialisers.py:2951
|
||||||
msgid "Duplicate document identifiers are not allowed."
|
msgid "Duplicate document identifiers are not allowed."
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: documents/serialisers.py:2969 documents/views.py:4763
|
#: documents/serialisers.py:2981 documents/views.py:4784
|
||||||
#, python-format
|
#, python-format
|
||||||
msgid "Documents not found: %(ids)s"
|
msgid "Documents not found: %(ids)s"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
@@ -1941,40 +1941,40 @@ msgstr ""
|
|||||||
msgid "Unable to parse URI {value}"
|
msgid "Unable to parse URI {value}"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: documents/views.py:336 documents/views.py:2723
|
#: documents/views.py:335 documents/views.py:2726
|
||||||
msgid "Invalid more_like_id"
|
msgid "Invalid more_like_id"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: documents/views.py:1670
|
#: documents/views.py:1673
|
||||||
msgid "Invalid AI configuration."
|
msgid "Invalid AI configuration."
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: documents/views.py:1681
|
#: documents/views.py:1684
|
||||||
msgid "AI backend request timed out."
|
msgid "AI backend request timed out."
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: documents/views.py:1693
|
#: documents/views.py:1696
|
||||||
msgid "AI backend rejected the request. Check logs for details."
|
msgid "AI backend rejected the request. Check logs for details."
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: documents/views.py:2548 documents/views.py:2864
|
#: documents/views.py:2551 documents/views.py:2867
|
||||||
msgid "Specify only one of text, title_search, query, or more_like_id."
|
msgid "Specify only one of text, title_search, query, or more_like_id."
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: documents/views.py:4776
|
#: documents/views.py:4797
|
||||||
#, python-format
|
#, python-format
|
||||||
msgid "Insufficient permissions to share document %(id)s."
|
msgid "Insufficient permissions to share document %(id)s."
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: documents/views.py:4822
|
#: documents/views.py:4843
|
||||||
msgid "Bundle is already being processed."
|
msgid "Bundle is already being processed."
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: documents/views.py:4886
|
#: documents/views.py:4907
|
||||||
msgid "The share link bundle is still being prepared. Please try again later."
|
msgid "The share link bundle is still being prepared. Please try again later."
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: documents/views.py:4900
|
#: documents/views.py:4921
|
||||||
msgid "The share link bundle is unavailable."
|
msgid "The share link bundle is unavailable."
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
@@ -2219,194 +2219,198 @@ msgid "Sets the LLM embedding model"
|
|||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: paperless/models.py:369
|
#: paperless/models.py:369
|
||||||
msgid "Sets the LLM embedding endpoint, optional"
|
msgid "Sets the LLM embedding API key"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: paperless/models.py:376
|
#: paperless/models.py:376
|
||||||
|
msgid "Sets the LLM embedding endpoint, optional"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: paperless/models.py:383
|
||||||
msgid "Sets the LLM embedding chunk size"
|
msgid "Sets the LLM embedding chunk size"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: paperless/models.py:382
|
#: paperless/models.py:389
|
||||||
msgid "Sets the LLM context size"
|
msgid "Sets the LLM context size"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: paperless/models.py:388
|
#: paperless/models.py:395
|
||||||
msgid "Sets the LLM backend"
|
msgid "Sets the LLM backend"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: paperless/models.py:396
|
#: paperless/models.py:403
|
||||||
msgid "Sets the LLM model"
|
msgid "Sets the LLM model"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: paperless/models.py:403
|
#: paperless/models.py:410
|
||||||
msgid "Sets the LLM API key"
|
msgid "Sets the LLM API key"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: paperless/models.py:410
|
#: paperless/models.py:417
|
||||||
msgid "Sets the LLM endpoint, optional"
|
msgid "Sets the LLM endpoint, optional"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: paperless/models.py:417
|
#: paperless/models.py:424
|
||||||
msgid "Sets the LLM output language"
|
msgid "Sets the LLM output language"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: paperless/models.py:424
|
#: paperless/models.py:431
|
||||||
msgid "Sets the LLM timeout in seconds"
|
msgid "Sets the LLM timeout in seconds"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: paperless/models.py:430
|
#: paperless/models.py:437
|
||||||
msgid "paperless application settings"
|
msgid "paperless application settings"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: paperless/settings/__init__.py:558
|
#: paperless/settings/__init__.py:559
|
||||||
msgid "English (US)"
|
msgid "English (US)"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: paperless/settings/__init__.py:559
|
#: paperless/settings/__init__.py:560
|
||||||
msgid "Arabic"
|
msgid "Arabic"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: paperless/settings/__init__.py:560
|
#: paperless/settings/__init__.py:561
|
||||||
msgid "Afrikaans"
|
msgid "Afrikaans"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: paperless/settings/__init__.py:561
|
#: paperless/settings/__init__.py:562
|
||||||
msgid "Belarusian"
|
msgid "Belarusian"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: paperless/settings/__init__.py:562
|
#: paperless/settings/__init__.py:563
|
||||||
msgid "Bulgarian"
|
msgid "Bulgarian"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: paperless/settings/__init__.py:563
|
#: paperless/settings/__init__.py:564
|
||||||
msgid "Catalan"
|
msgid "Catalan"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: paperless/settings/__init__.py:564
|
#: paperless/settings/__init__.py:565
|
||||||
msgid "Czech"
|
msgid "Czech"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: paperless/settings/__init__.py:565
|
#: paperless/settings/__init__.py:566
|
||||||
msgid "Danish"
|
msgid "Danish"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: paperless/settings/__init__.py:566
|
#: paperless/settings/__init__.py:567
|
||||||
msgid "German"
|
msgid "German"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: paperless/settings/__init__.py:567
|
#: paperless/settings/__init__.py:568
|
||||||
msgid "Greek"
|
msgid "Greek"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: paperless/settings/__init__.py:568
|
#: paperless/settings/__init__.py:569
|
||||||
msgid "English (GB)"
|
msgid "English (GB)"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: paperless/settings/__init__.py:569
|
#: paperless/settings/__init__.py:570
|
||||||
msgid "Spanish"
|
msgid "Spanish"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: paperless/settings/__init__.py:570
|
#: paperless/settings/__init__.py:571
|
||||||
msgid "Persian"
|
msgid "Persian"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: paperless/settings/__init__.py:571
|
#: paperless/settings/__init__.py:572
|
||||||
msgid "Finnish"
|
msgid "Finnish"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: paperless/settings/__init__.py:572
|
#: paperless/settings/__init__.py:573
|
||||||
msgid "French"
|
msgid "French"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: paperless/settings/__init__.py:573
|
#: paperless/settings/__init__.py:574
|
||||||
msgid "Hungarian"
|
msgid "Hungarian"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: paperless/settings/__init__.py:574
|
#: paperless/settings/__init__.py:575
|
||||||
msgid "Indonesian"
|
msgid "Indonesian"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: paperless/settings/__init__.py:575
|
#: paperless/settings/__init__.py:576
|
||||||
msgid "Italian"
|
msgid "Italian"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: paperless/settings/__init__.py:576
|
#: paperless/settings/__init__.py:577
|
||||||
msgid "Japanese"
|
msgid "Japanese"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: paperless/settings/__init__.py:577
|
#: paperless/settings/__init__.py:578
|
||||||
msgid "Korean"
|
msgid "Korean"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: paperless/settings/__init__.py:578
|
#: paperless/settings/__init__.py:579
|
||||||
msgid "Luxembourgish"
|
msgid "Luxembourgish"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: paperless/settings/__init__.py:579
|
#: paperless/settings/__init__.py:580
|
||||||
msgid "Norwegian"
|
msgid "Norwegian"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: paperless/settings/__init__.py:580
|
#: paperless/settings/__init__.py:581
|
||||||
msgid "Dutch"
|
msgid "Dutch"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: paperless/settings/__init__.py:581
|
#: paperless/settings/__init__.py:582
|
||||||
msgid "Polish"
|
msgid "Polish"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: paperless/settings/__init__.py:582
|
#: paperless/settings/__init__.py:583
|
||||||
msgid "Portuguese (Brazil)"
|
msgid "Portuguese (Brazil)"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: paperless/settings/__init__.py:583
|
#: paperless/settings/__init__.py:584
|
||||||
msgid "Portuguese"
|
msgid "Portuguese"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: paperless/settings/__init__.py:584
|
#: paperless/settings/__init__.py:585
|
||||||
msgid "Romanian"
|
msgid "Romanian"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: paperless/settings/__init__.py:585
|
#: paperless/settings/__init__.py:586
|
||||||
msgid "Russian"
|
msgid "Russian"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: paperless/settings/__init__.py:586
|
#: paperless/settings/__init__.py:587
|
||||||
msgid "Slovak"
|
msgid "Slovak"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: paperless/settings/__init__.py:587
|
#: paperless/settings/__init__.py:588
|
||||||
msgid "Slovenian"
|
msgid "Slovenian"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: paperless/settings/__init__.py:588
|
#: paperless/settings/__init__.py:589
|
||||||
msgid "Serbian"
|
msgid "Serbian"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: paperless/settings/__init__.py:589
|
#: paperless/settings/__init__.py:590
|
||||||
msgid "Swedish"
|
msgid "Swedish"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: paperless/settings/__init__.py:590
|
#: paperless/settings/__init__.py:591
|
||||||
msgid "Turkish"
|
msgid "Turkish"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: paperless/settings/__init__.py:591
|
#: paperless/settings/__init__.py:592
|
||||||
msgid "Ukrainian"
|
msgid "Ukrainian"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: paperless/settings/__init__.py:592
|
#: paperless/settings/__init__.py:593
|
||||||
msgid "Vietnamese"
|
msgid "Vietnamese"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: paperless/settings/__init__.py:593
|
#: paperless/settings/__init__.py:594
|
||||||
msgid "Chinese Simplified"
|
msgid "Chinese Simplified"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: paperless/settings/__init__.py:594
|
#: paperless/settings/__init__.py:595
|
||||||
msgid "Chinese Traditional"
|
msgid "Chinese Traditional"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: paperless/urls.py:435
|
#: paperless/urls.py:438
|
||||||
msgid "Paperless-ngx administration"
|
msgid "Paperless-ngx administration"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import dataclasses
|
import dataclasses
|
||||||
import json
|
import json
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
|
|
||||||
@@ -244,6 +245,7 @@ class AIConfig(BaseConfig):
|
|||||||
ai_enabled: bool = dataclasses.field(init=False)
|
ai_enabled: bool = dataclasses.field(init=False)
|
||||||
llm_embedding_backend: str = dataclasses.field(init=False)
|
llm_embedding_backend: str = dataclasses.field(init=False)
|
||||||
llm_embedding_model: str = dataclasses.field(init=False)
|
llm_embedding_model: str = dataclasses.field(init=False)
|
||||||
|
llm_embedding_api_key: str = dataclasses.field(init=False)
|
||||||
llm_embedding_endpoint: str = dataclasses.field(init=False)
|
llm_embedding_endpoint: str = dataclasses.field(init=False)
|
||||||
llm_embedding_chunk_size: int = dataclasses.field(init=False)
|
llm_embedding_chunk_size: int = dataclasses.field(init=False)
|
||||||
llm_context_size: int = dataclasses.field(init=False)
|
llm_context_size: int = dataclasses.field(init=False)
|
||||||
@@ -254,6 +256,7 @@ class AIConfig(BaseConfig):
|
|||||||
llm_endpoint: str = dataclasses.field(init=False)
|
llm_endpoint: str = dataclasses.field(init=False)
|
||||||
llm_output_language: str = dataclasses.field(init=False)
|
llm_output_language: str = dataclasses.field(init=False)
|
||||||
llm_allow_internal_endpoints: bool = dataclasses.field(init=False)
|
llm_allow_internal_endpoints: bool = dataclasses.field(init=False)
|
||||||
|
llm_extra_params: dict[str, Any] = dataclasses.field(init=False)
|
||||||
|
|
||||||
def __post_init__(self) -> None:
|
def __post_init__(self) -> None:
|
||||||
app_config = self._get_config_instance()
|
app_config = self._get_config_instance()
|
||||||
@@ -269,6 +272,9 @@ class AIConfig(BaseConfig):
|
|||||||
self.llm_embedding_model = (
|
self.llm_embedding_model = (
|
||||||
app_config.llm_embedding_model or settings.LLM_EMBEDDING_MODEL
|
app_config.llm_embedding_model or settings.LLM_EMBEDDING_MODEL
|
||||||
)
|
)
|
||||||
|
self.llm_embedding_api_key = (
|
||||||
|
app_config.llm_embedding_api_key or settings.LLM_EMBEDDING_API_KEY
|
||||||
|
)
|
||||||
self.llm_embedding_endpoint = (
|
self.llm_embedding_endpoint = (
|
||||||
app_config.llm_embedding_endpoint or settings.LLM_EMBEDDING_ENDPOINT
|
app_config.llm_embedding_endpoint or settings.LLM_EMBEDDING_ENDPOINT
|
||||||
)
|
)
|
||||||
@@ -287,6 +293,7 @@ class AIConfig(BaseConfig):
|
|||||||
app_config.llm_output_language or settings.LLM_OUTPUT_LANGUAGE
|
app_config.llm_output_language or settings.LLM_OUTPUT_LANGUAGE
|
||||||
)
|
)
|
||||||
self.llm_allow_internal_endpoints = settings.LLM_ALLOW_INTERNAL_ENDPOINTS
|
self.llm_allow_internal_endpoints = settings.LLM_ALLOW_INTERNAL_ENDPOINTS
|
||||||
|
self.llm_extra_params = settings.LLM_EXTRA_PARAMS
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def llm_index_enabled(self) -> bool:
|
def llm_index_enabled(self) -> bool:
|
||||||
|
|||||||
@@ -0,0 +1,23 @@
|
|||||||
|
# Generated by Django 5.2.16 on 2026-09-11 09:32
|
||||||
|
|
||||||
|
from django.db import migrations
|
||||||
|
from django.db import models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
dependencies = [
|
||||||
|
("paperless", "0016_alter_applicationconfiguration_ai_enabled"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="applicationconfiguration",
|
||||||
|
name="llm_embedding_api_key",
|
||||||
|
field=models.CharField(
|
||||||
|
blank=True,
|
||||||
|
max_length=1024,
|
||||||
|
null=True,
|
||||||
|
verbose_name="Sets the LLM embedding API key",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -365,6 +365,13 @@ class ApplicationConfiguration(AbstractSingletonModel):
|
|||||||
max_length=128,
|
max_length=128,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
llm_embedding_api_key = models.CharField(
|
||||||
|
verbose_name=_("Sets the LLM embedding API key"),
|
||||||
|
blank=True,
|
||||||
|
null=True,
|
||||||
|
max_length=1024,
|
||||||
|
)
|
||||||
|
|
||||||
llm_embedding_endpoint = models.CharField(
|
llm_embedding_endpoint = models.CharField(
|
||||||
verbose_name=_("Sets the LLM embedding endpoint, optional"),
|
verbose_name=_("Sets the LLM embedding endpoint, optional"),
|
||||||
blank=True,
|
blank=True,
|
||||||
|
|||||||
@@ -22,8 +22,6 @@ from pathlib import Path
|
|||||||
from typing import TYPE_CHECKING
|
from typing import TYPE_CHECKING
|
||||||
from typing import Self
|
from typing import Self
|
||||||
|
|
||||||
from bleach import clean
|
|
||||||
from bleach import linkify
|
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.utils import timezone
|
from django.utils import timezone
|
||||||
from django.utils.timezone import is_naive
|
from django.utils.timezone import is_naive
|
||||||
@@ -38,6 +36,9 @@ from humanize import naturalsize
|
|||||||
from imap_tools import MailAttachment
|
from imap_tools import MailAttachment
|
||||||
from imap_tools import MailMessage
|
from imap_tools import MailMessage
|
||||||
from tika_client import TikaClient
|
from tika_client import TikaClient
|
||||||
|
from turbohtml.clean import Linkify
|
||||||
|
from turbohtml.clean import linkify
|
||||||
|
from turbohtml.migration.bleach import clean
|
||||||
|
|
||||||
from documents.parsers import ParseError
|
from documents.parsers import ParseError
|
||||||
from documents.parsers import make_thumbnail_from_pdf
|
from documents.parsers import make_thumbnail_from_pdf
|
||||||
@@ -58,12 +59,6 @@ _SUPPORTED_MIME_TYPES: dict[str, str] = {
|
|||||||
"message/rfc822": ".eml",
|
"message/rfc822": ".eml",
|
||||||
}
|
}
|
||||||
|
|
||||||
# Bleach's email-address linkifier uses a superlinear regular expression. Keep
|
|
||||||
# email linkification for ordinary headers and short messages, but never run it
|
|
||||||
# over an unbounded attacker-controlled field. URL linkification remains enabled
|
|
||||||
# for longer text.
|
|
||||||
_MAX_EMAIL_LINKIFY_LENGTH = 2048
|
|
||||||
|
|
||||||
|
|
||||||
class MailDocumentParser:
|
class MailDocumentParser:
|
||||||
"""Parse .eml email files for Paperless-ngx.
|
"""Parse .eml email files for Paperless-ngx.
|
||||||
@@ -633,10 +628,7 @@ class MailDocumentParser:
|
|||||||
text = str(text)
|
text = str(text)
|
||||||
text = escape(text)
|
text = escape(text)
|
||||||
text = clean(text)
|
text = clean(text)
|
||||||
text = linkify(
|
text = linkify(text, Linkify(parse_email=True))
|
||||||
text,
|
|
||||||
parse_email="@" in text and len(text) <= _MAX_EMAIL_LINKIFY_LENGTH,
|
|
||||||
)
|
|
||||||
text = text.replace("\n", "<br>")
|
text = text.replace("\n", "<br>")
|
||||||
return text
|
return text
|
||||||
|
|
||||||
|
|||||||
@@ -216,6 +216,11 @@ class ApplicationConfigurationSerializer(
|
|||||||
externally_configured_variables = serializers.SerializerMethodField()
|
externally_configured_variables = serializers.SerializerMethodField()
|
||||||
user_args = serializers.JSONField(binary=True, allow_null=True)
|
user_args = serializers.JSONField(binary=True, allow_null=True)
|
||||||
barcode_tag_mapping = serializers.JSONField(binary=True, allow_null=True)
|
barcode_tag_mapping = serializers.JSONField(binary=True, allow_null=True)
|
||||||
|
llm_embedding_api_key = ObfuscatedPasswordField(
|
||||||
|
required=False,
|
||||||
|
allow_null=True,
|
||||||
|
max_length=1024,
|
||||||
|
)
|
||||||
llm_api_key = ObfuscatedPasswordField(
|
llm_api_key = ObfuscatedPasswordField(
|
||||||
required=False,
|
required=False,
|
||||||
allow_null=True,
|
allow_null=True,
|
||||||
@@ -227,7 +232,11 @@ class ApplicationConfigurationSerializer(
|
|||||||
max_length=1024,
|
max_length=1024,
|
||||||
)
|
)
|
||||||
|
|
||||||
OBFUSCATED_FIELDS = ("llm_api_key", "remote_ocr_api_key")
|
OBFUSCATED_FIELDS = (
|
||||||
|
"llm_embedding_api_key",
|
||||||
|
"llm_api_key",
|
||||||
|
"remote_ocr_api_key",
|
||||||
|
)
|
||||||
|
|
||||||
def get_externally_configured_variables(
|
def get_externally_configured_variables(
|
||||||
self,
|
self,
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import multiprocessing
|
|||||||
import os
|
import os
|
||||||
import tempfile
|
import tempfile
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
from typing import Final
|
from typing import Final
|
||||||
from urllib.parse import urlparse
|
from urllib.parse import urlparse
|
||||||
|
|
||||||
@@ -1081,6 +1082,25 @@ CLASSIFIER_LANGUAGES: Final[dict[str, str]] = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _get_llm_extra_params() -> dict[str, Any]:
|
||||||
|
"""
|
||||||
|
Parse PAPERLESS_AI_LLM_EXTRA_PARAMS, a JSON object passed straight through
|
||||||
|
to the LLM backend's request body.
|
||||||
|
"""
|
||||||
|
raw = os.getenv("PAPERLESS_AI_LLM_EXTRA_PARAMS", "{}")
|
||||||
|
try:
|
||||||
|
parsed = json.loads(raw)
|
||||||
|
except json.JSONDecodeError as e:
|
||||||
|
raise ImproperlyConfigured(
|
||||||
|
"PAPERLESS_AI_LLM_EXTRA_PARAMS must be valid JSON",
|
||||||
|
) from e
|
||||||
|
if not isinstance(parsed, dict):
|
||||||
|
raise ImproperlyConfigured(
|
||||||
|
"PAPERLESS_AI_LLM_EXTRA_PARAMS must be a JSON object",
|
||||||
|
)
|
||||||
|
return parsed
|
||||||
|
|
||||||
|
|
||||||
def _get_classifier_language_setting(ocr_lang: str) -> str | None:
|
def _get_classifier_language_setting(ocr_lang: str) -> str | None:
|
||||||
"""
|
"""
|
||||||
Maps the primary Tesseract language to the classifier's stemming
|
Maps the primary Tesseract language to the classifier's stemming
|
||||||
@@ -1216,6 +1236,7 @@ LLM_EMBEDDING_BACKEND = get_choice_from_env(
|
|||||||
{"huggingface", "openai-like", "ollama"},
|
{"huggingface", "openai-like", "ollama"},
|
||||||
)
|
)
|
||||||
LLM_EMBEDDING_MODEL = os.getenv("PAPERLESS_AI_LLM_EMBEDDING_MODEL")
|
LLM_EMBEDDING_MODEL = os.getenv("PAPERLESS_AI_LLM_EMBEDDING_MODEL")
|
||||||
|
LLM_EMBEDDING_API_KEY = os.getenv("PAPERLESS_AI_LLM_EMBEDDING_API_KEY")
|
||||||
LLM_EMBEDDING_ENDPOINT = os.getenv("PAPERLESS_AI_LLM_EMBEDDING_ENDPOINT")
|
LLM_EMBEDDING_ENDPOINT = os.getenv("PAPERLESS_AI_LLM_EMBEDDING_ENDPOINT")
|
||||||
LLM_EMBEDDING_CHUNK_SIZE = get_int_from_env(
|
LLM_EMBEDDING_CHUNK_SIZE = get_int_from_env(
|
||||||
"PAPERLESS_AI_LLM_EMBEDDING_CHUNK_SIZE",
|
"PAPERLESS_AI_LLM_EMBEDDING_CHUNK_SIZE",
|
||||||
@@ -1241,3 +1262,4 @@ LLM_ALLOW_INTERNAL_ENDPOINTS = get_bool_from_env(
|
|||||||
"PAPERLESS_AI_LLM_ALLOW_INTERNAL_ENDPOINTS",
|
"PAPERLESS_AI_LLM_ALLOW_INTERNAL_ENDPOINTS",
|
||||||
"true",
|
"true",
|
||||||
)
|
)
|
||||||
|
LLM_EXTRA_PARAMS = _get_llm_extra_params()
|
||||||
|
|||||||
@@ -22,11 +22,11 @@ if TYPE_CHECKING:
|
|||||||
|
|
||||||
|
|
||||||
@pytest.fixture(scope="session")
|
@pytest.fixture(scope="session")
|
||||||
def samples_dir() -> Path:
|
def parser_samples_dir() -> Path:
|
||||||
"""Absolute path to the shared parser sample files directory.
|
"""Absolute path to the shared parser sample files directory.
|
||||||
|
|
||||||
Sub-package conftest files derive format-specific paths from this root,
|
Sub-package conftest files derive format-specific paths from this root,
|
||||||
e.g. ``samples_dir / "text" / "test.txt"``.
|
e.g. ``parser_samples_dir / "text" / "test.txt"``.
|
||||||
|
|
||||||
Returns
|
Returns
|
||||||
-------
|
-------
|
||||||
@@ -37,7 +37,7 @@ def samples_dir() -> Path:
|
|||||||
|
|
||||||
|
|
||||||
@pytest.fixture(scope="session")
|
@pytest.fixture(scope="session")
|
||||||
def tagged_no_text_pdf_file(samples_dir: Path) -> Path:
|
def tagged_no_text_pdf_file(parser_samples_dir: Path) -> Path:
|
||||||
"""Path to a tagged PDF whose only "text" is pdftotext layout padding.
|
"""Path to a tagged PDF whose only "text" is pdftotext layout padding.
|
||||||
|
|
||||||
Reproduces GH #13387: ``/MarkInfo /Marked true`` is set, but the only
|
Reproduces GH #13387: ``/MarkInfo /Marked true`` is set, but the only
|
||||||
@@ -50,7 +50,7 @@ def tagged_no_text_pdf_file(samples_dir: Path) -> Path:
|
|||||||
Path
|
Path
|
||||||
Absolute path to ``tesseract/tagged-but-no-text.pdf``.
|
Absolute path to ``tesseract/tagged-but-no-text.pdf``.
|
||||||
"""
|
"""
|
||||||
return samples_dir / "tesseract" / "tagged-but-no-text.pdf"
|
return parser_samples_dir / "tesseract" / "tagged-but-no-text.pdf"
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture(autouse=True)
|
@pytest.fixture(autouse=True)
|
||||||
|
|||||||
@@ -37,15 +37,15 @@ if TYPE_CHECKING:
|
|||||||
|
|
||||||
|
|
||||||
@pytest.fixture(scope="session")
|
@pytest.fixture(scope="session")
|
||||||
def text_samples_dir(samples_dir: Path) -> Path:
|
def text_samples_dir(parser_samples_dir: Path) -> Path:
|
||||||
"""Absolute path to the text parser sample files directory.
|
"""Absolute path to the text parser sample files directory.
|
||||||
|
|
||||||
Returns
|
Returns
|
||||||
-------
|
-------
|
||||||
Path
|
Path
|
||||||
``<samples_dir>/text/``
|
``<parser_samples_dir>/text/``
|
||||||
"""
|
"""
|
||||||
return samples_dir / "text"
|
return parser_samples_dir / "text"
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture(scope="session")
|
@pytest.fixture(scope="session")
|
||||||
@@ -175,15 +175,15 @@ def no_engine_settings(
|
|||||||
|
|
||||||
|
|
||||||
@pytest.fixture(scope="session")
|
@pytest.fixture(scope="session")
|
||||||
def tika_samples_dir(samples_dir: Path) -> Path:
|
def tika_samples_dir(parser_samples_dir: Path) -> Path:
|
||||||
"""Absolute path to the Tika parser sample files directory.
|
"""Absolute path to the Tika parser sample files directory.
|
||||||
|
|
||||||
Returns
|
Returns
|
||||||
-------
|
-------
|
||||||
Path
|
Path
|
||||||
``<samples_dir>/tika/``
|
``<parser_samples_dir>/tika/``
|
||||||
"""
|
"""
|
||||||
return samples_dir / "tika"
|
return parser_samples_dir / "tika"
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture(scope="session")
|
@pytest.fixture(scope="session")
|
||||||
@@ -258,15 +258,15 @@ def tika_parser() -> Generator[TikaDocumentParser, None, None]:
|
|||||||
|
|
||||||
|
|
||||||
@pytest.fixture(scope="session")
|
@pytest.fixture(scope="session")
|
||||||
def mail_samples_dir(samples_dir: Path) -> Path:
|
def mail_samples_dir(parser_samples_dir: Path) -> Path:
|
||||||
"""Absolute path to the mail parser sample files directory.
|
"""Absolute path to the mail parser sample files directory.
|
||||||
|
|
||||||
Returns
|
Returns
|
||||||
-------
|
-------
|
||||||
Path
|
Path
|
||||||
``<samples_dir>/mail/``
|
``<parser_samples_dir>/mail/``
|
||||||
"""
|
"""
|
||||||
return samples_dir / "mail"
|
return parser_samples_dir / "mail"
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture(scope="session")
|
@pytest.fixture(scope="session")
|
||||||
@@ -421,75 +421,15 @@ def nginx_base_url() -> Generator[str, None, None]:
|
|||||||
|
|
||||||
|
|
||||||
@pytest.fixture(scope="session")
|
@pytest.fixture(scope="session")
|
||||||
def tesseract_samples_dir(samples_dir: Path) -> Path:
|
def tesseract_samples_dir(parser_samples_dir: Path) -> Path:
|
||||||
"""Absolute path to the tesseract parser sample files directory.
|
"""Absolute path to the tesseract parser sample files directory.
|
||||||
|
|
||||||
Returns
|
Returns
|
||||||
-------
|
-------
|
||||||
Path
|
Path
|
||||||
``<samples_dir>/tesseract/``
|
``<parser_samples_dir>/tesseract/``
|
||||||
"""
|
"""
|
||||||
return samples_dir / "tesseract"
|
return parser_samples_dir / "tesseract"
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture(scope="session")
|
|
||||||
def document_webp_file(tesseract_samples_dir: Path) -> Path:
|
|
||||||
"""Path to a WebP document sample file.
|
|
||||||
|
|
||||||
Returns
|
|
||||||
-------
|
|
||||||
Path
|
|
||||||
Absolute path to ``tesseract/document.webp``.
|
|
||||||
"""
|
|
||||||
return tesseract_samples_dir / "document.webp"
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture(scope="session")
|
|
||||||
def encrypted_pdf_file(tesseract_samples_dir: Path) -> Path:
|
|
||||||
"""Path to an encrypted PDF sample file.
|
|
||||||
|
|
||||||
Returns
|
|
||||||
-------
|
|
||||||
Path
|
|
||||||
Absolute path to ``tesseract/encrypted.pdf``.
|
|
||||||
"""
|
|
||||||
return tesseract_samples_dir / "encrypted.pdf"
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture(scope="session")
|
|
||||||
def multi_page_digital_pdf_file(tesseract_samples_dir: Path) -> Path:
|
|
||||||
"""Path to a multi-page digital PDF sample file.
|
|
||||||
|
|
||||||
Returns
|
|
||||||
-------
|
|
||||||
Path
|
|
||||||
Absolute path to ``tesseract/multi-page-digital.pdf``.
|
|
||||||
"""
|
|
||||||
return tesseract_samples_dir / "multi-page-digital.pdf"
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture(scope="session")
|
|
||||||
def multi_page_images_alpha_rgb_tiff_file(tesseract_samples_dir: Path) -> Path:
|
|
||||||
"""Path to a multi-page TIFF with alpha channel in RGB.
|
|
||||||
|
|
||||||
Returns
|
|
||||||
-------
|
|
||||||
Path
|
|
||||||
Absolute path to ``tesseract/multi-page-images-alpha-rgb.tiff``.
|
|
||||||
"""
|
|
||||||
return tesseract_samples_dir / "multi-page-images-alpha-rgb.tiff"
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture(scope="session")
|
|
||||||
def multi_page_images_alpha_tiff_file(tesseract_samples_dir: Path) -> Path:
|
|
||||||
"""Path to a multi-page TIFF with alpha channel.
|
|
||||||
|
|
||||||
Returns
|
|
||||||
-------
|
|
||||||
Path
|
|
||||||
Absolute path to ``tesseract/multi-page-images-alpha.tiff``.
|
|
||||||
"""
|
|
||||||
return tesseract_samples_dir / "multi-page-images-alpha.tiff"
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture(scope="session")
|
@pytest.fixture(scope="session")
|
||||||
@@ -504,90 +444,6 @@ def multi_page_images_pdf_file(tesseract_samples_dir: Path) -> Path:
|
|||||||
return tesseract_samples_dir / "multi-page-images.pdf"
|
return tesseract_samples_dir / "multi-page-images.pdf"
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture(scope="session")
|
|
||||||
def multi_page_images_tiff_file(tesseract_samples_dir: Path) -> Path:
|
|
||||||
"""Path to a multi-page TIFF sample file.
|
|
||||||
|
|
||||||
Returns
|
|
||||||
-------
|
|
||||||
Path
|
|
||||||
Absolute path to ``tesseract/multi-page-images.tiff``.
|
|
||||||
"""
|
|
||||||
return tesseract_samples_dir / "multi-page-images.tiff"
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture(scope="session")
|
|
||||||
def multi_page_mixed_pdf_file(tesseract_samples_dir: Path) -> Path:
|
|
||||||
"""Path to a multi-page mixed PDF sample file.
|
|
||||||
|
|
||||||
Returns
|
|
||||||
-------
|
|
||||||
Path
|
|
||||||
Absolute path to ``tesseract/multi-page-mixed.pdf``.
|
|
||||||
"""
|
|
||||||
return tesseract_samples_dir / "multi-page-mixed.pdf"
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture(scope="session")
|
|
||||||
def no_text_alpha_png_file(tesseract_samples_dir: Path) -> Path:
|
|
||||||
"""Path to a PNG with alpha channel and no text.
|
|
||||||
|
|
||||||
Returns
|
|
||||||
-------
|
|
||||||
Path
|
|
||||||
Absolute path to ``tesseract/no-text-alpha.png``.
|
|
||||||
"""
|
|
||||||
return tesseract_samples_dir / "no-text-alpha.png"
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture(scope="session")
|
|
||||||
def rotated_pdf_file(tesseract_samples_dir: Path) -> Path:
|
|
||||||
"""Path to a rotated PDF sample file.
|
|
||||||
|
|
||||||
Returns
|
|
||||||
-------
|
|
||||||
Path
|
|
||||||
Absolute path to ``tesseract/rotated.pdf``.
|
|
||||||
"""
|
|
||||||
return tesseract_samples_dir / "rotated.pdf"
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture(scope="session")
|
|
||||||
def rtl_test_pdf_file(tesseract_samples_dir: Path) -> Path:
|
|
||||||
"""Path to an RTL test PDF sample file.
|
|
||||||
|
|
||||||
Returns
|
|
||||||
-------
|
|
||||||
Path
|
|
||||||
Absolute path to ``tesseract/rtl-test.pdf``.
|
|
||||||
"""
|
|
||||||
return tesseract_samples_dir / "rtl-test.pdf"
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture(scope="session")
|
|
||||||
def signed_pdf_file(tesseract_samples_dir: Path) -> Path:
|
|
||||||
"""Path to a signed PDF sample file.
|
|
||||||
|
|
||||||
Returns
|
|
||||||
-------
|
|
||||||
Path
|
|
||||||
Absolute path to ``tesseract/signed.pdf``.
|
|
||||||
"""
|
|
||||||
return tesseract_samples_dir / "signed.pdf"
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture(scope="session")
|
|
||||||
def simple_alpha_png_file(tesseract_samples_dir: Path) -> Path:
|
|
||||||
"""Path to a simple PNG with alpha channel.
|
|
||||||
|
|
||||||
Returns
|
|
||||||
-------
|
|
||||||
Path
|
|
||||||
Absolute path to ``tesseract/simple-alpha.png``.
|
|
||||||
"""
|
|
||||||
return tesseract_samples_dir / "simple-alpha.png"
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture(scope="session")
|
@pytest.fixture(scope="session")
|
||||||
def simple_digital_pdf_file(tesseract_samples_dir: Path) -> Path:
|
def simple_digital_pdf_file(tesseract_samples_dir: Path) -> Path:
|
||||||
"""Path to a simple digital PDF sample file.
|
"""Path to a simple digital PDF sample file.
|
||||||
@@ -612,54 +468,6 @@ def simple_no_dpi_png_file(tesseract_samples_dir: Path) -> Path:
|
|||||||
return tesseract_samples_dir / "simple-no-dpi.png"
|
return tesseract_samples_dir / "simple-no-dpi.png"
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture(scope="session")
|
|
||||||
def simple_bmp_file(tesseract_samples_dir: Path) -> Path:
|
|
||||||
"""Path to a simple BMP sample file.
|
|
||||||
|
|
||||||
Returns
|
|
||||||
-------
|
|
||||||
Path
|
|
||||||
Absolute path to ``tesseract/simple.bmp``.
|
|
||||||
"""
|
|
||||||
return tesseract_samples_dir / "simple.bmp"
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture(scope="session")
|
|
||||||
def simple_gif_file(tesseract_samples_dir: Path) -> Path:
|
|
||||||
"""Path to a simple GIF sample file.
|
|
||||||
|
|
||||||
Returns
|
|
||||||
-------
|
|
||||||
Path
|
|
||||||
Absolute path to ``tesseract/simple.gif``.
|
|
||||||
"""
|
|
||||||
return tesseract_samples_dir / "simple.gif"
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture(scope="session")
|
|
||||||
def simple_heic_file(tesseract_samples_dir: Path) -> Path:
|
|
||||||
"""Path to a simple HEIC sample file.
|
|
||||||
|
|
||||||
Returns
|
|
||||||
-------
|
|
||||||
Path
|
|
||||||
Absolute path to ``tesseract/simple.heic``.
|
|
||||||
"""
|
|
||||||
return tesseract_samples_dir / "simple.heic"
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture(scope="session")
|
|
||||||
def simple_jpg_file(tesseract_samples_dir: Path) -> Path:
|
|
||||||
"""Path to a simple JPG sample file.
|
|
||||||
|
|
||||||
Returns
|
|
||||||
-------
|
|
||||||
Path
|
|
||||||
Absolute path to ``tesseract/simple.jpg``.
|
|
||||||
"""
|
|
||||||
return tesseract_samples_dir / "simple.jpg"
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture(scope="session")
|
@pytest.fixture(scope="session")
|
||||||
def simple_png_file(tesseract_samples_dir: Path) -> Path:
|
def simple_png_file(tesseract_samples_dir: Path) -> Path:
|
||||||
"""Path to a simple PNG sample file.
|
"""Path to a simple PNG sample file.
|
||||||
@@ -672,42 +480,6 @@ def simple_png_file(tesseract_samples_dir: Path) -> Path:
|
|||||||
return tesseract_samples_dir / "simple.png"
|
return tesseract_samples_dir / "simple.png"
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture(scope="session")
|
|
||||||
def simple_tif_file(tesseract_samples_dir: Path) -> Path:
|
|
||||||
"""Path to a simple TIF sample file.
|
|
||||||
|
|
||||||
Returns
|
|
||||||
-------
|
|
||||||
Path
|
|
||||||
Absolute path to ``tesseract/simple.tif``.
|
|
||||||
"""
|
|
||||||
return tesseract_samples_dir / "simple.tif"
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture(scope="session")
|
|
||||||
def single_page_mixed_pdf_file(tesseract_samples_dir: Path) -> Path:
|
|
||||||
"""Path to a single-page mixed PDF sample file.
|
|
||||||
|
|
||||||
Returns
|
|
||||||
-------
|
|
||||||
Path
|
|
||||||
Absolute path to ``tesseract/single-page-mixed.pdf``.
|
|
||||||
"""
|
|
||||||
return tesseract_samples_dir / "single-page-mixed.pdf"
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture(scope="session")
|
|
||||||
def with_form_pdf_file(tesseract_samples_dir: Path) -> Path:
|
|
||||||
"""Path to a PDF with form sample file.
|
|
||||||
|
|
||||||
Returns
|
|
||||||
-------
|
|
||||||
Path
|
|
||||||
Absolute path to ``tesseract/with-form.pdf``.
|
|
||||||
"""
|
|
||||||
return tesseract_samples_dir / "with-form.pdf"
|
|
||||||
|
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
# Tesseract parser instance and settings helpers
|
# Tesseract parser instance and settings helpers
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
|
|||||||
@@ -735,33 +735,24 @@ class TestParser:
|
|||||||
|
|
||||||
assert expected_html == actual_html
|
assert expected_html == actual_html
|
||||||
|
|
||||||
def test_mail_to_html_bounds_email_linkification(
|
def test_mail_to_html_linkifies_email_in_long_text(
|
||||||
self,
|
self,
|
||||||
mail_parser: MailDocumentParser,
|
mail_parser: MailDocumentParser,
|
||||||
) -> None:
|
) -> None:
|
||||||
mail = mock.Mock(
|
mail = mock.Mock(
|
||||||
subject="sender@example.com",
|
subject="",
|
||||||
from_values=None,
|
from_values=None,
|
||||||
to_values=[],
|
to_values=[],
|
||||||
cc_values=[],
|
cc_values=[],
|
||||||
bcc_values=[],
|
bcc_values=[],
|
||||||
attachments=[],
|
attachments=[],
|
||||||
date=timezone.now(),
|
date=timezone.now(),
|
||||||
text=("a." * 1500) + "@example.com",
|
text=("a." * 1500) + " sender@example.com",
|
||||||
)
|
)
|
||||||
|
|
||||||
with mock.patch(
|
html_file = mail_parser.mail_to_html(mail)
|
||||||
"paperless.parsers.mail.linkify",
|
|
||||||
side_effect=lambda text, **kwargs: text,
|
|
||||||
) as mock_linkify:
|
|
||||||
mail_parser.mail_to_html(mail)
|
|
||||||
|
|
||||||
parse_email_by_text = {
|
assert 'href="mailto:sender@example.com"' in html_file.read_text()
|
||||||
call.args[0]: call.kwargs["parse_email"]
|
|
||||||
for call in mock_linkify.call_args_list
|
|
||||||
}
|
|
||||||
assert parse_email_by_text["sender@example.com"] is True
|
|
||||||
assert parse_email_by_text[mail.text] is False
|
|
||||||
|
|
||||||
def test_generate_pdf_from_mail(
|
def test_generate_pdf_from_mail(
|
||||||
self,
|
self,
|
||||||
|
|||||||
@@ -10,8 +10,8 @@ from imagehash import average_hash
|
|||||||
from PIL import Image
|
from PIL import Image
|
||||||
from pytest_mock import MockerFixture
|
from pytest_mock import MockerFixture
|
||||||
|
|
||||||
from documents.tests.utils import util_call_with_backoff
|
|
||||||
from paperless.parsers.mail import MailDocumentParser
|
from paperless.parsers.mail import MailDocumentParser
|
||||||
|
from paperless_testing.retry import util_call_with_backoff
|
||||||
|
|
||||||
|
|
||||||
def extract_text(pdf_path: Path) -> str:
|
def extract_text(pdf_path: Path) -> str:
|
||||||
|
|||||||
@@ -3,13 +3,13 @@ import json
|
|||||||
from django.test import TestCase
|
from django.test import TestCase
|
||||||
from django.test import override_settings
|
from django.test import override_settings
|
||||||
|
|
||||||
from documents.tests.utils import FileSystemAssertsMixin
|
|
||||||
from paperless.models import ApplicationConfiguration
|
from paperless.models import ApplicationConfiguration
|
||||||
from paperless.models import CleanChoices
|
from paperless.models import CleanChoices
|
||||||
from paperless.models import ColorConvertChoices
|
from paperless.models import ColorConvertChoices
|
||||||
from paperless.models import ModeChoices
|
from paperless.models import ModeChoices
|
||||||
from paperless.models import OutputTypeChoices
|
from paperless.models import OutputTypeChoices
|
||||||
from paperless.parsers.tesseract import RasterisedDocumentParser
|
from paperless.parsers.tesseract import RasterisedDocumentParser
|
||||||
|
from paperless_testing.assertions import FileSystemAssertsMixin
|
||||||
from paperless_testing.dirs import DirectoriesMixin
|
from paperless_testing.dirs import DirectoriesMixin
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -3,8 +3,8 @@ from pathlib import Path
|
|||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from documents.tests.utils import util_call_with_backoff
|
|
||||||
from paperless.parsers.tika import TikaDocumentParser
|
from paperless.parsers.tika import TikaDocumentParser
|
||||||
|
from paperless_testing.retry import util_call_with_backoff
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.skipif(
|
@pytest.mark.skipif(
|
||||||
|
|||||||
@@ -17,6 +17,24 @@ class TestRemoteUser(DirectoriesMixin, APITestCase):
|
|||||||
|
|
||||||
self.user = UserFactory(username="temp_admin", superuser=True)
|
self.user = UserFactory(username="temp_admin", superuser=True)
|
||||||
|
|
||||||
|
# _parse_remote_user_settings() mutates these shared lists in place,
|
||||||
|
# so undo that after the test instead of leaking remote-user auth
|
||||||
|
# into every test that runs afterward.
|
||||||
|
original_middleware = list(settings.MIDDLEWARE)
|
||||||
|
original_auth_backends = list(settings.AUTHENTICATION_BACKENDS)
|
||||||
|
original_auth_classes = list(
|
||||||
|
settings.REST_FRAMEWORK["DEFAULT_AUTHENTICATION_CLASSES"],
|
||||||
|
)
|
||||||
|
|
||||||
|
def _restore_remote_user_settings() -> None:
|
||||||
|
settings.MIDDLEWARE[:] = original_middleware
|
||||||
|
settings.AUTHENTICATION_BACKENDS[:] = original_auth_backends
|
||||||
|
settings.REST_FRAMEWORK["DEFAULT_AUTHENTICATION_CLASSES"][:] = (
|
||||||
|
original_auth_classes
|
||||||
|
)
|
||||||
|
|
||||||
|
self.addCleanup(_restore_remote_user_settings)
|
||||||
|
|
||||||
def test_remote_user(self) -> None:
|
def test_remote_user(self) -> None:
|
||||||
"""
|
"""
|
||||||
GIVEN:
|
GIVEN:
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ from django.core.exceptions import ImproperlyConfigured
|
|||||||
|
|
||||||
from paperless.settings import _get_allauth_trusted_proxy_count
|
from paperless.settings import _get_allauth_trusted_proxy_count
|
||||||
from paperless.settings import _get_classifier_language_setting
|
from paperless.settings import _get_classifier_language_setting
|
||||||
|
from paperless.settings import _get_llm_extra_params
|
||||||
from paperless.settings import _get_search_language_setting
|
from paperless.settings import _get_search_language_setting
|
||||||
from paperless.settings import _parse_paperless_url
|
from paperless.settings import _parse_paperless_url
|
||||||
from paperless.settings import default_threads_per_worker
|
from paperless.settings import default_threads_per_worker
|
||||||
@@ -166,3 +167,45 @@ class TestPaperlessURLSettings(TestCase):
|
|||||||
|
|
||||||
self.assertIn(url, settings.CSRF_TRUSTED_ORIGINS)
|
self.assertIn(url, settings.CSRF_TRUSTED_ORIGINS)
|
||||||
self.assertIn(url, settings.CORS_ALLOWED_ORIGINS)
|
self.assertIn(url, settings.CORS_ALLOWED_ORIGINS)
|
||||||
|
|
||||||
|
|
||||||
|
class TestLlmExtraParams:
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
("env_value", "expected"),
|
||||||
|
[
|
||||||
|
pytest.param(None, {}, id="unset"),
|
||||||
|
pytest.param(
|
||||||
|
'{"reasoning_effort": "none"}',
|
||||||
|
{"reasoning_effort": "none"},
|
||||||
|
id="json-object",
|
||||||
|
),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_parses(
|
||||||
|
self,
|
||||||
|
monkeypatch,
|
||||||
|
env_value,
|
||||||
|
expected,
|
||||||
|
):
|
||||||
|
if env_value is None:
|
||||||
|
monkeypatch.delenv("PAPERLESS_AI_LLM_EXTRA_PARAMS", raising=False)
|
||||||
|
else:
|
||||||
|
monkeypatch.setenv("PAPERLESS_AI_LLM_EXTRA_PARAMS", env_value)
|
||||||
|
assert _get_llm_extra_params() == expected
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
("env_value", "match"),
|
||||||
|
[
|
||||||
|
pytest.param("reasoning_effort=none", "valid JSON", id="invalid-json"),
|
||||||
|
pytest.param('["none"]', "JSON object", id="not-an-object"),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_invalid_raises(
|
||||||
|
self,
|
||||||
|
monkeypatch,
|
||||||
|
env_value,
|
||||||
|
match,
|
||||||
|
):
|
||||||
|
monkeypatch.setenv("PAPERLESS_AI_LLM_EXTRA_PARAMS", env_value)
|
||||||
|
with pytest.raises(ImproperlyConfigured, match=match):
|
||||||
|
_get_llm_extra_params()
|
||||||
|
|||||||
@@ -0,0 +1,68 @@
|
|||||||
|
import time
|
||||||
|
|
||||||
|
from allauth.mfa import app_settings as mfa_settings
|
||||||
|
from allauth.mfa.totp.internal import auth as totp_auth
|
||||||
|
from django.test import TestCase
|
||||||
|
from django.urls import reverse
|
||||||
|
|
||||||
|
from paperless_testing.factories import UserFactory
|
||||||
|
|
||||||
|
|
||||||
|
class TestAdminAuth(TestCase):
|
||||||
|
def test_admin_login_redirects_to_allauth(self):
|
||||||
|
user = UserFactory(staff=True, password="testpassword")
|
||||||
|
admin_url = reverse("admin:index")
|
||||||
|
login_url = reverse("admin:login")
|
||||||
|
expected_url = f"{reverse('account_login')}?next={admin_url}"
|
||||||
|
|
||||||
|
response = self.client.get(login_url, {"next": admin_url})
|
||||||
|
self.assertRedirects(response, expected_url)
|
||||||
|
|
||||||
|
response = self.client.post(
|
||||||
|
login_url,
|
||||||
|
{"username": user.username, "password": "testpassword", "next": admin_url},
|
||||||
|
)
|
||||||
|
self.assertRedirects(response, expected_url)
|
||||||
|
self.assertNotIn("_auth_user_id", self.client.session)
|
||||||
|
|
||||||
|
def test_admin_access_requires_totp_for_enrolled_staff(self):
|
||||||
|
user = UserFactory(staff=True, password="testpassword")
|
||||||
|
secret = totp_auth.generate_totp_secret()
|
||||||
|
totp_auth.TOTP.activate(user, secret)
|
||||||
|
admin_url = reverse("admin:index")
|
||||||
|
mfa_url = reverse("mfa_authenticate")
|
||||||
|
|
||||||
|
response = self.client.post(
|
||||||
|
reverse("account_login"),
|
||||||
|
{"login": user.username, "password": "testpassword", "next": admin_url},
|
||||||
|
)
|
||||||
|
self.assertRedirects(response, mfa_url)
|
||||||
|
self.assertNotIn("_auth_user_id", self.client.session)
|
||||||
|
self.assertRedirects(
|
||||||
|
self.client.get(admin_url),
|
||||||
|
f"{reverse('admin:login')}?next={admin_url}",
|
||||||
|
fetch_redirect_response=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
response = self.client.post(mfa_url, {"code": "invalid"})
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
self.assertNotIn("_auth_user_id", self.client.session)
|
||||||
|
|
||||||
|
code = totp_auth.format_hotp_value(
|
||||||
|
totp_auth.hotp_value(secret, int(time.time()) // mfa_settings.TOTP_PERIOD),
|
||||||
|
)
|
||||||
|
response = self.client.post(mfa_url, {"code": code})
|
||||||
|
self.assertRedirects(response, admin_url)
|
||||||
|
self.assertEqual(self.client.session["_auth_user_id"], str(user.pk))
|
||||||
|
|
||||||
|
def test_staff_without_totp_can_still_log_in(self):
|
||||||
|
user = UserFactory(staff=True, password="testpassword")
|
||||||
|
admin_url = reverse("admin:index")
|
||||||
|
|
||||||
|
response = self.client.post(
|
||||||
|
reverse("account_login"),
|
||||||
|
{"login": user.username, "password": "testpassword", "next": admin_url},
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertRedirects(response, admin_url)
|
||||||
|
self.assertEqual(self.client.session["_auth_user_id"], str(user.pk))
|
||||||
@@ -30,3 +30,27 @@ class TestBooleanConfigPrecedence(TestCase):
|
|||||||
config.save()
|
config.save()
|
||||||
|
|
||||||
self.assertTrue(AIConfig().ai_enabled)
|
self.assertTrue(AIConfig().ai_enabled)
|
||||||
|
|
||||||
|
|
||||||
|
class TestAIConfigPrecedence(TestCase):
|
||||||
|
@override_settings(LLM_EMBEDDING_API_KEY="environment-embedding-key")
|
||||||
|
def test_database_embedding_api_key_overrides_environment_setting(self) -> None:
|
||||||
|
config, _ = ApplicationConfiguration.objects.get_or_create()
|
||||||
|
config.llm_embedding_api_key = "database-embedding-key"
|
||||||
|
config.save()
|
||||||
|
|
||||||
|
self.assertEqual(
|
||||||
|
AIConfig().llm_embedding_api_key,
|
||||||
|
"database-embedding-key",
|
||||||
|
)
|
||||||
|
|
||||||
|
@override_settings(LLM_EMBEDDING_API_KEY="environment-embedding-key")
|
||||||
|
def test_null_embedding_api_key_uses_environment_setting(self) -> None:
|
||||||
|
config, _ = ApplicationConfiguration.objects.get_or_create()
|
||||||
|
config.llm_embedding_api_key = None
|
||||||
|
config.save()
|
||||||
|
|
||||||
|
self.assertEqual(
|
||||||
|
AIConfig().llm_embedding_api_key,
|
||||||
|
"environment-embedding-key",
|
||||||
|
)
|
||||||
|
|||||||
@@ -1,10 +1,9 @@
|
|||||||
from documents.tests.utils import TestMigrations
|
from paperless_testing.migrations import TestMigrations
|
||||||
|
|
||||||
|
|
||||||
class TestMigrateSkipArchiveFile(TestMigrations):
|
class TestMigrateSkipArchiveFile(TestMigrations):
|
||||||
migrate_from = "0007_optimize_integer_field_sizes"
|
migrate_from = "0007_optimize_integer_field_sizes"
|
||||||
migrate_to = "0008_replace_skip_archive_file"
|
migrate_to = "0008_replace_skip_archive_file"
|
||||||
migrate_once = True
|
|
||||||
|
|
||||||
def setUpBeforeMigration(self, apps):
|
def setUpBeforeMigration(self, apps):
|
||||||
ApplicationConfiguration = apps.get_model(
|
ApplicationConfiguration = apps.get_model(
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
from allauth.account import views as allauth_account_views
|
from allauth.account import views as allauth_account_views
|
||||||
|
from allauth.account.decorators import secure_admin_login
|
||||||
from allauth.mfa.base import views as allauth_mfa_views
|
from allauth.mfa.base import views as allauth_mfa_views
|
||||||
from allauth.socialaccount import views as allauth_social_account_views
|
from allauth.socialaccount import views as allauth_social_account_views
|
||||||
from allauth.urls import build_provider_urlpatterns
|
from allauth.urls import build_provider_urlpatterns
|
||||||
@@ -68,6 +69,8 @@ from paperless_mail.views import MailRuleViewSet
|
|||||||
from paperless_mail.views import OauthCallbackView
|
from paperless_mail.views import OauthCallbackView
|
||||||
from paperless_mail.views import ProcessedMailViewSet
|
from paperless_mail.views import ProcessedMailViewSet
|
||||||
|
|
||||||
|
admin.site.login = secure_admin_login(admin.site.login)
|
||||||
|
|
||||||
api_router = DefaultRouter()
|
api_router = DefaultRouter()
|
||||||
api_router.register(r"correspondents", CorrespondentViewSet)
|
api_router.register(r"correspondents", CorrespondentViewSet)
|
||||||
api_router.register(r"document_types", DocumentTypeViewSet)
|
api_router.register(r"document_types", DocumentTypeViewSet)
|
||||||
|
|||||||
@@ -75,6 +75,7 @@ class AIClient:
|
|||||||
context_window=self.settings.llm_context_size,
|
context_window=self.settings.llm_context_size,
|
||||||
request_timeout=self.settings.llm_request_timeout,
|
request_timeout=self.settings.llm_request_timeout,
|
||||||
system_prompt=LLM_SYSTEM_PROMPT,
|
system_prompt=LLM_SYSTEM_PROMPT,
|
||||||
|
additional_kwargs=self.settings.llm_extra_params,
|
||||||
client=Client(
|
client=Client(
|
||||||
host=endpoint,
|
host=endpoint,
|
||||||
timeout=self.settings.llm_request_timeout,
|
timeout=self.settings.llm_request_timeout,
|
||||||
@@ -111,6 +112,7 @@ class AIClient:
|
|||||||
is_chat_model=True,
|
is_chat_model=True,
|
||||||
is_function_calling_model=True,
|
is_function_calling_model=True,
|
||||||
system_prompt=LLM_SYSTEM_PROMPT,
|
system_prompt=LLM_SYSTEM_PROMPT,
|
||||||
|
additional_kwargs=self.settings.llm_extra_params,
|
||||||
http_client=http_client,
|
http_client=http_client,
|
||||||
async_http_client=async_http_client,
|
async_http_client=async_http_client,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -41,7 +41,9 @@ def get_embedding_model(config: AIConfig) -> "BaseEmbedding":
|
|||||||
)
|
)
|
||||||
return OpenAILikeEmbedding(
|
return OpenAILikeEmbedding(
|
||||||
model_name=config.llm_embedding_model or "text-embedding-3-small",
|
model_name=config.llm_embedding_model or "text-embedding-3-small",
|
||||||
api_key=config.llm_api_key or PLACEHOLDER_API_KEY,
|
api_key=config.llm_embedding_api_key
|
||||||
|
or config.llm_api_key
|
||||||
|
or PLACEHOLDER_API_KEY,
|
||||||
api_base=endpoint,
|
api_base=endpoint,
|
||||||
timeout=config.llm_request_timeout,
|
timeout=config.llm_request_timeout,
|
||||||
http_client=http_client,
|
http_client=http_client,
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import datetime
|
import datetime
|
||||||
from collections.abc import Generator
|
from collections.abc import Generator
|
||||||
from types import SimpleNamespace
|
from types import SimpleNamespace
|
||||||
|
from typing import TYPE_CHECKING
|
||||||
from unittest.mock import MagicMock
|
from unittest.mock import MagicMock
|
||||||
from unittest.mock import patch
|
from unittest.mock import patch
|
||||||
|
|
||||||
@@ -28,6 +29,9 @@ from paperless_testing.factories import TagFactory
|
|||||||
from paperless_testing.factories import UserFactory
|
from paperless_testing.factories import UserFactory
|
||||||
from paperless_testing.permissions import grant_object
|
from paperless_testing.permissions import grant_object
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from paperless_testing.dirs import PaperlessDirs
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
def mock_document():
|
def mock_document():
|
||||||
@@ -630,10 +634,11 @@ class TestFulltextSimilarDocuments:
|
|||||||
def fulltext_backend(
|
def fulltext_backend(
|
||||||
self,
|
self,
|
||||||
mocker: pytest_mock.MockerFixture,
|
mocker: pytest_mock.MockerFixture,
|
||||||
|
paperless_dirs: "PaperlessDirs",
|
||||||
) -> Generator[TantivyBackend, None, None]:
|
) -> Generator[TantivyBackend, None, None]:
|
||||||
"""An in-memory Tantivy backend, wired up as the module-level
|
"""An on-disk Tantivy backend, wired up as the module-level
|
||||||
singleton _fulltext_similar_documents resolves via get_backend()."""
|
singleton _fulltext_similar_documents resolves via get_backend()."""
|
||||||
backend = TantivyBackend(path=None)
|
backend = TantivyBackend(path=paperless_dirs.index_dir)
|
||||||
backend.open()
|
backend.open()
|
||||||
mocker.patch("documents.search.get_backend", return_value=backend)
|
mocker.patch("documents.search.get_backend", return_value=backend)
|
||||||
try:
|
try:
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ def mock_ai_config():
|
|||||||
mock_config.llm_allow_internal_endpoints = True
|
mock_config.llm_allow_internal_endpoints = True
|
||||||
mock_config.llm_context_size = 8192
|
mock_config.llm_context_size = 8192
|
||||||
mock_config.llm_request_timeout = 120
|
mock_config.llm_request_timeout = 120
|
||||||
|
mock_config.llm_extra_params = {}
|
||||||
MockAIConfig.return_value = mock_config
|
MockAIConfig.return_value = mock_config
|
||||||
yield mock_config
|
yield mock_config
|
||||||
|
|
||||||
@@ -52,6 +53,7 @@ def test_get_llm_ollama(mock_ai_config, mock_ollama_llm):
|
|||||||
context_window=8192,
|
context_window=8192,
|
||||||
request_timeout=120,
|
request_timeout=120,
|
||||||
system_prompt=LLM_SYSTEM_PROMPT,
|
system_prompt=LLM_SYSTEM_PROMPT,
|
||||||
|
additional_kwargs={},
|
||||||
client=ANY,
|
client=ANY,
|
||||||
async_client=ANY,
|
async_client=ANY,
|
||||||
)
|
)
|
||||||
@@ -74,6 +76,7 @@ def test_get_llm_openai(mock_ai_config, mock_openai_llm):
|
|||||||
is_chat_model=True,
|
is_chat_model=True,
|
||||||
is_function_calling_model=True,
|
is_function_calling_model=True,
|
||||||
system_prompt=LLM_SYSTEM_PROMPT,
|
system_prompt=LLM_SYSTEM_PROMPT,
|
||||||
|
additional_kwargs={},
|
||||||
http_client=ANY,
|
http_client=ANY,
|
||||||
async_http_client=ANY,
|
async_http_client=ANY,
|
||||||
)
|
)
|
||||||
@@ -196,6 +199,36 @@ def test_run_llm_query_openai_uses_tools(mock_ai_config, mock_openai_llm):
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
("backend", "llm_fixture"),
|
||||||
|
[
|
||||||
|
pytest.param("openai-like", "mock_openai_llm", id="openai-like"),
|
||||||
|
pytest.param("ollama", "mock_ollama_llm", id="ollama"),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_get_llm_passes_extra_params(request, mock_ai_config, backend, llm_fixture):
|
||||||
|
"""
|
||||||
|
GIVEN:
|
||||||
|
- Extra LLM params configured, e.g. for a provider that needs a
|
||||||
|
parameter we do not set ourselves
|
||||||
|
WHEN:
|
||||||
|
- The client builds the LLM
|
||||||
|
THEN:
|
||||||
|
- They are handed to the backend as additional_kwargs
|
||||||
|
"""
|
||||||
|
llm_mock = request.getfixturevalue(llm_fixture)
|
||||||
|
mock_ai_config.llm_backend = backend
|
||||||
|
mock_ai_config.llm_model = "gpt-5.6-luna"
|
||||||
|
mock_ai_config.llm_endpoint = "http://test-url"
|
||||||
|
mock_ai_config.llm_extra_params = {"reasoning_effort": "none"}
|
||||||
|
|
||||||
|
AIClient()
|
||||||
|
|
||||||
|
assert llm_mock.call_args.kwargs["additional_kwargs"] == {
|
||||||
|
"reasoning_effort": "none",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def test_run_llm_query_openai_timeout_raises_local_error(
|
def test_run_llm_query_openai_timeout_raises_local_error(
|
||||||
mock_ai_config,
|
mock_ai_config,
|
||||||
mock_openai_llm,
|
mock_openai_llm,
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ from paperless_ai.embedding import get_embedding_model
|
|||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
def mock_ai_config():
|
def mock_ai_config():
|
||||||
with patch("paperless_ai.embedding.AIConfig") as MockAIConfig:
|
with patch("paperless_ai.embedding.AIConfig") as MockAIConfig:
|
||||||
|
MockAIConfig.return_value.llm_embedding_api_key = None
|
||||||
MockAIConfig.return_value.llm_embedding_endpoint = None
|
MockAIConfig.return_value.llm_embedding_endpoint = None
|
||||||
MockAIConfig.return_value.llm_allow_internal_endpoints = True
|
MockAIConfig.return_value.llm_allow_internal_endpoints = True
|
||||||
MockAIConfig.return_value.llm_context_size = 8192
|
MockAIConfig.return_value.llm_context_size = 8192
|
||||||
@@ -63,6 +64,7 @@ def mock_document():
|
|||||||
def test_get_embedding_model_openai(mock_ai_config):
|
def test_get_embedding_model_openai(mock_ai_config):
|
||||||
mock_ai_config.return_value.llm_embedding_backend = LLMEmbeddingBackend.OPENAI_LIKE
|
mock_ai_config.return_value.llm_embedding_backend = LLMEmbeddingBackend.OPENAI_LIKE
|
||||||
mock_ai_config.return_value.llm_embedding_model = "text-embedding-3-small"
|
mock_ai_config.return_value.llm_embedding_model = "text-embedding-3-small"
|
||||||
|
mock_ai_config.return_value.llm_embedding_api_key = "test_embedding_api_key"
|
||||||
mock_ai_config.return_value.llm_api_key = "test_api_key"
|
mock_ai_config.return_value.llm_api_key = "test_api_key"
|
||||||
mock_ai_config.return_value.llm_endpoint = "http://test-url"
|
mock_ai_config.return_value.llm_endpoint = "http://test-url"
|
||||||
|
|
||||||
@@ -72,7 +74,7 @@ def test_get_embedding_model_openai(mock_ai_config):
|
|||||||
model = get_embedding_model(mock_ai_config.return_value)
|
model = get_embedding_model(mock_ai_config.return_value)
|
||||||
MockOpenAIEmbedding.assert_called_once_with(
|
MockOpenAIEmbedding.assert_called_once_with(
|
||||||
model_name="text-embedding-3-small",
|
model_name="text-embedding-3-small",
|
||||||
api_key="test_api_key",
|
api_key="test_embedding_api_key",
|
||||||
api_base="http://test-url",
|
api_base="http://test-url",
|
||||||
timeout=120,
|
timeout=120,
|
||||||
http_client=ANY,
|
http_client=ANY,
|
||||||
@@ -81,6 +83,20 @@ def test_get_embedding_model_openai(mock_ai_config):
|
|||||||
assert model == MockOpenAIEmbedding.return_value
|
assert model == MockOpenAIEmbedding.return_value
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_embedding_model_openai_falls_back_to_llm_api_key(mock_ai_config):
|
||||||
|
mock_ai_config.return_value.llm_embedding_backend = LLMEmbeddingBackend.OPENAI_LIKE
|
||||||
|
mock_ai_config.return_value.llm_embedding_model = "text-embedding-3-small"
|
||||||
|
mock_ai_config.return_value.llm_api_key = "test_api_key"
|
||||||
|
mock_ai_config.return_value.llm_endpoint = "http://test-url"
|
||||||
|
|
||||||
|
with patch(
|
||||||
|
"llama_index.embeddings.openai_like.OpenAILikeEmbedding",
|
||||||
|
) as MockOpenAIEmbedding:
|
||||||
|
get_embedding_model(mock_ai_config.return_value)
|
||||||
|
|
||||||
|
assert MockOpenAIEmbedding.call_args.kwargs["api_key"] == "test_api_key"
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize("configured_key", [None, ""])
|
@pytest.mark.parametrize("configured_key", [None, ""])
|
||||||
def test_get_embedding_model_openai_without_api_key_sends_placeholder(
|
def test_get_embedding_model_openai_without_api_key_sends_placeholder(
|
||||||
mock_ai_config,
|
mock_ai_config,
|
||||||
@@ -89,6 +105,7 @@ def test_get_embedding_model_openai_without_api_key_sends_placeholder(
|
|||||||
"""Same required key handling as the LLM client, see #13831."""
|
"""Same required key handling as the LLM client, see #13831."""
|
||||||
mock_ai_config.return_value.llm_embedding_backend = LLMEmbeddingBackend.OPENAI_LIKE
|
mock_ai_config.return_value.llm_embedding_backend = LLMEmbeddingBackend.OPENAI_LIKE
|
||||||
mock_ai_config.return_value.llm_embedding_model = "text-embedding-3-small"
|
mock_ai_config.return_value.llm_embedding_model = "text-embedding-3-small"
|
||||||
|
mock_ai_config.return_value.llm_embedding_api_key = configured_key
|
||||||
mock_ai_config.return_value.llm_api_key = configured_key
|
mock_ai_config.return_value.llm_api_key = configured_key
|
||||||
mock_ai_config.return_value.llm_endpoint = "http://test-url"
|
mock_ai_config.return_value.llm_endpoint = "http://test-url"
|
||||||
|
|
||||||
|
|||||||
@@ -2,14 +2,14 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import factory
|
import factory
|
||||||
from django.utils import timezone
|
from django.utils import timezone
|
||||||
from factory.django import DjangoModelFactory
|
|
||||||
|
|
||||||
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_mail.models import ProcessedMail
|
from paperless_mail.models import ProcessedMail
|
||||||
|
from paperless_testing.typed_factory import TypedModelFactory
|
||||||
|
|
||||||
|
|
||||||
class MailAccountFactory(DjangoModelFactory[MailAccount]):
|
class MailAccountFactory(TypedModelFactory[MailAccount]):
|
||||||
class Meta:
|
class Meta:
|
||||||
model = MailAccount
|
model = MailAccount
|
||||||
|
|
||||||
@@ -24,7 +24,7 @@ class MailAccountFactory(DjangoModelFactory[MailAccount]):
|
|||||||
is_token = False
|
is_token = False
|
||||||
|
|
||||||
|
|
||||||
class MailRuleFactory(DjangoModelFactory[MailRule]):
|
class MailRuleFactory(TypedModelFactory[MailRule]):
|
||||||
class Meta:
|
class Meta:
|
||||||
model = MailRule
|
model = MailRule
|
||||||
|
|
||||||
@@ -44,7 +44,7 @@ class MailRuleFactory(DjangoModelFactory[MailRule]):
|
|||||||
stop_processing = False
|
stop_processing = False
|
||||||
|
|
||||||
|
|
||||||
class ProcessedMailFactory(DjangoModelFactory[ProcessedMail]):
|
class ProcessedMailFactory(TypedModelFactory[ProcessedMail]):
|
||||||
class Meta:
|
class Meta:
|
||||||
model = ProcessedMail
|
model = ProcessedMail
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,262 @@
|
|||||||
|
import dataclasses
|
||||||
|
import email.message
|
||||||
|
import uuid
|
||||||
|
from contextlib import AbstractContextManager
|
||||||
|
|
||||||
|
from imap_tools import MailboxFolderSelectError
|
||||||
|
from imap_tools import MailboxLoginError
|
||||||
|
from imap_tools import MailMessage
|
||||||
|
from imap_tools import MailMessageFlags
|
||||||
|
|
||||||
|
|
||||||
|
@dataclasses.dataclass
|
||||||
|
class _AttachmentDef:
|
||||||
|
filename: str = "a_file.pdf"
|
||||||
|
maintype: str = "application/pdf"
|
||||||
|
subtype: str = "pdf"
|
||||||
|
disposition: str = "attachment"
|
||||||
|
content: bytes = b"a PDF document"
|
||||||
|
|
||||||
|
|
||||||
|
class BogusFolderManager:
|
||||||
|
current_folder = "INBOX"
|
||||||
|
uidvalidity = "1"
|
||||||
|
|
||||||
|
def set(self, new_folder) -> None:
|
||||||
|
if new_folder not in ["INBOX", "spam"]:
|
||||||
|
raise MailboxFolderSelectError(None, "uhm")
|
||||||
|
self.current_folder = new_folder
|
||||||
|
|
||||||
|
def status(self, folder, options):
|
||||||
|
return {"UIDVALIDITY": self.uidvalidity}
|
||||||
|
|
||||||
|
|
||||||
|
class BogusClient:
|
||||||
|
def __init__(self, messages) -> None:
|
||||||
|
self.messages: list[MailMessage] = messages
|
||||||
|
self.capabilities: list[str] = []
|
||||||
|
|
||||||
|
def __enter__(self):
|
||||||
|
return self
|
||||||
|
|
||||||
|
def __exit__(self, exc_type, exc_val, exc_tb) -> None:
|
||||||
|
pass
|
||||||
|
|
||||||
|
def authenticate(self, mechanism, authobject) -> None:
|
||||||
|
# authobject must be a callable object
|
||||||
|
auth_bytes = authobject(None)
|
||||||
|
if auth_bytes != b"\x00admin\x00w57\xc3\xa4\xc3\xb6\xc3\xbcw4b6huwb6nhu":
|
||||||
|
raise MailboxLoginError("BAD", "OK")
|
||||||
|
|
||||||
|
def uid(self, command, *args) -> None:
|
||||||
|
if command == "STORE":
|
||||||
|
for message in self.messages:
|
||||||
|
if message.uid == args[0]:
|
||||||
|
flag = args[2]
|
||||||
|
if flag == "processed":
|
||||||
|
message._raw_flag_data.append(b"+FLAGS (processed)")
|
||||||
|
if hasattr(message, "flags"):
|
||||||
|
del message.flags
|
||||||
|
|
||||||
|
|
||||||
|
class BogusMailBox(AbstractContextManager):
|
||||||
|
# Common values so tests don't need to remember an accepted login
|
||||||
|
USERNAME: str = "admin"
|
||||||
|
ASCII_PASSWORD: str = "secret"
|
||||||
|
# Note the non-ascii characters here
|
||||||
|
UTF_PASSWORD: str = "w57äöüw4b6huwb6nhu"
|
||||||
|
# A dummy access token
|
||||||
|
ACCESS_TOKEN = "ea7e075cd3acf2c54c48e600398d5d5a"
|
||||||
|
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self.messages: list[MailMessage] = []
|
||||||
|
self.messages_spam: list[MailMessage] = []
|
||||||
|
self.folder = BogusFolderManager()
|
||||||
|
self.client = BogusClient(self.messages)
|
||||||
|
self._host = ""
|
||||||
|
|
||||||
|
def __enter__(self):
|
||||||
|
return self
|
||||||
|
|
||||||
|
def __exit__(self, exc_type, exc_val, exc_tb) -> None:
|
||||||
|
pass
|
||||||
|
|
||||||
|
def updateClient(self) -> None:
|
||||||
|
self.client = BogusClient(self.messages)
|
||||||
|
|
||||||
|
def login(self, username, password) -> None:
|
||||||
|
# This will raise a UnicodeEncodeError if the password is not ASCII only
|
||||||
|
password.encode("ascii")
|
||||||
|
# Otherwise, check for correct values
|
||||||
|
if username != self.USERNAME or password != self.ASCII_PASSWORD:
|
||||||
|
raise MailboxLoginError("BAD", "OK")
|
||||||
|
|
||||||
|
def login_utf8(self, username, password) -> None:
|
||||||
|
# Expected to only be called with the UTF-8 password
|
||||||
|
if username != self.USERNAME or password != self.UTF_PASSWORD:
|
||||||
|
raise MailboxLoginError("BAD", "OK")
|
||||||
|
|
||||||
|
def xoauth2(self, username: str, access_token: str) -> None:
|
||||||
|
if username != self.USERNAME or access_token != self.ACCESS_TOKEN:
|
||||||
|
raise MailboxLoginError("BAD", "OK")
|
||||||
|
|
||||||
|
def fetch(
|
||||||
|
self,
|
||||||
|
criteria="ALL",
|
||||||
|
charset="",
|
||||||
|
*,
|
||||||
|
mark_seen=True,
|
||||||
|
bulk=True,
|
||||||
|
uid_list=None,
|
||||||
|
):
|
||||||
|
if uid_list is not None:
|
||||||
|
return [m for m in self.messages if m.uid in uid_list]
|
||||||
|
return self._filter_messages(criteria)
|
||||||
|
|
||||||
|
def uids(self, criteria, charset="") -> list[str]:
|
||||||
|
return [m.uid for m in self._filter_messages(criteria)]
|
||||||
|
|
||||||
|
def _filter_messages(self, criteria):
|
||||||
|
msg = self.messages
|
||||||
|
|
||||||
|
criteria = str(criteria).strip("()").split(" ")
|
||||||
|
|
||||||
|
if "UNSEEN" in criteria:
|
||||||
|
msg = filter(lambda m: not m.seen, msg)
|
||||||
|
|
||||||
|
if "SUBJECT" in criteria:
|
||||||
|
subject = criteria[criteria.index("SUBJECT") + 1].strip('"')
|
||||||
|
msg = filter(lambda m: subject in m.subject, msg)
|
||||||
|
|
||||||
|
if "BODY" in criteria:
|
||||||
|
body = criteria[criteria.index("BODY") + 1].strip('"')
|
||||||
|
msg = filter(lambda m: body in m.text, msg)
|
||||||
|
|
||||||
|
if "FROM" in criteria:
|
||||||
|
from_ = criteria[criteria.index("FROM") + 1].strip('"')
|
||||||
|
msg = filter(lambda m: from_ in m.from_, msg)
|
||||||
|
|
||||||
|
if "TO" in criteria:
|
||||||
|
to_ = criteria[criteria.index("TO") + 1].strip('"')
|
||||||
|
msg = filter(lambda m: any(to_ in to_addr for to_addr in m.to), msg)
|
||||||
|
|
||||||
|
if "UNFLAGGED" in criteria:
|
||||||
|
msg = filter(lambda m: not m.flagged, msg)
|
||||||
|
|
||||||
|
if "UNKEYWORD" in criteria:
|
||||||
|
tag = criteria[criteria.index("UNKEYWORD") + 1].strip("'")
|
||||||
|
msg = filter(lambda m: tag not in m.flags, msg)
|
||||||
|
|
||||||
|
if "(X-GM-LABELS" in criteria: # ['NOT', '(X-GM-LABELS', '"processed"']
|
||||||
|
msg = filter(lambda m: "processed" not in m.flags, msg)
|
||||||
|
|
||||||
|
if "UID" in criteria:
|
||||||
|
uid_list = criteria[criteria.index("UID") + 1].split(",")
|
||||||
|
msg = filter(lambda m: m.uid in uid_list, msg)
|
||||||
|
|
||||||
|
return list(msg)
|
||||||
|
|
||||||
|
def delete(self, uid_list) -> None:
|
||||||
|
self.messages = list(filter(lambda m: m.uid not in uid_list, self.messages))
|
||||||
|
|
||||||
|
def flag(self, uid_list, flag_set, value) -> None:
|
||||||
|
for message in self.messages:
|
||||||
|
if message.uid in uid_list:
|
||||||
|
for flag in flag_set:
|
||||||
|
if flag == MailMessageFlags.FLAGGED:
|
||||||
|
message.flagged = value
|
||||||
|
if flag == MailMessageFlags.SEEN:
|
||||||
|
message.seen = value
|
||||||
|
if flag == "processed":
|
||||||
|
message._raw_flag_data.append(b"+FLAGS (processed)")
|
||||||
|
if hasattr(message, "flags"):
|
||||||
|
del message.flags
|
||||||
|
|
||||||
|
def move(self, uid_list, folder) -> None:
|
||||||
|
if folder == "spam":
|
||||||
|
self.messages_spam += list(
|
||||||
|
filter(lambda m: m.uid in uid_list, self.messages),
|
||||||
|
)
|
||||||
|
self.messages = list(filter(lambda m: m.uid not in uid_list, self.messages))
|
||||||
|
else:
|
||||||
|
raise Exception
|
||||||
|
|
||||||
|
|
||||||
|
def fake_magic_from_buffer(buffer, *, mime=False):
|
||||||
|
if mime:
|
||||||
|
if "PDF" in str(buffer):
|
||||||
|
return "application/pdf"
|
||||||
|
else:
|
||||||
|
return "unknown/type"
|
||||||
|
else:
|
||||||
|
return "Some verbose file description"
|
||||||
|
|
||||||
|
|
||||||
|
class MessageBuilder:
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self._next_uid = 1
|
||||||
|
|
||||||
|
def create_message(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
attachments: int | list[_AttachmentDef] = 1,
|
||||||
|
body: str = "",
|
||||||
|
subject: str = "the subject",
|
||||||
|
from_: str = "no_one@mail.com",
|
||||||
|
to: list[str] | None = None,
|
||||||
|
seen: bool = False,
|
||||||
|
flagged: bool = False,
|
||||||
|
processed: bool = False,
|
||||||
|
) -> MailMessage:
|
||||||
|
if to is None:
|
||||||
|
to = ["tosomeone@somewhere.com"]
|
||||||
|
|
||||||
|
email_msg = email.message.EmailMessage()
|
||||||
|
# TODO: This does NOT set the UID
|
||||||
|
email_msg["Message-ID"] = str(uuid.uuid4())
|
||||||
|
email_msg["Subject"] = subject
|
||||||
|
email_msg["From"] = from_
|
||||||
|
email_msg["To"] = str(" ,".join(to))
|
||||||
|
email_msg.set_content(body)
|
||||||
|
|
||||||
|
# Either add some default number of attachments
|
||||||
|
# or the provided attachments
|
||||||
|
if isinstance(attachments, int):
|
||||||
|
for i in range(attachments):
|
||||||
|
attachment = _AttachmentDef(filename=f"file_{i}.pdf")
|
||||||
|
email_msg.add_attachment(
|
||||||
|
attachment.content,
|
||||||
|
maintype=attachment.maintype,
|
||||||
|
subtype=attachment.subtype,
|
||||||
|
disposition=attachment.disposition,
|
||||||
|
filename=attachment.filename,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
for attachment in attachments:
|
||||||
|
email_msg.add_attachment(
|
||||||
|
attachment.content,
|
||||||
|
maintype=attachment.maintype,
|
||||||
|
subtype=attachment.subtype,
|
||||||
|
disposition=attachment.disposition,
|
||||||
|
filename=attachment.filename,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Convert the EmailMessage to an imap_tools MailMessage
|
||||||
|
imap_msg = MailMessage.from_bytes(email_msg.as_bytes())
|
||||||
|
|
||||||
|
# TODO: Unsure how to add a uid to the actual EmailMessage. This hacks it in,
|
||||||
|
# based on how imap_tools uses regex to extract it.
|
||||||
|
# This should be a large enough pool
|
||||||
|
uid = self._next_uid
|
||||||
|
self._next_uid += 1
|
||||||
|
|
||||||
|
imap_msg._raw_uid_data = f"UID {uid}".encode()
|
||||||
|
|
||||||
|
imap_msg.seen = seen
|
||||||
|
imap_msg.flagged = flagged
|
||||||
|
if processed:
|
||||||
|
imap_msg._raw_flag_data.append(b"+FLAGS (processed)")
|
||||||
|
if hasattr(imap_msg, "flags"):
|
||||||
|
del imap_msg.flags
|
||||||
|
|
||||||
|
return imap_msg
|
||||||
@@ -11,7 +11,7 @@ from paperless_mail.models import ProcessedMail
|
|||||||
from paperless_mail.tests.factories import MailAccountFactory
|
from paperless_mail.tests.factories import MailAccountFactory
|
||||||
from paperless_mail.tests.factories import MailRuleFactory
|
from paperless_mail.tests.factories import MailRuleFactory
|
||||||
from paperless_mail.tests.factories import ProcessedMailFactory
|
from paperless_mail.tests.factories import ProcessedMailFactory
|
||||||
from paperless_mail.tests.test_mail import BogusMailBox
|
from paperless_mail.tests.helpers import BogusMailBox
|
||||||
from paperless_testing.dirs import DirectoriesMixin
|
from paperless_testing.dirs import DirectoriesMixin
|
||||||
from paperless_testing.factories import CorrespondentFactory
|
from paperless_testing.factories import CorrespondentFactory
|
||||||
from paperless_testing.factories import DocumentTypeFactory
|
from paperless_testing.factories import DocumentTypeFactory
|
||||||
|
|||||||
@@ -1,9 +1,7 @@
|
|||||||
import dataclasses
|
import dataclasses
|
||||||
import email.contentmanager
|
|
||||||
import time
|
import time
|
||||||
import uuid
|
import uuid
|
||||||
from collections import namedtuple
|
from collections import namedtuple
|
||||||
from contextlib import AbstractContextManager
|
|
||||||
from datetime import timedelta
|
from datetime import timedelta
|
||||||
from unittest import mock
|
from unittest import mock
|
||||||
|
|
||||||
@@ -18,16 +16,12 @@ from imap_tools import NOT
|
|||||||
from imap_tools import EmailAddress
|
from imap_tools import EmailAddress
|
||||||
from imap_tools import FolderInfo
|
from imap_tools import FolderInfo
|
||||||
from imap_tools import MailboxFolderSelectError
|
from imap_tools import MailboxFolderSelectError
|
||||||
from imap_tools import MailboxLoginError
|
|
||||||
from imap_tools import MailMessage
|
|
||||||
from imap_tools import MailMessageFlags
|
|
||||||
from imap_tools import errors
|
from imap_tools import errors
|
||||||
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 MatchingModel
|
from documents.models import MatchingModel
|
||||||
from documents.tests.utils import FileSystemAssertsMixin
|
|
||||||
from paperless_mail import tasks
|
from paperless_mail import tasks
|
||||||
from paperless_mail.mail import MailAccountHandler
|
from paperless_mail.mail import MailAccountHandler
|
||||||
from paperless_mail.mail import MailError
|
from paperless_mail.mail import MailError
|
||||||
@@ -40,265 +34,17 @@ from paperless_mail.models import MailRule
|
|||||||
from paperless_mail.models import ProcessedMail
|
from paperless_mail.models import ProcessedMail
|
||||||
from paperless_mail.tests.factories import MailAccountFactory
|
from paperless_mail.tests.factories import MailAccountFactory
|
||||||
from paperless_mail.tests.factories import MailRuleFactory
|
from paperless_mail.tests.factories import MailRuleFactory
|
||||||
|
from paperless_mail.tests.helpers import BogusMailBox
|
||||||
|
from paperless_mail.tests.helpers import MessageBuilder
|
||||||
|
from paperless_mail.tests.helpers import _AttachmentDef
|
||||||
|
from paperless_mail.tests.helpers import fake_magic_from_buffer
|
||||||
|
from paperless_testing.assertions import FileSystemAssertsMixin
|
||||||
from paperless_testing.dirs import DirectoriesMixin
|
from paperless_testing.dirs import DirectoriesMixin
|
||||||
from paperless_testing.factories import CorrespondentFactory
|
from paperless_testing.factories import CorrespondentFactory
|
||||||
from paperless_testing.factories import UserFactory
|
from paperless_testing.factories import UserFactory
|
||||||
from paperless_testing.permissions import grant_global
|
from paperless_testing.permissions import grant_global
|
||||||
|
|
||||||
|
|
||||||
@dataclasses.dataclass
|
|
||||||
class _AttachmentDef:
|
|
||||||
filename: str = "a_file.pdf"
|
|
||||||
maintype: str = "application/pdf"
|
|
||||||
subtype: str = "pdf"
|
|
||||||
disposition: str = "attachment"
|
|
||||||
content: bytes = b"a PDF document"
|
|
||||||
|
|
||||||
|
|
||||||
class BogusFolderManager:
|
|
||||||
current_folder = "INBOX"
|
|
||||||
uidvalidity = "1"
|
|
||||||
|
|
||||||
def set(self, new_folder) -> None:
|
|
||||||
if new_folder not in ["INBOX", "spam"]:
|
|
||||||
raise MailboxFolderSelectError(None, "uhm")
|
|
||||||
self.current_folder = new_folder
|
|
||||||
|
|
||||||
def status(self, folder, options):
|
|
||||||
return {"UIDVALIDITY": self.uidvalidity}
|
|
||||||
|
|
||||||
|
|
||||||
class BogusClient:
|
|
||||||
def __init__(self, messages) -> None:
|
|
||||||
self.messages: list[MailMessage] = messages
|
|
||||||
self.capabilities: list[str] = []
|
|
||||||
|
|
||||||
def __enter__(self):
|
|
||||||
return self
|
|
||||||
|
|
||||||
def __exit__(self, exc_type, exc_val, exc_tb) -> None:
|
|
||||||
pass
|
|
||||||
|
|
||||||
def authenticate(self, mechanism, authobject) -> None:
|
|
||||||
# authobject must be a callable object
|
|
||||||
auth_bytes = authobject(None)
|
|
||||||
if auth_bytes != b"\x00admin\x00w57\xc3\xa4\xc3\xb6\xc3\xbcw4b6huwb6nhu":
|
|
||||||
raise MailboxLoginError("BAD", "OK")
|
|
||||||
|
|
||||||
def uid(self, command, *args) -> None:
|
|
||||||
if command == "STORE":
|
|
||||||
for message in self.messages:
|
|
||||||
if message.uid == args[0]:
|
|
||||||
flag = args[2]
|
|
||||||
if flag == "processed":
|
|
||||||
message._raw_flag_data.append(b"+FLAGS (processed)")
|
|
||||||
if hasattr(message, "flags"):
|
|
||||||
del message.flags
|
|
||||||
|
|
||||||
|
|
||||||
class BogusMailBox(AbstractContextManager):
|
|
||||||
# Common values so tests don't need to remember an accepted login
|
|
||||||
USERNAME: str = "admin"
|
|
||||||
ASCII_PASSWORD: str = "secret"
|
|
||||||
# Note the non-ascii characters here
|
|
||||||
UTF_PASSWORD: str = "w57äöüw4b6huwb6nhu"
|
|
||||||
# A dummy access token
|
|
||||||
ACCESS_TOKEN = "ea7e075cd3acf2c54c48e600398d5d5a"
|
|
||||||
|
|
||||||
def __init__(self) -> None:
|
|
||||||
self.messages: list[MailMessage] = []
|
|
||||||
self.messages_spam: list[MailMessage] = []
|
|
||||||
self.folder = BogusFolderManager()
|
|
||||||
self.client = BogusClient(self.messages)
|
|
||||||
self._host = ""
|
|
||||||
|
|
||||||
def __enter__(self):
|
|
||||||
return self
|
|
||||||
|
|
||||||
def __exit__(self, exc_type, exc_val, exc_tb) -> None:
|
|
||||||
pass
|
|
||||||
|
|
||||||
def updateClient(self) -> None:
|
|
||||||
self.client = BogusClient(self.messages)
|
|
||||||
|
|
||||||
def login(self, username, password) -> None:
|
|
||||||
# This will raise a UnicodeEncodeError if the password is not ASCII only
|
|
||||||
password.encode("ascii")
|
|
||||||
# Otherwise, check for correct values
|
|
||||||
if username != self.USERNAME or password != self.ASCII_PASSWORD:
|
|
||||||
raise MailboxLoginError("BAD", "OK")
|
|
||||||
|
|
||||||
def login_utf8(self, username, password) -> None:
|
|
||||||
# Expected to only be called with the UTF-8 password
|
|
||||||
if username != self.USERNAME or password != self.UTF_PASSWORD:
|
|
||||||
raise MailboxLoginError("BAD", "OK")
|
|
||||||
|
|
||||||
def xoauth2(self, username: str, access_token: str) -> None:
|
|
||||||
if username != self.USERNAME or access_token != self.ACCESS_TOKEN:
|
|
||||||
raise MailboxLoginError("BAD", "OK")
|
|
||||||
|
|
||||||
def fetch(
|
|
||||||
self,
|
|
||||||
criteria="ALL",
|
|
||||||
charset="",
|
|
||||||
*,
|
|
||||||
mark_seen=True,
|
|
||||||
bulk=True,
|
|
||||||
uid_list=None,
|
|
||||||
):
|
|
||||||
if uid_list is not None:
|
|
||||||
return [m for m in self.messages if m.uid in uid_list]
|
|
||||||
return self._filter_messages(criteria)
|
|
||||||
|
|
||||||
def uids(self, criteria, charset="") -> list[str]:
|
|
||||||
return [m.uid for m in self._filter_messages(criteria)]
|
|
||||||
|
|
||||||
def _filter_messages(self, criteria):
|
|
||||||
msg = self.messages
|
|
||||||
|
|
||||||
criteria = str(criteria).strip("()").split(" ")
|
|
||||||
|
|
||||||
if "UNSEEN" in criteria:
|
|
||||||
msg = filter(lambda m: not m.seen, msg)
|
|
||||||
|
|
||||||
if "SUBJECT" in criteria:
|
|
||||||
subject = criteria[criteria.index("SUBJECT") + 1].strip('"')
|
|
||||||
msg = filter(lambda m: subject in m.subject, msg)
|
|
||||||
|
|
||||||
if "BODY" in criteria:
|
|
||||||
body = criteria[criteria.index("BODY") + 1].strip('"')
|
|
||||||
msg = filter(lambda m: body in m.text, msg)
|
|
||||||
|
|
||||||
if "FROM" in criteria:
|
|
||||||
from_ = criteria[criteria.index("FROM") + 1].strip('"')
|
|
||||||
msg = filter(lambda m: from_ in m.from_, msg)
|
|
||||||
|
|
||||||
if "TO" in criteria:
|
|
||||||
to_ = criteria[criteria.index("TO") + 1].strip('"')
|
|
||||||
msg = filter(lambda m: any(to_ in to_addr for to_addr in m.to), msg)
|
|
||||||
|
|
||||||
if "UNFLAGGED" in criteria:
|
|
||||||
msg = filter(lambda m: not m.flagged, msg)
|
|
||||||
|
|
||||||
if "UNKEYWORD" in criteria:
|
|
||||||
tag = criteria[criteria.index("UNKEYWORD") + 1].strip("'")
|
|
||||||
msg = filter(lambda m: tag not in m.flags, msg)
|
|
||||||
|
|
||||||
if "(X-GM-LABELS" in criteria: # ['NOT', '(X-GM-LABELS', '"processed"']
|
|
||||||
msg = filter(lambda m: "processed" not in m.flags, msg)
|
|
||||||
|
|
||||||
if "UID" in criteria:
|
|
||||||
uid_list = criteria[criteria.index("UID") + 1].split(",")
|
|
||||||
msg = filter(lambda m: m.uid in uid_list, msg)
|
|
||||||
|
|
||||||
return list(msg)
|
|
||||||
|
|
||||||
def delete(self, uid_list) -> None:
|
|
||||||
self.messages = list(filter(lambda m: m.uid not in uid_list, self.messages))
|
|
||||||
|
|
||||||
def flag(self, uid_list, flag_set, value) -> None:
|
|
||||||
for message in self.messages:
|
|
||||||
if message.uid in uid_list:
|
|
||||||
for flag in flag_set:
|
|
||||||
if flag == MailMessageFlags.FLAGGED:
|
|
||||||
message.flagged = value
|
|
||||||
if flag == MailMessageFlags.SEEN:
|
|
||||||
message.seen = value
|
|
||||||
if flag == "processed":
|
|
||||||
message._raw_flag_data.append(b"+FLAGS (processed)")
|
|
||||||
if hasattr(message, "flags"):
|
|
||||||
del message.flags
|
|
||||||
|
|
||||||
def move(self, uid_list, folder) -> None:
|
|
||||||
if folder == "spam":
|
|
||||||
self.messages_spam += list(
|
|
||||||
filter(lambda m: m.uid in uid_list, self.messages),
|
|
||||||
)
|
|
||||||
self.messages = list(filter(lambda m: m.uid not in uid_list, self.messages))
|
|
||||||
else:
|
|
||||||
raise Exception
|
|
||||||
|
|
||||||
|
|
||||||
def fake_magic_from_buffer(buffer, *, mime=False):
|
|
||||||
if mime:
|
|
||||||
if "PDF" in str(buffer):
|
|
||||||
return "application/pdf"
|
|
||||||
else:
|
|
||||||
return "unknown/type"
|
|
||||||
else:
|
|
||||||
return "Some verbose file description"
|
|
||||||
|
|
||||||
|
|
||||||
class MessageBuilder:
|
|
||||||
def __init__(self) -> None:
|
|
||||||
self._next_uid = 1
|
|
||||||
|
|
||||||
def create_message(
|
|
||||||
self,
|
|
||||||
*,
|
|
||||||
attachments: int | list[_AttachmentDef] = 1,
|
|
||||||
body: str = "",
|
|
||||||
subject: str = "the subject",
|
|
||||||
from_: str = "no_one@mail.com",
|
|
||||||
to: list[str] | None = None,
|
|
||||||
seen: bool = False,
|
|
||||||
flagged: bool = False,
|
|
||||||
processed: bool = False,
|
|
||||||
) -> MailMessage:
|
|
||||||
if to is None:
|
|
||||||
to = ["tosomeone@somewhere.com"]
|
|
||||||
|
|
||||||
email_msg = email.message.EmailMessage()
|
|
||||||
# TODO: This does NOT set the UID
|
|
||||||
email_msg["Message-ID"] = str(uuid.uuid4())
|
|
||||||
email_msg["Subject"] = subject
|
|
||||||
email_msg["From"] = from_
|
|
||||||
email_msg["To"] = str(" ,".join(to))
|
|
||||||
email_msg.set_content(body)
|
|
||||||
|
|
||||||
# Either add some default number of attachments
|
|
||||||
# or the provided attachments
|
|
||||||
if isinstance(attachments, int):
|
|
||||||
for i in range(attachments):
|
|
||||||
attachment = _AttachmentDef(filename=f"file_{i}.pdf")
|
|
||||||
email_msg.add_attachment(
|
|
||||||
attachment.content,
|
|
||||||
maintype=attachment.maintype,
|
|
||||||
subtype=attachment.subtype,
|
|
||||||
disposition=attachment.disposition,
|
|
||||||
filename=attachment.filename,
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
for attachment in attachments:
|
|
||||||
email_msg.add_attachment(
|
|
||||||
attachment.content,
|
|
||||||
maintype=attachment.maintype,
|
|
||||||
subtype=attachment.subtype,
|
|
||||||
disposition=attachment.disposition,
|
|
||||||
filename=attachment.filename,
|
|
||||||
)
|
|
||||||
|
|
||||||
# Convert the EmailMessage to an imap_tools MailMessage
|
|
||||||
imap_msg = MailMessage.from_bytes(email_msg.as_bytes())
|
|
||||||
|
|
||||||
# TODO: Unsure how to add a uid to the actual EmailMessage. This hacks it in,
|
|
||||||
# based on how imap_tools uses regex to extract it.
|
|
||||||
# This should be a large enough pool
|
|
||||||
uid = self._next_uid
|
|
||||||
self._next_uid += 1
|
|
||||||
|
|
||||||
imap_msg._raw_uid_data = f"UID {uid}".encode()
|
|
||||||
|
|
||||||
imap_msg.seen = seen
|
|
||||||
imap_msg.flagged = flagged
|
|
||||||
if processed:
|
|
||||||
imap_msg._raw_flag_data.append(b"+FLAGS (processed)")
|
|
||||||
if hasattr(imap_msg, "flags"):
|
|
||||||
del imap_msg.flags
|
|
||||||
|
|
||||||
return imap_msg
|
|
||||||
|
|
||||||
|
|
||||||
def reset_bogus_mailbox(
|
def reset_bogus_mailbox(
|
||||||
bogus_mailbox: BogusMailBox,
|
bogus_mailbox: BogusMailBox,
|
||||||
message_builder: MessageBuilder,
|
message_builder: MessageBuilder,
|
||||||
@@ -1819,7 +1565,12 @@ class TestMail(
|
|||||||
("electronic", None, "invoices@mycompany.com", None, 1),
|
("electronic", None, "invoices@mycompany.com", None, 1),
|
||||||
(None, "amazon", "me@myselfandi.com", None, 1),
|
(None, "amazon", "me@myselfandi.com", None, 1),
|
||||||
]:
|
]:
|
||||||
with self.subTest(f_body=f_body, f_from=f_from, f_subject=f_subject):
|
with self.subTest(
|
||||||
|
f_body=f_body,
|
||||||
|
f_from=f_from,
|
||||||
|
f_to=f_to,
|
||||||
|
f_subject=f_subject,
|
||||||
|
):
|
||||||
MailRule.objects.all().delete()
|
MailRule.objects.all().delete()
|
||||||
_ = MailRule.objects.create(
|
_ = MailRule.objects.create(
|
||||||
name="testrule3",
|
name="testrule3",
|
||||||
@@ -2060,7 +1811,7 @@ class TestPostConsumeAction(TestCase):
|
|||||||
|
|
||||||
with (
|
with (
|
||||||
self.assertRaises(errors.ImapToolsError),
|
self.assertRaises(errors.ImapToolsError),
|
||||||
self.assertLogs("paperless.mail", level="ERROR") as cm,
|
self.assertLogs("paperless_mail", level="ERROR") as cm,
|
||||||
):
|
):
|
||||||
apply_mail_action(
|
apply_mail_action(
|
||||||
result=[],
|
result=[],
|
||||||
@@ -2069,9 +1820,10 @@ class TestPostConsumeAction(TestCase):
|
|||||||
message_subject=self.message_subject,
|
message_subject=self.message_subject,
|
||||||
message_date=self.message_date,
|
message_date=self.message_date,
|
||||||
)
|
)
|
||||||
error_str = cm.output[0]
|
|
||||||
expected_str = "Error while processing mail action during post_consume"
|
error_str = cm.output[0]
|
||||||
self.assertIn(expected_str, error_str)
|
expected_str = "Error while processing mail action during post_consume"
|
||||||
|
self.assertIn(expected_str, error_str)
|
||||||
|
|
||||||
processed_mail = ProcessedMail.objects.get(uid=self.message_uid)
|
processed_mail = ProcessedMail.objects.get(uid=self.message_uid)
|
||||||
self.assertEqual(processed_mail.status, "FAILED")
|
self.assertEqual(processed_mail.status, "FAILED")
|
||||||
|
|||||||
@@ -15,9 +15,9 @@ import pytest
|
|||||||
|
|
||||||
from paperless_mail.models import MailRule
|
from paperless_mail.models import MailRule
|
||||||
from paperless_mail.tests.factories import MailAccountFactory
|
from paperless_mail.tests.factories import MailAccountFactory
|
||||||
from paperless_mail.tests.test_mail import MessageBuilder
|
from paperless_mail.tests.helpers import MessageBuilder
|
||||||
from paperless_mail.tests.test_mail import _AttachmentDef
|
from paperless_mail.tests.helpers import _AttachmentDef
|
||||||
from paperless_mail.tests.test_mail import fake_magic_from_buffer
|
from paperless_mail.tests.helpers import fake_magic_from_buffer
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture()
|
@pytest.fixture()
|
||||||
|
|||||||
@@ -16,8 +16,8 @@ from paperless_mail.mail import MailAccountHandler
|
|||||||
from paperless_mail.models import MailRule
|
from paperless_mail.models import MailRule
|
||||||
from paperless_mail.preprocessor import MailMessageDecryptor
|
from paperless_mail.preprocessor import MailMessageDecryptor
|
||||||
from paperless_mail.tests.factories import MailAccountFactory
|
from paperless_mail.tests.factories import MailAccountFactory
|
||||||
|
from paperless_mail.tests.helpers import _AttachmentDef
|
||||||
from paperless_mail.tests.test_mail import TestMail
|
from paperless_mail.tests.test_mail import TestMail
|
||||||
from paperless_mail.tests.test_mail import _AttachmentDef
|
|
||||||
|
|
||||||
|
|
||||||
class MessageEncryptor:
|
class MessageEncryptor:
|
||||||
|
|||||||
@@ -0,0 +1,37 @@
|
|||||||
|
"""Filesystem assertions for unittest-style tests."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from os import PathLike
|
||||||
|
|
||||||
|
|
||||||
|
class FileSystemAssertsMixin:
|
||||||
|
def assertIsFile(self, path: PathLike[str] | str) -> None:
|
||||||
|
if not Path(path).resolve().is_file():
|
||||||
|
raise AssertionError(f"File does not exist: {path}")
|
||||||
|
|
||||||
|
def assertIsNotFile(self, path: PathLike[str] | str) -> None:
|
||||||
|
if Path(path).resolve().is_file():
|
||||||
|
raise AssertionError(f"File does exist: {path}")
|
||||||
|
|
||||||
|
def assertIsDir(self, path: PathLike[str] | str) -> None:
|
||||||
|
if not Path(path).resolve().is_dir():
|
||||||
|
raise AssertionError(f"Dir does not exist: {path}")
|
||||||
|
|
||||||
|
def assertIsNotDir(self, path: PathLike[str] | str) -> None:
|
||||||
|
if Path(path).resolve().is_dir():
|
||||||
|
raise AssertionError(f"Dir does exist: {path}")
|
||||||
|
|
||||||
|
def assertFileCountInDir(self, path: PathLike[str] | str, count: int) -> None:
|
||||||
|
path = Path(path).resolve()
|
||||||
|
if not path.is_dir():
|
||||||
|
raise AssertionError(f"Path {path} is not a directory")
|
||||||
|
found = len([x for x in path.iterdir() if x.is_file()])
|
||||||
|
if found != count:
|
||||||
|
raise AssertionError(
|
||||||
|
f"Path {path} contains {found} files instead of {count} files",
|
||||||
|
)
|
||||||
@@ -37,6 +37,7 @@ class PaperlessDirs:
|
|||||||
logging_dir: Path
|
logging_dir: Path
|
||||||
model_file: Path
|
model_file: Path
|
||||||
media_lock: Path
|
media_lock: Path
|
||||||
|
share_link_bundle_dir: Path
|
||||||
|
|
||||||
|
|
||||||
class DirSettings(TypedDict):
|
class DirSettings(TypedDict):
|
||||||
@@ -54,6 +55,7 @@ class DirSettings(TypedDict):
|
|||||||
STATIC_ROOT: Path
|
STATIC_ROOT: Path
|
||||||
MODEL_FILE: Path
|
MODEL_FILE: Path
|
||||||
MEDIA_LOCK: Path
|
MEDIA_LOCK: Path
|
||||||
|
SHARE_LINK_BUNDLE_DIR: Path
|
||||||
|
|
||||||
|
|
||||||
def build_paperless_dirs(root: Path) -> PaperlessDirs:
|
def build_paperless_dirs(root: Path) -> PaperlessDirs:
|
||||||
@@ -75,6 +77,7 @@ def build_paperless_dirs(root: Path) -> PaperlessDirs:
|
|||||||
logging_dir=data_dir / "log",
|
logging_dir=data_dir / "log",
|
||||||
model_file=data_dir / "classification_model.pickle",
|
model_file=data_dir / "classification_model.pickle",
|
||||||
media_lock=media_dir / "media.lock",
|
media_lock=media_dir / "media.lock",
|
||||||
|
share_link_bundle_dir=documents_dir / "share_link_bundles",
|
||||||
)
|
)
|
||||||
|
|
||||||
for directory in (
|
for directory in (
|
||||||
@@ -109,6 +112,7 @@ def dirs_settings(dirs: PaperlessDirs) -> DirSettings:
|
|||||||
STATIC_ROOT=dirs.static_dir,
|
STATIC_ROOT=dirs.static_dir,
|
||||||
MODEL_FILE=dirs.model_file,
|
MODEL_FILE=dirs.model_file,
|
||||||
MEDIA_LOCK=dirs.media_lock,
|
MEDIA_LOCK=dirs.media_lock,
|
||||||
|
SHARE_LINK_BUNDLE_DIR=dirs.share_link_bundle_dir,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -5,8 +5,7 @@ Factory-boy factories for documents app models.
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import factory
|
import factory
|
||||||
from django.contrib.auth import get_user_model
|
from django.contrib.auth.models import User
|
||||||
from factory.django import DjangoModelFactory
|
|
||||||
|
|
||||||
from documents.models import Correspondent
|
from documents.models import Correspondent
|
||||||
from documents.models import Document
|
from documents.models import Document
|
||||||
@@ -15,11 +14,10 @@ from documents.models import MatchingModel
|
|||||||
from documents.models import PaperlessTask
|
from documents.models import PaperlessTask
|
||||||
from documents.models import StoragePath
|
from documents.models import StoragePath
|
||||||
from documents.models import Tag
|
from documents.models import Tag
|
||||||
|
from paperless_testing.typed_factory import TypedModelFactory
|
||||||
UserModelT = get_user_model()
|
|
||||||
|
|
||||||
|
|
||||||
class CorrespondentFactory(DjangoModelFactory[Correspondent]):
|
class CorrespondentFactory(TypedModelFactory[Correspondent]):
|
||||||
class Meta:
|
class Meta:
|
||||||
model = Correspondent
|
model = Correspondent
|
||||||
|
|
||||||
@@ -28,7 +26,7 @@ class CorrespondentFactory(DjangoModelFactory[Correspondent]):
|
|||||||
matching_algorithm = MatchingModel.MATCH_NONE
|
matching_algorithm = MatchingModel.MATCH_NONE
|
||||||
|
|
||||||
|
|
||||||
class DocumentTypeFactory(DjangoModelFactory[DocumentType]):
|
class DocumentTypeFactory(TypedModelFactory[DocumentType]):
|
||||||
class Meta:
|
class Meta:
|
||||||
model = DocumentType
|
model = DocumentType
|
||||||
|
|
||||||
@@ -37,7 +35,7 @@ class DocumentTypeFactory(DjangoModelFactory[DocumentType]):
|
|||||||
matching_algorithm = MatchingModel.MATCH_NONE
|
matching_algorithm = MatchingModel.MATCH_NONE
|
||||||
|
|
||||||
|
|
||||||
class TagFactory(DjangoModelFactory[Tag]):
|
class TagFactory(TypedModelFactory[Tag]):
|
||||||
class Meta:
|
class Meta:
|
||||||
model = Tag
|
model = Tag
|
||||||
|
|
||||||
@@ -47,7 +45,7 @@ class TagFactory(DjangoModelFactory[Tag]):
|
|||||||
is_inbox_tag = False
|
is_inbox_tag = False
|
||||||
|
|
||||||
|
|
||||||
class StoragePathFactory(DjangoModelFactory[StoragePath]):
|
class StoragePathFactory(TypedModelFactory[StoragePath]):
|
||||||
class Meta:
|
class Meta:
|
||||||
model = StoragePath
|
model = StoragePath
|
||||||
|
|
||||||
@@ -59,7 +57,7 @@ class StoragePathFactory(DjangoModelFactory[StoragePath]):
|
|||||||
matching_algorithm = MatchingModel.MATCH_NONE
|
matching_algorithm = MatchingModel.MATCH_NONE
|
||||||
|
|
||||||
|
|
||||||
class DocumentFactory(DjangoModelFactory[Document]):
|
class DocumentFactory(TypedModelFactory[Document]):
|
||||||
class Meta:
|
class Meta:
|
||||||
model = Document
|
model = Document
|
||||||
|
|
||||||
@@ -71,9 +69,9 @@ class DocumentFactory(DjangoModelFactory[Document]):
|
|||||||
storage_path = None
|
storage_path = None
|
||||||
|
|
||||||
|
|
||||||
class UserFactory(DjangoModelFactory[UserModelT]):
|
class UserFactory(TypedModelFactory[User]):
|
||||||
class Meta:
|
class Meta:
|
||||||
model = UserModelT
|
model = User
|
||||||
|
|
||||||
username = factory.Sequence(lambda n: f"user{n}")
|
username = factory.Sequence(lambda n: f"user{n}")
|
||||||
is_staff = False
|
is_staff = False
|
||||||
@@ -88,7 +86,7 @@ class UserFactory(DjangoModelFactory[UserModelT]):
|
|||||||
staff = factory.Trait(is_staff=True)
|
staff = factory.Trait(is_staff=True)
|
||||||
|
|
||||||
|
|
||||||
class PaperlessTaskFactory(DjangoModelFactory[PaperlessTask]):
|
class PaperlessTaskFactory(TypedModelFactory[PaperlessTask]):
|
||||||
class Meta:
|
class Meta:
|
||||||
model = PaperlessTask
|
model = PaperlessTask
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,31 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
|
from documents.plugins.helpers import ProgressManager
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from documents.plugins.helpers import WebsocketPayload
|
||||||
|
|
||||||
|
|
||||||
|
class FakeProgressManager(ProgressManager):
|
||||||
|
"""
|
||||||
|
The real ProgressManager with the channel layer cut out: send_progress still
|
||||||
|
builds the payload, so it cannot drift, and the payloads are recorded instead
|
||||||
|
of being sent to Redis.
|
||||||
|
|
||||||
|
Use it through the `fake_progress_manager` fixture, or construct it directly.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, filename: str | None = None, task_id: str | None = None) -> None:
|
||||||
|
super().__init__(filename, task_id)
|
||||||
|
self.payloads: list[WebsocketPayload] = []
|
||||||
|
|
||||||
|
def open(self) -> None:
|
||||||
|
pass
|
||||||
|
|
||||||
|
def close(self) -> None:
|
||||||
|
pass
|
||||||
|
|
||||||
|
def send(self, payload: WebsocketPayload) -> None:
|
||||||
|
self.payloads.append(payload)
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from django.http import StreamingHttpResponse
|
||||||
|
|
||||||
|
|
||||||
|
def read_streaming_response(response: StreamingHttpResponse) -> bytes:
|
||||||
|
"""Consume a StreamingHttpResponse/FileResponse and close it."""
|
||||||
|
content = b"".join(response.streaming_content)
|
||||||
|
response.close()
|
||||||
|
return content
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import TYPE_CHECKING
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from django.apps import apps
|
||||||
|
from django.db import connection
|
||||||
|
from django.db.migrations.executor import MigrationExecutor
|
||||||
|
from django.test import TransactionTestCase
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from django.apps.registry import Apps
|
||||||
|
|
||||||
|
|
||||||
|
class TestMigrations(TransactionTestCase):
|
||||||
|
@property
|
||||||
|
def app(self) -> str:
|
||||||
|
return apps.get_containing_app_config(type(self).__module__).name
|
||||||
|
|
||||||
|
migrate_from: Any = None
|
||||||
|
dependencies: list[tuple[str, str]] | None = None
|
||||||
|
migrate_to: Any = None
|
||||||
|
|
||||||
|
def setUp(self) -> None:
|
||||||
|
super().setUp()
|
||||||
|
|
||||||
|
assert self.migrate_from and self.migrate_to, (
|
||||||
|
f"TestCase '{type(self).__name__}' must define migrate_from and migrate_to properties"
|
||||||
|
)
|
||||||
|
self.migrate_from = [(self.app, self.migrate_from)]
|
||||||
|
if self.dependencies is not None:
|
||||||
|
self.migrate_from.extend(self.dependencies)
|
||||||
|
self.migrate_to = [(self.app, self.migrate_to)]
|
||||||
|
executor = MigrationExecutor(connection)
|
||||||
|
old_apps = executor.loader.project_state(self.migrate_from).apps
|
||||||
|
|
||||||
|
# Reverse to the original migration
|
||||||
|
executor.migrate(self.migrate_from)
|
||||||
|
|
||||||
|
self.setUpBeforeMigration(old_apps)
|
||||||
|
|
||||||
|
self.apps = old_apps
|
||||||
|
|
||||||
|
# Run the migration to test
|
||||||
|
executor = MigrationExecutor(connection)
|
||||||
|
executor.loader.build_graph() # reload.
|
||||||
|
executor.migrate(self.migrate_to)
|
||||||
|
|
||||||
|
self.apps = executor.loader.project_state(self.migrate_to).apps
|
||||||
|
|
||||||
|
def setUpBeforeMigration(self, apps: Apps) -> None:
|
||||||
|
pass
|
||||||
|
|
||||||
|
def tearDown(self) -> None:
|
||||||
|
"""
|
||||||
|
Ensure the database schema is restored to the latest migration after
|
||||||
|
each migration test, so subsequent tests run against HEAD.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
executor = MigrationExecutor(connection)
|
||||||
|
executor.loader.build_graph()
|
||||||
|
targets = executor.loader.graph.leaf_nodes()
|
||||||
|
executor.migrate(targets)
|
||||||
|
finally:
|
||||||
|
super().tearDown()
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import time
|
||||||
|
import warnings
|
||||||
|
from typing import TYPE_CHECKING
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from documents.parsers import ParseError
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from collections.abc import Callable
|
||||||
|
|
||||||
|
|
||||||
|
def util_call_with_backoff(
|
||||||
|
method_or_callable: Callable,
|
||||||
|
args: list | tuple,
|
||||||
|
*,
|
||||||
|
skip_on_50x_err: bool = True,
|
||||||
|
) -> tuple[bool, Any]:
|
||||||
|
"""
|
||||||
|
For whatever reason, the images started during the test pipeline like to
|
||||||
|
segfault sometimes, crash and otherwise fail randomly, when run with the
|
||||||
|
exact files that usually pass.
|
||||||
|
|
||||||
|
So, this function will retry the given method/function up to 3 times, with larger backoff
|
||||||
|
periods between each attempt, in hopes the issue resolves itself during
|
||||||
|
one attempt to parse.
|
||||||
|
|
||||||
|
This will wait the following:
|
||||||
|
- Attempt 1 - 20s following failure
|
||||||
|
- Attempt 2 - 40s following failure
|
||||||
|
- Attempt 3 - 80s following failure
|
||||||
|
|
||||||
|
"""
|
||||||
|
result = None
|
||||||
|
succeeded = False
|
||||||
|
retry_time = 20.0
|
||||||
|
retry_count = 0
|
||||||
|
status_codes = []
|
||||||
|
max_retry_count = 3
|
||||||
|
|
||||||
|
while retry_count < max_retry_count and not succeeded:
|
||||||
|
try:
|
||||||
|
result = method_or_callable(*args)
|
||||||
|
|
||||||
|
succeeded = True
|
||||||
|
except ParseError as e: # pragma: no cover
|
||||||
|
cause_exec = e.__cause__
|
||||||
|
if cause_exec is not None and isinstance(cause_exec, httpx.HTTPStatusError):
|
||||||
|
status_codes.append(cause_exec.response.status_code)
|
||||||
|
warnings.warn(
|
||||||
|
f"HTTP Exception for {cause_exec.request.url} - {cause_exec}",
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
warnings.warn(f"Unexpected error: {e}")
|
||||||
|
except Exception as e: # pragma: no cover
|
||||||
|
warnings.warn(f"Unexpected error: {e}")
|
||||||
|
|
||||||
|
retry_count = retry_count + 1
|
||||||
|
|
||||||
|
if not succeeded and retry_count < max_retry_count:
|
||||||
|
time.sleep(retry_time)
|
||||||
|
retry_time = retry_time * 2.0
|
||||||
|
|
||||||
|
if (
|
||||||
|
not succeeded
|
||||||
|
and status_codes
|
||||||
|
and skip_on_50x_err
|
||||||
|
and all(httpx.codes.is_server_error(code) for code in status_codes)
|
||||||
|
):
|
||||||
|
pytest.skip("Repeated HTTP 50x for service") # pragma: no cover
|
||||||
|
|
||||||
|
return succeeded, result
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user