mirror of
https://github.com/paperless-ngx/paperless-ngx.git
synced 2026-09-26 11:20:31 +00:00
Compare commits
19
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b3bf220f56 | ||
|
|
d4429c3cc7 | ||
|
|
4f777d7438 | ||
|
|
2a44d8b5ba | ||
|
|
abf5050ea7 | ||
|
|
091ddf7c45 | ||
|
|
c9f7f2cfbe | ||
|
|
b457610ffb | ||
|
|
969c2ea0e2 | ||
|
|
31b806a285 | ||
|
|
99851b418c | ||
|
|
e34eda07bb | ||
|
|
793459b416 | ||
|
|
04297fd02c | ||
|
|
1b277dd8e1 | ||
|
|
3c20abeb4c | ||
|
|
b11f1f8459 | ||
|
|
7424e7ce0b | ||
|
|
a53a3d3769 |
+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
|
||||||
|
|||||||
+35
-21
@@ -9745,7 +9745,7 @@
|
|||||||
</context-group>
|
</context-group>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/document-list/document-list.component.ts</context>
|
<context context-type="sourcefile">src/app/components/document-list/document-list.component.ts</context>
|
||||||
<context context-type="linenumber">348</context>
|
<context context-type="linenumber">351</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/manage/document-attributes/document-attributes.component.html</context>
|
<context context-type="sourcefile">src/app/components/manage/document-attributes/document-attributes.component.html</context>
|
||||||
@@ -9760,7 +9760,7 @@
|
|||||||
</context-group>
|
</context-group>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/document-list/document-list.component.ts</context>
|
<context context-type="sourcefile">src/app/components/document-list/document-list.component.ts</context>
|
||||||
<context context-type="linenumber">341</context>
|
<context context-type="linenumber">344</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/manage/document-attributes/document-attributes.component.html</context>
|
<context context-type="sourcefile">src/app/components/manage/document-attributes/document-attributes.component.html</context>
|
||||||
@@ -10016,56 +10016,56 @@
|
|||||||
<source>Reset filters / selection</source>
|
<source>Reset filters / selection</source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/document-list/document-list.component.ts</context>
|
<context context-type="sourcefile">src/app/components/document-list/document-list.component.ts</context>
|
||||||
<context context-type="linenumber">329</context>
|
<context context-type="linenumber">332</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="4135055128446167640" datatype="html">
|
<trans-unit id="4135055128446167640" datatype="html">
|
||||||
<source>Open first [selected] document</source>
|
<source>Open first [selected] document</source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/document-list/document-list.component.ts</context>
|
<context context-type="sourcefile">src/app/components/document-list/document-list.component.ts</context>
|
||||||
<context context-type="linenumber">357</context>
|
<context context-type="linenumber">360</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="3629960544875360046" datatype="html">
|
<trans-unit id="3629960544875360046" datatype="html">
|
||||||
<source>Previous page</source>
|
<source>Previous page</source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/document-list/document-list.component.ts</context>
|
<context context-type="sourcefile">src/app/components/document-list/document-list.component.ts</context>
|
||||||
<context context-type="linenumber">373</context>
|
<context context-type="linenumber">376</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="3337301694210287595" datatype="html">
|
<trans-unit id="3337301694210287595" datatype="html">
|
||||||
<source>Next page</source>
|
<source>Next page</source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/document-list/document-list.component.ts</context>
|
<context context-type="sourcefile">src/app/components/document-list/document-list.component.ts</context>
|
||||||
<context context-type="linenumber">385</context>
|
<context context-type="linenumber">388</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="2155249406916744630" datatype="html">
|
<trans-unit id="2155249406916744630" datatype="html">
|
||||||
<source>View "<x id="PH" equiv-text="this.list.activeSavedViewTitle"/>" saved successfully.</source>
|
<source>View "<x id="PH" equiv-text="this.list.activeSavedViewTitle"/>" saved successfully.</source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/document-list/document-list.component.ts</context>
|
<context context-type="sourcefile">src/app/components/document-list/document-list.component.ts</context>
|
||||||
<context context-type="linenumber">419</context>
|
<context context-type="linenumber">422</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="4646273665293421938" datatype="html">
|
<trans-unit id="4646273665293421938" datatype="html">
|
||||||
<source>Failed to save view "<x id="PH" equiv-text="this.list.activeSavedViewTitle"/>".</source>
|
<source>Failed to save view "<x id="PH" equiv-text="this.list.activeSavedViewTitle"/>".</source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/document-list/document-list.component.ts</context>
|
<context context-type="sourcefile">src/app/components/document-list/document-list.component.ts</context>
|
||||||
<context context-type="linenumber">425</context>
|
<context context-type="linenumber">428</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="6837554170707123455" datatype="html">
|
<trans-unit id="6837554170707123455" datatype="html">
|
||||||
<source>View "<x id="PH" equiv-text="savedView.name"/>" created successfully.</source>
|
<source>View "<x id="PH" equiv-text="savedView.name"/>" created successfully.</source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/document-list/document-list.component.ts</context>
|
<context context-type="sourcefile">src/app/components/document-list/document-list.component.ts</context>
|
||||||
<context context-type="linenumber">494</context>
|
<context context-type="linenumber">497</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="6028096992841030074" datatype="html">
|
<trans-unit id="6028096992841030074" datatype="html">
|
||||||
<source>View "<x id="PH" equiv-text="savedView.name"/>" created successfully, but could not update visibility settings.</source>
|
<source>View "<x id="PH" equiv-text="savedView.name"/>" created successfully, but could not update visibility settings.</source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/document-list/document-list.component.ts</context>
|
<context context-type="sourcefile">src/app/components/document-list/document-list.component.ts</context>
|
||||||
<context context-type="linenumber">500</context>
|
<context context-type="linenumber">503</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="739880801667335279" datatype="html">
|
<trans-unit id="739880801667335279" datatype="html">
|
||||||
@@ -12018,81 +12018,95 @@
|
|||||||
<context context-type="linenumber">351</context>
|
<context context-type="linenumber">351</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
|
<trans-unit id="861068592166833023" datatype="html">
|
||||||
|
<source>LLM Embedding API Key</source>
|
||||||
|
<context-group purpose="location">
|
||||||
|
<context context-type="sourcefile">src/app/data/paperless-config.ts</context>
|
||||||
|
<context context-type="linenumber">358</context>
|
||||||
|
</context-group>
|
||||||
|
</trans-unit>
|
||||||
|
<trans-unit id="2929108042259892948" datatype="html">
|
||||||
|
<source>Used for embeddings when set, otherwise LLM API key is used.</source>
|
||||||
|
<context-group purpose="location">
|
||||||
|
<context context-type="sourcefile">src/app/data/paperless-config.ts</context>
|
||||||
|
<context context-type="linenumber">360</context>
|
||||||
|
</context-group>
|
||||||
|
</trans-unit>
|
||||||
<trans-unit id="3554114880473286122" datatype="html">
|
<trans-unit id="3554114880473286122" datatype="html">
|
||||||
<source>LLM Embedding Endpoint</source>
|
<source>LLM Embedding Endpoint</source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/data/paperless-config.ts</context>
|
<context context-type="sourcefile">src/app/data/paperless-config.ts</context>
|
||||||
<context context-type="linenumber">358</context>
|
<context context-type="linenumber">366</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="1044242175651289991" datatype="html">
|
<trans-unit id="1044242175651289991" datatype="html">
|
||||||
<source>LLM Embedding Chunk Size</source>
|
<source>LLM Embedding Chunk Size</source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/data/paperless-config.ts</context>
|
<context context-type="sourcefile">src/app/data/paperless-config.ts</context>
|
||||||
<context context-type="linenumber">365</context>
|
<context context-type="linenumber">373</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="7218245223139363113" datatype="html">
|
<trans-unit id="7218245223139363113" datatype="html">
|
||||||
<source>LLM Context Size</source>
|
<source>LLM Context Size</source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/data/paperless-config.ts</context>
|
<context context-type="sourcefile">src/app/data/paperless-config.ts</context>
|
||||||
<context context-type="linenumber">372</context>
|
<context context-type="linenumber">380</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="4234495692726214397" datatype="html">
|
<trans-unit id="4234495692726214397" datatype="html">
|
||||||
<source>LLM Backend</source>
|
<source>LLM Backend</source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/data/paperless-config.ts</context>
|
<context context-type="sourcefile">src/app/data/paperless-config.ts</context>
|
||||||
<context context-type="linenumber">379</context>
|
<context context-type="linenumber">387</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="7935234833834000002" datatype="html">
|
<trans-unit id="7935234833834000002" datatype="html">
|
||||||
<source>LLM Model</source>
|
<source>LLM Model</source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/data/paperless-config.ts</context>
|
<context context-type="sourcefile">src/app/data/paperless-config.ts</context>
|
||||||
<context context-type="linenumber">387</context>
|
<context context-type="linenumber">395</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="1980550530387803165" datatype="html">
|
<trans-unit id="1980550530387803165" datatype="html">
|
||||||
<source>LLM API Key</source>
|
<source>LLM API Key</source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/data/paperless-config.ts</context>
|
<context context-type="sourcefile">src/app/data/paperless-config.ts</context>
|
||||||
<context context-type="linenumber">394</context>
|
<context context-type="linenumber">402</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="6126617860376156501" datatype="html">
|
<trans-unit id="6126617860376156501" datatype="html">
|
||||||
<source>LLM Endpoint</source>
|
<source>LLM Endpoint</source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/data/paperless-config.ts</context>
|
<context context-type="sourcefile">src/app/data/paperless-config.ts</context>
|
||||||
<context context-type="linenumber">401</context>
|
<context context-type="linenumber">409</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="6572826277249350975" datatype="html">
|
<trans-unit id="6572826277249350975" datatype="html">
|
||||||
<source>LLM Output Language</source>
|
<source>LLM Output Language</source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/data/paperless-config.ts</context>
|
<context context-type="sourcefile">src/app/data/paperless-config.ts</context>
|
||||||
<context context-type="linenumber">408</context>
|
<context context-type="linenumber">416</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="3284403507172415792" datatype="html">
|
<trans-unit id="3284403507172415792" datatype="html">
|
||||||
<source>Language to use for generated AI suggestions. When unset, AI suggestions use the user's display language if explicitly set.</source>
|
<source>Language to use for generated AI suggestions. When unset, AI suggestions use the user's display language if explicitly set.</source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/data/paperless-config.ts</context>
|
<context context-type="sourcefile">src/app/data/paperless-config.ts</context>
|
||||||
<context context-type="linenumber">412</context>
|
<context context-type="linenumber">420</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="4493921125434706859" datatype="html">
|
<trans-unit id="4493921125434706859" datatype="html">
|
||||||
<source>LLM Request Timeout</source>
|
<source>LLM Request Timeout</source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/data/paperless-config.ts</context>
|
<context context-type="sourcefile">src/app/data/paperless-config.ts</context>
|
||||||
<context context-type="linenumber">416</context>
|
<context context-type="linenumber">424</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="483994032066441287" datatype="html">
|
<trans-unit id="483994032066441287" datatype="html">
|
||||||
<source>Timeout in seconds for LLM requests.</source>
|
<source>Timeout in seconds for LLM requests.</source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/data/paperless-config.ts</context>
|
<context context-type="sourcefile">src/app/data/paperless-config.ts</context>
|
||||||
<context context-type="linenumber">420</context>
|
<context context-type="linenumber">428</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="1055686627716339120" datatype="html">
|
<trans-unit id="1055686627716339120" datatype="html">
|
||||||
|
|||||||
@@ -154,11 +154,28 @@
|
|||||||
& 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;
|
||||||
|
}
|
||||||
|
|
||||||
|
& :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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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}")
|
||||||
|
|
||||||
|
|||||||
@@ -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 "
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -2525,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(
|
||||||
@@ -2541,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:
|
||||||
@@ -2549,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],
|
||||||
@@ -2570,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/",
|
||||||
@@ -2599,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:
|
||||||
"""
|
"""
|
||||||
|
|||||||
@@ -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",
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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:
|
||||||
"""
|
"""
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -69,7 +69,9 @@ from paperless_mail.models import MailAccount
|
|||||||
from paperless_mail.models import MailRule
|
from paperless_mail.models import MailRule
|
||||||
from paperless_testing.assertions 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 UserFactory
|
from paperless_testing.factories import UserFactory
|
||||||
|
from paperless_testing.permissions import grant_global
|
||||||
from paperless_testing.permissions import grant_object
|
from paperless_testing.permissions import grant_object
|
||||||
|
|
||||||
|
|
||||||
@@ -1059,6 +1061,41 @@ class TestWorkflows(
|
|||||||
self.assertEqual(doc.correspondent, self.c2)
|
self.assertEqual(doc.correspondent, self.c2)
|
||||||
self.assertEqual(doc.title, f"Doc created in {created.year}")
|
self.assertEqual(doc.title, f"Doc created in {created.year}")
|
||||||
|
|
||||||
|
@pytest.mark.usefixtures("_search_index")
|
||||||
|
def test_document_added_workflow_indexes_final_title(self) -> None:
|
||||||
|
trigger = WorkflowTrigger.objects.create(
|
||||||
|
type=WorkflowTrigger.WorkflowTriggerType.DOCUMENT_ADDED,
|
||||||
|
filter_filename="*sample*",
|
||||||
|
)
|
||||||
|
action = WorkflowAction.objects.create(
|
||||||
|
assign_title="Linked document",
|
||||||
|
assign_owner=self.user2,
|
||||||
|
)
|
||||||
|
link_field = CustomField.objects.create(
|
||||||
|
name="Related documents",
|
||||||
|
data_type=CustomField.FieldDataType.DOCUMENTLINK,
|
||||||
|
)
|
||||||
|
action.assign_custom_fields.add(link_field)
|
||||||
|
workflow = Workflow.objects.create(name="Link workflow", order=0)
|
||||||
|
workflow.triggers.add(trigger)
|
||||||
|
workflow.actions.add(action)
|
||||||
|
|
||||||
|
doc = DocumentFactory.create()
|
||||||
|
document_consumption_finished.send(sender=self.__class__, document=doc)
|
||||||
|
|
||||||
|
self.assertTrue(doc.custom_fields.filter(field=link_field).exists())
|
||||||
|
doc.refresh_from_db()
|
||||||
|
self.assertEqual(doc.title, "Linked document")
|
||||||
|
|
||||||
|
grant_global(self.user2, "view_document")
|
||||||
|
self.client.force_authenticate(user=self.user2)
|
||||||
|
response = self.client.get("/api/documents/?title_search=linked")
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
self.assertEqual(
|
||||||
|
[result["id"] for result in response.data["results"]],
|
||||||
|
[doc.pk],
|
||||||
|
)
|
||||||
|
|
||||||
def test_document_added_no_match_filename(self) -> None:
|
def test_document_added_no_match_filename(self) -> None:
|
||||||
trigger = WorkflowTrigger.objects.create(
|
trigger = WorkflowTrigger.objects.create(
|
||||||
type=WorkflowTrigger.WorkflowTriggerType.DOCUMENT_ADDED,
|
type=WorkflowTrigger.WorkflowTriggerType.DOCUMENT_ADDED,
|
||||||
@@ -4422,18 +4459,18 @@ class TestWorkflows(
|
|||||||
)
|
)
|
||||||
|
|
||||||
@mock.patch("documents.bulk_edit.remove_password")
|
@mock.patch("documents.bulk_edit.remove_password")
|
||||||
def test_password_removal_action_fails_without_correct_password(
|
def test_password_removal_action_skips_blank_and_whitespace_passwords(
|
||||||
self,
|
self,
|
||||||
mock_remove_password,
|
mock_remove_password,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""
|
"""
|
||||||
GIVEN:
|
GIVEN:
|
||||||
- Workflow password removal action
|
- Workflow password removal action
|
||||||
- No correct password provided
|
- Only blank and whitespace-only passwords configured
|
||||||
WHEN:
|
WHEN:
|
||||||
- Document updated triggering the workflow
|
- Document updated triggering the workflow
|
||||||
THEN:
|
THEN:
|
||||||
- Password removal is attempted for all passwords and fails
|
- Password removal is not attempted
|
||||||
"""
|
"""
|
||||||
doc = Document.objects.create(
|
doc = Document.objects.create(
|
||||||
title="Protected",
|
title="Protected",
|
||||||
@@ -4454,6 +4491,60 @@ class TestWorkflows(
|
|||||||
|
|
||||||
mock_remove_password.assert_not_called()
|
mock_remove_password.assert_not_called()
|
||||||
|
|
||||||
|
@mock.patch("documents.bulk_edit.remove_password")
|
||||||
|
def test_password_removal_action_fails_without_correct_password(
|
||||||
|
self,
|
||||||
|
mock_remove_password,
|
||||||
|
) -> None:
|
||||||
|
"""
|
||||||
|
GIVEN:
|
||||||
|
- Workflow password removal action
|
||||||
|
- No configured password is correct
|
||||||
|
WHEN:
|
||||||
|
- Document updated triggering the workflow
|
||||||
|
THEN:
|
||||||
|
- Password removal is attempted for every configured password and fails
|
||||||
|
"""
|
||||||
|
doc = Document.objects.create(
|
||||||
|
title="Protected",
|
||||||
|
checksum="pw-checksum-3",
|
||||||
|
)
|
||||||
|
trigger = WorkflowTrigger.objects.create(
|
||||||
|
type=WorkflowTrigger.WorkflowTriggerType.DOCUMENT_UPDATED,
|
||||||
|
)
|
||||||
|
action = WorkflowAction.objects.create(
|
||||||
|
type=WorkflowAction.WorkflowActionType.PASSWORD_REMOVAL,
|
||||||
|
passwords=["wrong", "also-wrong"],
|
||||||
|
)
|
||||||
|
workflow = Workflow.objects.create(name="Password workflow wrong passwords")
|
||||||
|
workflow.triggers.add(trigger)
|
||||||
|
workflow.actions.add(action)
|
||||||
|
|
||||||
|
mock_remove_password.side_effect = ValueError("wrong password")
|
||||||
|
|
||||||
|
with self.assertLogs("paperless.workflows.actions", level="ERROR"):
|
||||||
|
run_workflows(trigger.type, doc)
|
||||||
|
|
||||||
|
assert mock_remove_password.call_count == 2
|
||||||
|
mock_remove_password.assert_has_calls(
|
||||||
|
[
|
||||||
|
mock.call(
|
||||||
|
[doc.id],
|
||||||
|
password="wrong",
|
||||||
|
update_document=True,
|
||||||
|
user=doc.owner,
|
||||||
|
source_paths_by_id=None,
|
||||||
|
),
|
||||||
|
mock.call(
|
||||||
|
[doc.id],
|
||||||
|
password="also-wrong",
|
||||||
|
update_document=True,
|
||||||
|
user=doc.owner,
|
||||||
|
source_paths_by_id=None,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
@mock.patch("documents.bulk_edit.remove_password")
|
@mock.patch("documents.bulk_edit.remove_password")
|
||||||
def test_password_removal_action_skips_without_passwords(
|
def test_password_removal_action_skips_without_passwords(
|
||||||
self,
|
self,
|
||||||
|
|||||||
+51
-34
@@ -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
|
||||||
@@ -2968,11 +2967,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 +2983,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 +3148,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 +3226,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-21 19:00+0000\n"
|
"POT-Creation-Date: 2026-09-23 19:00+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:2885 documents/views.py:343 documents/views.py:2726
|
#: documents/serialisers.py:2885 documents/views.py:342 documents/views.py:2725
|
||||||
#: paperless_mail/serialisers.py:156
|
#: paperless_mail/serialisers.py:156
|
||||||
msgid "Insufficient permissions."
|
msgid "Insufficient permissions."
|
||||||
msgstr ""
|
msgstr ""
|
||||||
@@ -1673,7 +1673,7 @@ msgstr ""
|
|||||||
msgid "Duplicate document identifiers are not allowed."
|
msgid "Duplicate document identifiers are not allowed."
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: documents/serialisers.py:2971 documents/views.py:4763
|
#: documents/serialisers.py:2971 documents/views.py:4780
|
||||||
#, 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:2722
|
||||||
msgid "Invalid more_like_id"
|
msgid "Invalid more_like_id"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: documents/views.py:1670
|
#: documents/views.py:1669
|
||||||
msgid "Invalid AI configuration."
|
msgid "Invalid AI configuration."
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: documents/views.py:1681
|
#: documents/views.py:1680
|
||||||
msgid "AI backend request timed out."
|
msgid "AI backend request timed out."
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: documents/views.py:1693
|
#: documents/views.py:1692
|
||||||
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:2547 documents/views.py:2863
|
||||||
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:4793
|
||||||
#, 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:4839
|
||||||
msgid "Bundle is already being processed."
|
msgid "Bundle is already being processed."
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: documents/views.py:4886
|
#: documents/views.py:4903
|
||||||
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:4917
|
||||||
msgid "The share link bundle is unavailable."
|
msgid "The share link bundle is unavailable."
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
@@ -2219,190 +2219,194 @@ 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 ""
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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",
|
||||||
|
)
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|
||||||
|
|||||||
@@ -1565,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",
|
||||||
@@ -1806,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=[],
|
||||||
@@ -1815,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")
|
||||||
|
|||||||
@@ -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,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -220,7 +220,7 @@ wheels = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "autobahn"
|
name = "autobahn"
|
||||||
version = "25.12.2"
|
version = "26.7.1"
|
||||||
source = { registry = "https://pypi.org/simple" }
|
source = { registry = "https://pypi.org/simple" }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "cbor2" },
|
{ name = "cbor2" },
|
||||||
@@ -228,24 +228,35 @@ dependencies = [
|
|||||||
{ name = "cryptography" },
|
{ name = "cryptography" },
|
||||||
{ name = "hyperlink" },
|
{ name = "hyperlink" },
|
||||||
{ name = "msgpack", marker = "platform_python_implementation == 'CPython'" },
|
{ name = "msgpack", marker = "platform_python_implementation == 'CPython'" },
|
||||||
{ name = "py-ubjson" },
|
|
||||||
{ name = "txaio" },
|
{ name = "txaio" },
|
||||||
{ name = "u-msgpack-python", marker = "platform_python_implementation != 'CPython'" },
|
{ name = "u-msgpack-python", marker = "platform_python_implementation != 'CPython'" },
|
||||||
{ name = "ujson" },
|
{ name = "ujson" },
|
||||||
]
|
]
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/54/d5/9adf0f5b9eb244e58e898e9f3db4b00c09835ef4b6c37d491886e0376b4f/autobahn-25.12.2.tar.gz", hash = "sha256:754c06a54753aeb7e8d10c5cbf03249ad9e2a1a32bca8be02865c6f00628a98c", size = 13893652, upload-time = "2025-12-15T11:13:19.086Z" }
|
sdist = { url = "https://files.pythonhosted.org/packages/de/73/f109f563c27e048e45d135d81af19e6ca391e24905550b06bd1c9d674c57/autobahn-26.7.1.tar.gz", hash = "sha256:c6949a2c6eb95fb1c218837dbda0a59abbbebafb8b11098551c01a7061dfd245", size = 14056542, upload-time = "2026-07-15T19:14:01.246Z" }
|
||||||
wheels = [
|
wheels = [
|
||||||
{ url = "https://files.pythonhosted.org/packages/ae/23/923e4f11dc9d12b9f5a014f36d591c479d623d54dda3bdcbd688cd12f052/autobahn-25.12.2-cp311-cp311-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:16df879672c60f1f3fe452138c80f0fd221b3cb2ee5a14390c80f33b994104c1", size = 2053413, upload-time = "2025-12-15T11:12:58.167Z" },
|
{ url = "https://files.pythonhosted.org/packages/44/8c/381cdcab8016df2177adc93d25f84ca3a5fb8f8be4f9d784336416c7bee8/autobahn-26.7.1-cp311-cp311-macosx_15_0_arm64.whl", hash = "sha256:3fe80550707f0affb5cb10f3e0f66ec7e6e52abb29edc66dd76734c2d7d51bf4", size = 1997747, upload-time = "2026-07-15T19:13:21.998Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/b3/0d/3d39637a1e32f555ce5fabec4a723a035556ef918b14140faea05e7de902/autobahn-25.12.2-cp311-cp311-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6ffe28048ef96eb0f925f24c2569bd72332e120f4cb31cd6c40dd66718a5f85e", size = 2224850, upload-time = "2025-12-15T11:13:00.089Z" },
|
{ url = "https://files.pythonhosted.org/packages/79/a9/9293c6c6bc8970f42c9675942de78f306e18eafa47edba52fd27f9dc71bd/autobahn-26.7.1-cp311-cp311-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:00fb9acd8775eaa0e272f36b76db903f10de56478f6a72f0bd07ee882ae1f2b8", size = 2082284, upload-time = "2026-07-15T19:13:23.582Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/64/8d/36452c06cbcad6d04587aeb87dfa987ef94be4a427b9f2155783d166bd97/autobahn-25.12.2-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:220748f21e91bd4a538d2d3de640cc17ee30b79f1c04a6c3dcdef321d531ee1c", size = 2225453, upload-time = "2025-12-15T11:13:02.865Z" },
|
{ url = "https://files.pythonhosted.org/packages/a9/ba/7396cb42a9c59df20c350ea05f75e6f25f582b474dee82b8e32823b2711e/autobahn-26.7.1-cp311-cp311-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:97c9674eddd55ad3ebd733824789175e5fb90c88afd523de507569ba0fcd6853", size = 2254260, upload-time = "2026-07-15T19:13:24.894Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/83/30/ef9c47038e4e9257319d6e1b87668b3df360a0c488d66ccff9d11aaff6ba/autobahn-25.12.2-cp313-cp313-macosx_15_0_arm64.whl", hash = "sha256:bc17f6cab9438156d2701c293c76fd02a144f9be0a992c065dfee1935ce4845b", size = 1960447, upload-time = "2025-12-15T11:13:05.007Z" },
|
{ url = "https://files.pythonhosted.org/packages/b7/64/19753442770662ff45c4fe48db6345ac6fa3100fbbd989241c074e38ea6f/autobahn-26.7.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:30fa714de5c9903ef64084d3a938d8a3bac0bb42f1532d5de22f34b04a1c4819", size = 3173653, upload-time = "2026-07-15T19:13:26.261Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/e2/e4/f3d5cb70bc0b9b5523d940734b2e0a251510d051a50d2e723f321e890859/autobahn-25.12.2-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b5297a782fc7d0a26842438ef1342549ceee29496cda52672ac44635c79eeb94", size = 2053955, upload-time = "2025-12-15T11:13:06.052Z" },
|
{ url = "https://files.pythonhosted.org/packages/04/a4/b690f272427acf1e8ea03b146e559dc67ade10dd4e0cccacc1d4c011b141/autobahn-26.7.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:c3362197f3b9d5b0df7f3365bd00dedba7ee8b649d652941377abe690d1c8b14", size = 3402880, upload-time = "2026-07-15T19:13:27.683Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/ea/49/4e592a19ae58fd9c796821a882b22598fac295ede50f899cc9d14a0282b6/autobahn-25.12.2-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c0c3f1d5dafda52f8dc962ab583b6f3473b7b7186cab082d05372ed43a8261a5", size = 2225441, upload-time = "2025-12-15T11:13:07.527Z" },
|
{ url = "https://files.pythonhosted.org/packages/6e/23/0769ef39e1cfb0bec15bacdd7f407aaedfda14c0ca3f7e818b856f2ed1a1/autobahn-26.7.1-cp312-cp312-macosx_15_0_arm64.whl", hash = "sha256:6c9013e9aa9ea8a561c89d7be2709546b51fc7ac8fdf6cd71bc12a634672d9a8", size = 2000605, upload-time = "2026-07-15T19:13:30.391Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/54/b7/0a0e3ecb2af7e452f5f359d19bdc647cbc8658f3f498bfa3bf8545cf4768/autobahn-25.12.2-cp314-cp314-macosx_15_0_arm64.whl", hash = "sha256:c840ee136bfaf6560467160129b0b25a0e33c9a51e2b251e98c5474f27583915", size = 1960463, upload-time = "2025-12-15T11:13:10.183Z" },
|
{ url = "https://files.pythonhosted.org/packages/f9/ef/26833f38ecf3aef3ff0aa09feb12f5d472f7370104148a6b78e3c7afc286/autobahn-26.7.1-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:abd9ebe577dd1030f9a0c41d20dc00eca90d5cf338531abcb510d978825feeea", size = 2082844, upload-time = "2026-07-15T19:13:31.559Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/19/8b/4215ac49d6b793b592fb08698f3a0e21a59eb3520be7f7ed288fcb52d919/autobahn-25.12.2-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9abda5cf817c0f8a19a55a67a031adf2fc70ed351719b5bd9e6fa0f5f4bc8f89", size = 2225590, upload-time = "2025-12-15T11:13:11.367Z" },
|
{ url = "https://files.pythonhosted.org/packages/b2/4c/00553ee9d57ee11df47bc9867d120cfe721a73bec0b58fb3a3b91cd7c797/autobahn-26.7.1-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:de491baa4cf52fb6d7f542e445c72d94ce52dc7aabb47b0b4d5191e452dd2c74", size = 2254852, upload-time = "2026-07-15T19:13:32.8Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/d6/99/b4a3da42471d3ec36e2dca0c1a5368a079fed9f73b159ce3f049c4a4983b/autobahn-25.12.2-pp311-pypy311_pp73-macosx_15_0_arm64.whl", hash = "sha256:0c226329ddec154c6f3b491ea3e4713035f0326c96ebfd6b305bf90f27a2fba1", size = 1955357, upload-time = "2025-12-15T11:13:13.581Z" },
|
{ url = "https://files.pythonhosted.org/packages/c3/ca/7884f6ffb8410882df98cb939dea24225dd79e4f091ceb59f4b826e54f2f/autobahn-26.7.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:9723561c820ed032fe5a8f1530cb6d5f91dc595eea6009f3b2de7044a3df892a", size = 3174235, upload-time = "2026-07-15T19:13:34.134Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/89/81/67f19dd7395a9f1123a1f071314f8d1c4879c1869adeb8d99a236e756ac0/autobahn-25.12.2-pp311-pypy311_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:5f079393a7626eb448c8accf21151f5f206d02f8e9cee4313d62a5ca30a3aaed", size = 623173, upload-time = "2025-12-15T11:13:14.945Z" },
|
{ url = "https://files.pythonhosted.org/packages/1f/e7/c6704e8f6bef3aa552a851a34908a06d91317d35ca55ec04b5db14385c33/autobahn-26.7.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:e81c86cf41adca8a56ca5621ecd9ba40037f6d90d338c334bd529c8ac94bd7b6", size = 3403687, upload-time = "2026-07-15T19:13:35.633Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/71/eb/857eab3d25e3b9cc9e7e741d6193808ad91de0befb38cf10658bd339c205/autobahn-25.12.2-pp311-pypy311_pp73-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9b3a6c7d54a9f0434a435d88b86555510e5d0a84aa87042e292f29f707cab237", size = 2178008, upload-time = "2025-12-15T11:13:15.881Z" },
|
{ url = "https://files.pythonhosted.org/packages/36/92/2f6e57d9f9e6b86b9db362f58aaa6cfeadc2f3a6901ec95aab27ef232b5c/autobahn-26.7.1-cp313-cp313-macosx_15_0_arm64.whl", hash = "sha256:2ce48214b28f73338fabe0c7fd13d222cfab9e1dd2ef11660293522f64e76727", size = 1987052, upload-time = "2026-07-15T19:13:38.543Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/38/6d/f170134468e276fa9ea57eb1ae41f9cc0dcd0228e9d501f370fa50c0ee31/autobahn-26.7.1-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a5f285dce9b3dff3eb2ef6c818ac8ede24d96bd1edac340855170fc9825c38a9", size = 2082813, upload-time = "2026-07-15T19:13:39.686Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/0d/ce/b735fa933e9ba4fa8c3f9aa9ae68b4e2d4aadb0a92b38ade30e37a7d4795/autobahn-26.7.1-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:66ab6e034e54f8c473df1a6b8031a3db46c16deebaf0c9b66db3e9137d2fab5e", size = 2254818, upload-time = "2026-07-15T19:13:40.951Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/df/3e/57855f4f52aa0ee64c6d8210637d0d9847de4c1082e03ddd2ffafd853d2d/autobahn-26.7.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:20b3eab7d483e93278f9d7345592eb6b465883a6e88c2823aad13c3f943452db", size = 682494, upload-time = "2026-07-15T19:13:42.193Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/3b/3c/3944f17dd2a06aee7d0d9f1c37b5a94518434d13ba2c38e738d33ca10daf/autobahn-26.7.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:cd840524ff190aee695a58e8acd8740ee66b4a0e6a58ccb41416ed2cbe48d43f", size = 3403666, upload-time = "2026-07-15T19:13:43.508Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/51/3e/200471878093a502f8e8078c1ca19fd82acc68ae9ac363e395170da6dbe2/autobahn-26.7.1-cp314-cp314-macosx_15_0_arm64.whl", hash = "sha256:f3d1be925e3fb33fff5280c1bd02027047519812c400d3efa5477d3968686c94", size = 1987070, upload-time = "2026-07-15T19:13:47.112Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/61/d1/704f881fd2c52b056dc0f14e6d0d640b1f3ff43f3b84cf85d3631e3243f4/autobahn-26.7.1-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:44f8094b0c0fa29a4963ce12130b7932469f89afa0242ff858d2b26541a81005", size = 2082939, upload-time = "2026-07-15T19:13:48.569Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/a8/27/84e76aec7abbcb502d4cd34ef5c859eaa19a3b707cda68ab7ce68478dd92/autobahn-26.7.1-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a0fcf9c3ff6b9b2bc85d6e1814a94d1d941d62f7df36d350b523d77df85d66ea", size = 2254983, upload-time = "2026-07-15T19:13:49.802Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/e6/81/a810732a10342c5d6b90d19f83fa2bc9b6126e7c0cda7c4df867e311aa2e/autobahn-26.7.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:4f82e5a113f6c1ff14cec99aa411f7da8fceec3dcd4647d7ebdfc0278811e14d", size = 3174295, upload-time = "2026-07-15T19:13:51.227Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/a6/c6/4886fdaecfeda013e085288a9d83bad6f1ded9995b8088ca933d9ec37201/autobahn-26.7.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:919309cbe41b28b0a3028e7c6fed52aca9fd21639619f372d3270c44341b6bdc", size = 3403713, upload-time = "2026-07-15T19:13:52.744Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/94/14/6485c29ad06a6bd7b3017558f99f6f89dcaa6ef6641930b25d9247adba4c/autobahn-26.7.1-pp311-pypy311_pp73-macosx_15_0_arm64.whl", hash = "sha256:9088acf790caf8cfd86590cb2b749279256ee210198f41d9858a38d1346e56c9", size = 1981962, upload-time = "2026-07-15T19:13:55.79Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/8d/46/cb6d09604417beacdf485b414a05efa18511b0e78ac5451b3655bee711fa/autobahn-26.7.1-pp311-pypy311_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:ea4548ee15c6bdf8aa0a1e81bf47b42db350f2bef69f83bace27a95ed0d21276", size = 655967, upload-time = "2026-07-15T19:13:57.19Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/00/d9/b846bc5a37f25ac147879d6451c466968a54efbf9d0467f0732f37c6f3f3/autobahn-26.7.1-pp311-pypy311_pp73-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d4ee0fe13a5218831d60863becd8c3cf6558e6c5ccc5d9d0e722012bdb1459bf", size = 2207401, upload-time = "2026-07-15T19:13:58.392Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -3211,40 +3222,45 @@ wheels = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pikepdf"
|
name = "pikepdf"
|
||||||
version = "10.2.0"
|
version = "10.13.0.post1"
|
||||||
source = { registry = "https://pypi.org/simple" }
|
source = { registry = "https://pypi.org/simple" }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "deprecated" },
|
|
||||||
{ name = "lxml" },
|
{ name = "lxml" },
|
||||||
{ name = "packaging" },
|
{ name = "packaging" },
|
||||||
{ name = "pillow" },
|
{ name = "pillow" },
|
||||||
]
|
]
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/6e/e9/a1462d6160805ca80c8f4aafc941aaf410a92d0fcc683706e94f499c2fac/pikepdf-10.2.0.tar.gz", hash = "sha256:0f398b0daeb2ffd2358f75c06f1dd47b9ba76f1a77dfe938cccf7080c58227d7", size = 4568506, upload-time = "2026-01-09T22:54:25.847Z" }
|
sdist = { url = "https://files.pythonhosted.org/packages/1c/0e/6e74dd213537b71c945743a4b3112dbb430896ad68b8a6ad22e4468455d4/pikepdf-10.13.0.post1.tar.gz", hash = "sha256:4b73f926ebae81f04bf14527af330bd00bb268be767e0f189f7c4c3e4ad7ae0a", size = 4973186, upload-time = "2026-09-05T06:49:20.825Z" }
|
||||||
wheels = [
|
wheels = [
|
||||||
{ url = "https://files.pythonhosted.org/packages/10/dc/aa7293763b603a9080ffab7ab87c7b571d637a389e9fb2ba839b864ca283/pikepdf-10.2.0-cp311-cp311-macosx_14_0_arm64.whl", hash = "sha256:fb93732127d5183a91300af39e1cda5ded309e8439daec93536331a472b5e190", size = 4727891, upload-time = "2026-01-09T22:53:26.262Z" },
|
{ url = "https://files.pythonhosted.org/packages/4d/c1/48c9c0ed2ed88ca5d9cdd7f16075385a05a812d781b61bfa7f2d5182b247/pikepdf-10.13.0.post1-cp311-cp311-macosx_14_0_arm64.whl", hash = "sha256:98a7305e330f797da02b543d3ad57a134c4a14c6ec6f8d86d91aa9dd130c425b", size = 1846300, upload-time = "2026-09-05T06:48:11.563Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/d2/dc/700c31f2c14f94d92483b10e1918390948ed20f6f572d82beb78ac5f94d0/pikepdf-10.2.0-cp311-cp311-macosx_15_0_x86_64.whl", hash = "sha256:ab7bd4629539cf2136a799dc3eaa2dfda59937035a97b0c5e22a7a3a4033cc49", size = 5030510, upload-time = "2026-01-09T22:53:28.023Z" },
|
{ url = "https://files.pythonhosted.org/packages/e9/20/484a3a61664132dc8c4bd97e0b8291fa79f9f4a3b1e1ffd5b67ac41ed98a/pikepdf-10.13.0.post1-cp311-cp311-macosx_15_0_x86_64.whl", hash = "sha256:f3dedd02795626f17ee42d5c02ec4ec94e28aa47046ef430d4478454a8fbd07f", size = 1944179, upload-time = "2026-09-05T06:48:14.01Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/23/46/dc63364b05aa1913f2d7480cad62676bfb473065ba4b02d314dfd482f7dd/pikepdf-10.2.0-cp311-cp311-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2f5623a5ba456d69dfeb86dc3bb3ec31ec1d120382d8c24804d1b430fce715ea", size = 2439498, upload-time = "2026-01-09T22:53:30.122Z" },
|
{ url = "https://files.pythonhosted.org/packages/66/38/797df7d60352fc5ec3943c425acaa15d032cd2e673b516861f449536db57/pikepdf-10.13.0.post1-cp311-cp311-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7ba09ef5a5f26e38ee558d2a08223fee08a5ef1868962ae2d8590d4de3c8c92f", size = 2105054, upload-time = "2026-09-05T06:48:15.547Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/59/b6/1f9b8ca588fd34d9e3df49a80c62016e0b42ce6e580146c46d9728fdb6e8/pikepdf-10.2.0-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:dec4d12f294df378d122ae441c27c1e76fb0d15b1e9d7374ae70c26604559bab", size = 2666945, upload-time = "2026-01-09T22:53:32.309Z" },
|
{ url = "https://files.pythonhosted.org/packages/a8/8a/1f003558c5c05cecf182af775839ad674fe23e06b314d0219b9d8422680a/pikepdf-10.13.0.post1-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:365b94f2be7e2857c6cb5445b56dc52dc7417ba9f06c8a4282d9f521cb2d0fb8", size = 2308617, upload-time = "2026-09-05T06:48:17.216Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/c1/31/b1e61fac59f0b807edde655a821ff83bb041ae1500234c52ed1a2403c44a/pikepdf-10.2.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:c0cebe3235232f1bd3c5f7956218ce92241c94223cb80eba837d372a40c61765", size = 3638109, upload-time = "2026-01-09T22:53:34.144Z" },
|
{ url = "https://files.pythonhosted.org/packages/55/5b/0e7193ee8c7ca5b15f478918033a0fa78917b01249e1d4a4a65754644cd3/pikepdf-10.13.0.post1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:f515a31c76cce043bbb7b781e77a343a4e26fa8f520ba337d30ddea0f7a0ce50", size = 3742337, upload-time = "2026-09-05T06:48:18.971Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/02/e9/a99bbf503c9d55e54553edff84ec67cac49d335fc33f3d5516c4746b6340/pikepdf-10.2.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:d0908e845c9140e245ad89a19fdfc6e5a6d82fcb505b8cc2c0ce81439ac4f064", size = 3829538, upload-time = "2026-01-09T22:53:36.341Z" },
|
{ url = "https://files.pythonhosted.org/packages/b6/f5/519e8728c04d05dcbd44d03b266a3f6acf8de3a0a6ed5ec0fa39ececddd7/pikepdf-10.13.0.post1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:b63577c44fedf7ed6b971076f7c7ed0ff95a8bac627ac93b79e8b542214861a7", size = 3952988, upload-time = "2026-09-05T06:48:20.984Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/73/18/598383493a0f0f0c4eecd09b8fe06dddb9d326a89e2623a134d43e051485/pikepdf-10.2.0-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:18c35d00baff72bfae82d67028bedb02ea2b208e1af5545c23cd681f2487a279", size = 4737716, upload-time = "2026-01-09T22:53:39.973Z" },
|
{ url = "https://files.pythonhosted.org/packages/6c/a5/598e72c72ed46046e297f15763dffda88424870724a4a22f599b815cb774/pikepdf-10.13.0.post1-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:2c6e83f8a1828ec79cdec4df8cc07209eaf10ed7e4f5a90a7356b254bacc07d5", size = 1845515, upload-time = "2026-09-05T06:48:24.477Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/a9/f5/bec04784ba07d44f03b52ea524bcb7409bf7185ee8abec7ae29e3ac9e9ae/pikepdf-10.2.0-cp312-cp312-macosx_15_0_x86_64.whl", hash = "sha256:dd849d033b95de15965c095ebc4d78983099a11bb7b7897801dfaf3cb4083a35", size = 5042152, upload-time = "2026-01-09T22:53:41.808Z" },
|
{ url = "https://files.pythonhosted.org/packages/f9/b3/29691a5e9ee915357c081730d8cc02f35f19b4155857556fbe562a4d83ba/pikepdf-10.13.0.post1-cp312-cp312-macosx_15_0_x86_64.whl", hash = "sha256:f2463f650efab46905b9e279f5c776faf96c65bb45c1acf9f2de0e8a6eec5fb7", size = 1944755, upload-time = "2026-09-05T06:48:26.332Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/38/3e/148b3c8e101c8ac3a33f41e86c5739413575495e471bce45ee228aafcbd6/pikepdf-10.2.0-cp312-cp312-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e9910efdc7907af3da9e7b2a125a1f67d512165ffa623f62825deeb642669a7a", size = 2445796, upload-time = "2026-01-09T22:53:44.027Z" },
|
{ url = "https://files.pythonhosted.org/packages/04/4e/201f553b9405424d7aefa3be997f0a1c787ac3a089a844f1fc42f412fe0e/pikepdf-10.13.0.post1-cp312-cp312-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9613505f5b22203465d4224a3fd8cf69876ce8278442c6478ac6849f54724a30", size = 2102202, upload-time = "2026-09-05T06:48:28.816Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/c2/ef/b06f8fd68c34fed631cb8e3520dd955e59987de0eee6960dbc94bed11711/pikepdf-10.2.0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f0ec947e6429d7a3306153d32a0142462fdd8f905c5fe08c8a8e8c53b9c28a5c", size = 2693908, upload-time = "2026-01-09T22:53:46.039Z" },
|
{ url = "https://files.pythonhosted.org/packages/3e/a3/bd7e7b321e8bfe4b8530da57d12c557a259bbd4b40e739960a1f2ea507cb/pikepdf-10.13.0.post1-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:01f80ca046d984752cf6f08093debc193884bee91c01c104aa0236767711a20f", size = 2307194, upload-time = "2026-09-05T06:48:31.558Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/3e/bf/e5c40e9210e2ae8da7cad2cf6ae7d1db3b63a2916e6040645958e9ab4054/pikepdf-10.2.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:b6383219a1cd31400403a69737a4e2a0c5d2a2c4cb9f380bcf45e33e8de802ea", size = 3643423, upload-time = "2026-01-09T22:53:48.333Z" },
|
{ url = "https://files.pythonhosted.org/packages/23/b0/ca630f56015dfc6c4c8c81fdeb5f5099e7fca354f54a41dfe7f1382a5316/pikepdf-10.13.0.post1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:1f76fcbe5d86f2ae6f231ba542cd04793d4c89e75bd5f62526b7412926ff2100", size = 3740157, upload-time = "2026-09-05T06:48:33.285Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/d4/1b/969dfb29dc9fd7b82fa7bc065df498e8a3e7ddb81e982140634ee539a8db/pikepdf-10.2.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:46d2f9ef5a84949bfc11152a323558f94cf85d9d97e9c510c061c7f803028f3f", size = 3854816, upload-time = "2026-01-09T22:53:50.158Z" },
|
{ url = "https://files.pythonhosted.org/packages/be/48/7a84adc2fd14ec35e4b3d007575914de1ce0518e8c69b35ebee62fa2b142/pikepdf-10.13.0.post1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:092a9bf15739e931ecab15ec3baee5d9629dad90ea4e42b779f6b439d2d1e462", size = 3951601, upload-time = "2026-09-05T06:48:35.468Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/f4/c5/e6f9e3407dd73ec570000a64747ff84e2f57b06b0477d1da6eaca5038162/pikepdf-10.2.0-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:09ff28d1de7fc7711a7ef8dfc40396d9243b64ee24c37cd1ab2a9f9827895caa", size = 4737680, upload-time = "2026-01-09T22:53:54.905Z" },
|
{ url = "https://files.pythonhosted.org/packages/10/f4/3636368760840cbc3ee512330024dd6f518d583c1bbbb1b551ca8e18f5e8/pikepdf-10.13.0.post1-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:87141ada970386ff6640db54f0bda734d3bde7960d3ba04a76768b48e25028ca", size = 1845524, upload-time = "2026-09-05T06:48:39.309Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/eb/de/dffb785235ac2d930db86b215c1848d7258e625fa1949dd0633f8b72ab0a/pikepdf-10.2.0-cp313-cp313-macosx_15_0_x86_64.whl", hash = "sha256:62348b66e1401a4db0c64976b72dd74bb1a9eb3a33007a661500f4f8a64436bd", size = 5042150, upload-time = "2026-01-09T22:53:57.722Z" },
|
{ url = "https://files.pythonhosted.org/packages/ce/dc/7bbfba253a0394a237b81be371a64f904f99636d579ec78ef5d92025fd2d/pikepdf-10.13.0.post1-cp313-cp313-macosx_15_0_x86_64.whl", hash = "sha256:55e53b4d8a4b1700f686f76e3a68411e421e962a4c8b1b90d00aab3f3e494a55", size = 1944829, upload-time = "2026-09-05T06:48:41.601Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/1a/f0/4d883f57304d98650ade30a8c73fe593582d9afd9a7dada1f5f3f4cce362/pikepdf-10.2.0-cp313-cp313-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6b9e91780cb9ea3c6a350ffbcf03d5d95c30084d238afbd1d4b927cdb9e3649d", size = 2445446, upload-time = "2026-01-09T22:53:59.993Z" },
|
{ url = "https://files.pythonhosted.org/packages/b3/a3/10367bfb93501a151cbb96b6973f7c049fef04040aea35cf6cedf579c3e4/pikepdf-10.13.0.post1-cp313-cp313-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8fb8f82dc43056a4b4f891e78ee1db4e3ced75ba3e87b836f8e28c8771228928", size = 2102269, upload-time = "2026-09-05T06:48:43.222Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/62/65/ffe2555812a152d616accacea7c1c617c27a75590379ea7d9cc3a26bd92d/pikepdf-10.2.0-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:52360a49a22e9353ec9a08ff5713cec8aacaf3ef960c704bc0a89ca8f050bdad", size = 2696242, upload-time = "2026-01-09T22:54:01.687Z" },
|
{ url = "https://files.pythonhosted.org/packages/2d/bd/a68b5d9b4aef4d4b9c374cfdfd941623f52303d31adea13554568df42abe/pikepdf-10.13.0.post1-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d3b58ccb30b93ba400e6a6a83315b4830eea49f6f19e18d78241fe6b1c49fec2", size = 2307160, upload-time = "2026-09-05T06:48:45.192Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/ed/8c/2f937b0e2867cd48b523122e08753571fc9847978e239d7b5db9bd46879c/pikepdf-10.2.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:4ba4c1046939eb22c24c396deb37f8e0500caaa66b73114be55377d5554b4167", size = 3643730, upload-time = "2026-01-09T22:54:04.177Z" },
|
{ url = "https://files.pythonhosted.org/packages/9a/59/47bd86d9e338d301c28416d52d0e154a0d6322cf6b957c9df5f3d7828aa2/pikepdf-10.13.0.post1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:6b038cd5bcbb6c1952bcc271695eaf24c4199d72e45606ee5e467f837760820e", size = 3739722, upload-time = "2026-09-05T06:48:47.105Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/2e/17/f2919e4085c399e938bb945ea712dea70b3849e17cae6403f0cc1100e9ef/pikepdf-10.2.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:3dcd8957a08e0a47f7a138904dca8cf73962fa17a096a47cd8bc33eb83a4f0a7", size = 3856645, upload-time = "2026-01-09T22:54:07.887Z" },
|
{ url = "https://files.pythonhosted.org/packages/49/40/87fb6dddc9dce110429c72449e942174fd500f6fc4c9ff518a6b73057aa7/pikepdf-10.13.0.post1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:b7b0cbb135de32ec3f41651a08ab294e3c18ae9fec32516a48d27e64a53a47f0", size = 3951549, upload-time = "2026-09-05T06:48:49.263Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/1d/6e/846902abe8286d3b4ab70893e9ffbeec99aadd93ba1536cf471b222bb910/pikepdf-10.2.0-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:194c9a81ecb49e425a5cd5162621270b5e42cf05709d87eac018bd6f9ce98f80", size = 4733930, upload-time = "2026-01-09T22:54:11.931Z" },
|
{ url = "https://files.pythonhosted.org/packages/14/0e/86897bf5325824c1f2d9d8be89839baf11011b5392d92620cb0273fb9af5/pikepdf-10.13.0.post1-cp314-abi3-macosx_14_0_arm64.whl", hash = "sha256:51fae4a4a3c6549aa4c405896ff7010f3e43e0c4f407c0bcee071ef13d271202", size = 1845245, upload-time = "2026-09-05T06:48:53.575Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/8b/6d/abdbb794d2a512d4e828ef2014cc47ca263ad3fbd1b65f25f791b9c0bb1e/pikepdf-10.2.0-cp314-cp314-macosx_15_0_x86_64.whl", hash = "sha256:5adcf87dbfff4e1cd0a850db487274f474c94a6bf6347f3842c53da8d0eaa8df", size = 5042477, upload-time = "2026-01-09T22:54:13.708Z" },
|
{ url = "https://files.pythonhosted.org/packages/f0/ed/923846b7511627f8564d09345e083f14674dfb193de92d27f1cc4650602e/pikepdf-10.13.0.post1-cp314-abi3-macosx_15_0_x86_64.whl", hash = "sha256:8cb976331cb8b03ec3465e06d9e7a3eadbadb7e622be888e70f918ac732a105e", size = 1943659, upload-time = "2026-09-05T06:48:55.823Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/52/6c/6c42694fe1574a37aa2a40b4ba29a6713b4226436155ef7aa0bef649c117/pikepdf-10.2.0-cp314-cp314-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5de3cecbb35c4bc651e9326932974217be1d450d4a9840d77a592062eb507e27", size = 2448419, upload-time = "2026-01-09T22:54:15.6Z" },
|
{ url = "https://files.pythonhosted.org/packages/58/bb/fcb09ad4bd227bbb37a7e5b24de86f9ce9d462aa0c7ee18781899bfa378a/pikepdf-10.13.0.post1-cp314-abi3-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3e18d5a009bbe5f3ab18f916fb9e28f0c5b0d920736e3a85cc10c627ec633596", size = 2099423, upload-time = "2026-09-05T06:48:57.671Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/45/f4/aca3286aa37ace581afc8e3e0644a0cc55b9f9ceb31f28219d12ca11536c/pikepdf-10.2.0-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:77868fd25182a45a4f3dec3c461aea8c696ef9565894c5cde4394bc8c32fb069", size = 2697600, upload-time = "2026-01-09T22:54:18.123Z" },
|
{ url = "https://files.pythonhosted.org/packages/3b/c9/707f9ba96727fa366a650237e46f0e20a73b244592eb6b97a49e401e2b43/pikepdf-10.13.0.post1-cp314-abi3-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:414f42c83e5e6029870de1a988625dafc95781ebff10e15d82caeb5e69a83c9c", size = 2303539, upload-time = "2026-09-05T06:48:59.506Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/eb/a6/9135f9f0189634de61410573a0712d849e0157e3902e6b867339cc7dbf1b/pikepdf-10.2.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:a9a10e15e2f4d0bba36a2b4328342d00eff1a5a31399e1d1a93483c70d3c2b0e", size = 3647720, upload-time = "2026-01-09T22:54:20.158Z" },
|
{ url = "https://files.pythonhosted.org/packages/22/15/79a2ccc354514a1321a161867a011fc917be158fc01a88da8c78ad18399d/pikepdf-10.13.0.post1-cp314-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:f0eb89f06cad9231b9db54d81a22592b03b63924824a6b850febd2b75daa6546", size = 3737772, upload-time = "2026-09-05T06:49:01.245Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/83/60/f282077773a3321fad4cbfb16fe73ee3f8dd93b408df65c24779f12227c5/pikepdf-10.2.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:a8f80ecf00fb15a760f218432a1046e7797cd14eaa6ccb52c8814ae8852745d8", size = 3859133, upload-time = "2026-01-09T22:54:22.358Z" },
|
{ url = "https://files.pythonhosted.org/packages/6e/85/a17440c2de64da71dc012b42e644b30ee4d98eb540c14d4e9f3538b73a9e/pikepdf-10.13.0.post1-cp314-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:544f1be1b1e5630a79cd182a8663c439504099eb9eae0d342a50173d9825bdf3", size = 3948342, upload-time = "2026-09-05T06:49:03.27Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/e9/65/15a796a3cf9fb17d41acc1ab6719e7d3dcdf1260e76909d0dd32ecc97ba7/pikepdf-10.13.0.post1-cp314-cp314t-macosx_14_0_arm64.whl", hash = "sha256:ef4ed47d40aa44deb063feb4a88e8bcf1c8fa0183ce526dc4295f7cbdb1292f8", size = 1853638, upload-time = "2026-09-05T06:49:07.184Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/aa/f3/5d49a511fd13b59b94c5ad673695d331fce5d2846ab1501646c2a3b35b5f/pikepdf-10.13.0.post1-cp314-cp314t-macosx_15_0_x86_64.whl", hash = "sha256:571efcd1d54e0dd817973c76c253feb6fb758c93bb0c16a893cc68f2a178d404", size = 1951768, upload-time = "2026-09-05T06:49:09.048Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/06/de/f6bbd9653695f6e2ed3494a439f11f3a89450c004fe9bf8ee583201ea759/pikepdf-10.13.0.post1-cp314-cp314t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2db9a18074ba112e7c517e8c21dfd8894cc13b8a37ccf49a5841192170fb68eb", size = 2107137, upload-time = "2026-09-05T06:49:10.788Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/c0/aa/43b355681f05ea0b5808a26cba9e8e764686fa8dcebe0875db612664de40/pikepdf-10.13.0.post1-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f7962a75cf22d0d683b49ab19b8966e94dc7014d9aa6806f3c0d2b37fb9ae607", size = 2310991, upload-time = "2026-09-05T06:49:12.455Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/a9/46/77574e9c4bded01afd7a3fe538f5432e396c3772c9bc1eab5d287aed00df/pikepdf-10.13.0.post1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:b948e11f7dd3710f939194f00b4d75b0df87a30d53b31414128768ac25da77d8", size = 3744358, upload-time = "2026-09-05T06:49:14.428Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/89/a8/4857df72cf4773553c2e6a82f93ee5e98c02f1c4e4877379e84d27384982/pikepdf-10.13.0.post1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:99f6afccd6119233e7133bd4c2ade48461de3ddd4269cc28a4f90cdf7c1372f5", size = 3955964, upload-time = "2026-09-05T06:49:16.656Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -3604,12 +3620,6 @@ wheels = [
|
|||||||
{ url = "https://files.pythonhosted.org/packages/37/ed/89c2c620af0e1660354cd8aabf9f5b21f911597ce22acb37c805d6c86bc8/psycopg_pool-3.3.1-py3-none-any.whl", hash = "sha256:2af5b432941c4c9ad5c87b3fa410aec910ec8f7c122855897983a06c45f2e4b5", size = 40023, upload-time = "2026-05-01T23:31:53.136Z" },
|
{ url = "https://files.pythonhosted.org/packages/37/ed/89c2c620af0e1660354cd8aabf9f5b21f911597ce22acb37c805d6c86bc8/psycopg_pool-3.3.1-py3-none-any.whl", hash = "sha256:2af5b432941c4c9ad5c87b3fa410aec910ec8f7c122855897983a06c45f2e4b5", size = 40023, upload-time = "2026-05-01T23:31:53.136Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "py-ubjson"
|
|
||||||
version = "0.16.1"
|
|
||||||
source = { registry = "https://pypi.org/simple" }
|
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/1d/c7/28220d37e041fe1df03e857fe48f768dcd30cd151480bf6f00da8713214a/py-ubjson-0.16.1.tar.gz", hash = "sha256:b9bfb8695a1c7e3632e800fb83c943bf67ed45ddd87cd0344851610c69a5a482", size = 50316, upload-time = "2020-04-18T15:05:57.698Z" }
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pyasn1"
|
name = "pyasn1"
|
||||||
version = "0.6.4"
|
version = "0.6.4"
|
||||||
|
|||||||
Reference in New Issue
Block a user