mirror of
https://github.com/paperless-ngx/paperless-ngx.git
synced 2026-03-05 00:36:26 +00:00
Compare commits
11 Commits
logo-v3
...
mitigate-p
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a4fc3cfed3 | ||
|
|
00e9522f15 | ||
|
|
aeb800b498 | ||
|
|
190fc70288 | ||
|
|
c623234769 | ||
|
|
299dac21ee | ||
|
|
5498503d60 | ||
|
|
16b58c2de5 | ||
|
|
c724fbb5d9 | ||
|
|
9c0f112e94 | ||
|
|
43406f44f2 |
17
.github/workflows/pr-bot.yml
vendored
17
.github/workflows/pr-bot.yml
vendored
@@ -2,13 +2,24 @@ name: PR Bot
|
||||
on:
|
||||
pull_request_target:
|
||||
types: [opened]
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: write
|
||||
jobs:
|
||||
anti-slop:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
issues: read
|
||||
pull-requests: write
|
||||
steps:
|
||||
- uses: peakoss/anti-slop@v0
|
||||
with:
|
||||
max-failures: 4
|
||||
failure-add-pr-labels: 'ai'
|
||||
pr-bot:
|
||||
name: Automated PR Bot
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: write
|
||||
steps:
|
||||
- name: Label PR by file path or branch name
|
||||
# see .github/labeler.yml for the labeler config
|
||||
|
||||
@@ -440,9 +440,6 @@ src/documents/permissions.py:0: error: Item "list[str]" of "Any | list[str] | Qu
|
||||
src/documents/permissions.py:0: error: Item "list[str]" of "Any | list[str] | QuerySet[User, User]" has no attribute "exists" [union-attr]
|
||||
src/documents/permissions.py:0: error: Missing type parameters for generic type "QuerySet" [type-arg]
|
||||
src/documents/permissions.py:0: error: Missing type parameters for generic type "dict" [type-arg]
|
||||
src/documents/plugins/helpers.py:0: error: "Collection[str]" has no attribute "update" [attr-defined]
|
||||
src/documents/plugins/helpers.py:0: error: Argument 1 to "send" of "BaseStatusManager" has incompatible type "dict[str, Collection[str]]"; expected "dict[str, str | int | None]" [arg-type]
|
||||
src/documents/plugins/helpers.py:0: error: Argument 1 to "send" of "BaseStatusManager" has incompatible type "dict[str, Collection[str]]"; expected "dict[str, str | int | None]" [arg-type]
|
||||
src/documents/plugins/helpers.py:0: error: Function is missing a type annotation [no-untyped-def]
|
||||
src/documents/plugins/helpers.py:0: error: Function is missing a type annotation [no-untyped-def]
|
||||
src/documents/plugins/helpers.py:0: error: Skipping analyzing "channels_redis.pubsub": module is installed, but missing library stubs or py.typed marker [import-untyped]
|
||||
@@ -667,7 +664,6 @@ src/documents/signals/handlers.py:0: error: Argument 3 to "validate_move" has in
|
||||
src/documents/signals/handlers.py:0: error: Argument 5 to "_suggestion_printer" has incompatible type "Any | None"; expected "MatchingModel" [arg-type]
|
||||
src/documents/signals/handlers.py:0: error: Argument 5 to "_suggestion_printer" has incompatible type "Any | None"; expected "MatchingModel" [arg-type]
|
||||
src/documents/signals/handlers.py:0: error: Argument 5 to "_suggestion_printer" has incompatible type "Any | None"; expected "MatchingModel" [arg-type]
|
||||
src/documents/signals/handlers.py:0: error: Function is missing a return type annotation [no-untyped-def]
|
||||
src/documents/signals/handlers.py:0: error: Function is missing a type annotation [no-untyped-def]
|
||||
src/documents/signals/handlers.py:0: error: Function is missing a type annotation [no-untyped-def]
|
||||
src/documents/signals/handlers.py:0: error: Function is missing a type annotation for one or more arguments [no-untyped-def]
|
||||
@@ -1928,6 +1924,7 @@ src/paperless/tests/test_websockets.py:0: error: Item "None" of "BaseChannelLaye
|
||||
src/paperless/tests/test_websockets.py:0: error: Item "None" of "BaseChannelLayer | None" has no attribute "group_send" [union-attr]
|
||||
src/paperless/tests/test_websockets.py:0: error: Item "None" of "BaseChannelLayer | None" has no attribute "group_send" [union-attr]
|
||||
src/paperless/tests/test_websockets.py:0: error: Item "None" of "BaseChannelLayer | None" has no attribute "group_send" [union-attr]
|
||||
src/paperless/tests/test_websockets.py:0: error: Item "None" of "BaseChannelLayer | None" has no attribute "group_send" [union-attr]
|
||||
src/paperless/tests/test_websockets.py:0: error: TypedDict "_WebsocketTestScope" has no key "user" [typeddict-item]
|
||||
src/paperless/tests/test_websockets.py:0: error: TypedDict "_WebsocketTestScope" has no key "user" [typeddict-item]
|
||||
src/paperless/tests/test_websockets.py:0: error: TypedDict "_WebsocketTestScope" has no key "user" [typeddict-item]
|
||||
|
||||
@@ -30,7 +30,7 @@ RUN set -eux \
|
||||
# Purpose: Installs s6-overlay and rootfs
|
||||
# Comments:
|
||||
# - Don't leave anything extra in here either
|
||||
FROM ghcr.io/astral-sh/uv:0.10.5-python3.12-trixie-slim AS s6-overlay-base
|
||||
FROM ghcr.io/astral-sh/uv:0.10.7-python3.12-trixie-slim AS s6-overlay-base
|
||||
|
||||
WORKDIR /usr/src/s6
|
||||
|
||||
|
||||
@@ -111,6 +111,7 @@ docs = [
|
||||
testing = [
|
||||
"daphne",
|
||||
"factory-boy~=3.3.1",
|
||||
"faker~=40.5.1",
|
||||
"imagehash",
|
||||
"pytest~=9.0.0",
|
||||
"pytest-cov~=7.0.0",
|
||||
@@ -309,6 +310,7 @@ markers = [
|
||||
[tool.pytest_env]
|
||||
PAPERLESS_DISABLE_DBHANDLER = "true"
|
||||
PAPERLESS_CACHE_BACKEND = "django.core.cache.backends.locmem.LocMemCache"
|
||||
PAPERLESS_CHANNELS_BACKEND = "channels.layers.InMemoryChannelLayer"
|
||||
|
||||
[tool.coverage.report]
|
||||
exclude_also = [
|
||||
|
||||
@@ -1217,7 +1217,7 @@
|
||||
</context-group>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
|
||||
<context context-type="linenumber">1629</context>
|
||||
<context context-type="linenumber">1756</context>
|
||||
</context-group>
|
||||
</trans-unit>
|
||||
<trans-unit id="1577733187050997705" datatype="html">
|
||||
@@ -1238,8 +1238,8 @@
|
||||
<context context-type="linenumber">82</context>
|
||||
</context-group>
|
||||
</trans-unit>
|
||||
<trans-unit id="8035757452478567832" datatype="html">
|
||||
<source>Update existing document</source>
|
||||
<trans-unit id="7860582931776068318" datatype="html">
|
||||
<source>Add document version</source>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/components/admin/settings/settings.component.html</context>
|
||||
<context context-type="linenumber">280</context>
|
||||
@@ -2088,6 +2088,10 @@
|
||||
<context context-type="sourcefile">src/app/components/admin/tasks/tasks.component.ts</context>
|
||||
<context context-type="linenumber">155</context>
|
||||
</context-group>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
|
||||
<context context-type="linenumber">634</context>
|
||||
</context-group>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/components/document-detail/document-version-dropdown/document-version-dropdown.component.html</context>
|
||||
<context context-type="linenumber">50</context>
|
||||
@@ -2794,11 +2798,11 @@
|
||||
</context-group>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
|
||||
<context context-type="linenumber">1249</context>
|
||||
<context context-type="linenumber">1376</context>
|
||||
</context-group>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
|
||||
<context context-type="linenumber">1630</context>
|
||||
<context context-type="linenumber">1757</context>
|
||||
</context-group>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.ts</context>
|
||||
@@ -3396,7 +3400,7 @@
|
||||
</context-group>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
|
||||
<context context-type="linenumber">1202</context>
|
||||
<context context-type="linenumber">1329</context>
|
||||
</context-group>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.ts</context>
|
||||
@@ -3501,7 +3505,7 @@
|
||||
</context-group>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
|
||||
<context context-type="linenumber">1681</context>
|
||||
<context context-type="linenumber">1808</context>
|
||||
</context-group>
|
||||
</trans-unit>
|
||||
<trans-unit id="6661109599266152398" datatype="html">
|
||||
@@ -3512,7 +3516,7 @@
|
||||
</context-group>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
|
||||
<context context-type="linenumber">1682</context>
|
||||
<context context-type="linenumber">1809</context>
|
||||
</context-group>
|
||||
</trans-unit>
|
||||
<trans-unit id="5162686434580248853" datatype="html">
|
||||
@@ -3523,7 +3527,7 @@
|
||||
</context-group>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
|
||||
<context context-type="linenumber">1683</context>
|
||||
<context context-type="linenumber">1810</context>
|
||||
</context-group>
|
||||
</trans-unit>
|
||||
<trans-unit id="8157388568390631653" datatype="html">
|
||||
@@ -5484,7 +5488,7 @@
|
||||
</context-group>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
|
||||
<context context-type="linenumber">1206</context>
|
||||
<context context-type="linenumber">1333</context>
|
||||
</context-group>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.ts</context>
|
||||
@@ -7691,67 +7695,81 @@
|
||||
<source>Error retrieving metadata</source>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
|
||||
<context context-type="linenumber">397</context>
|
||||
<context context-type="linenumber">408</context>
|
||||
</context-group>
|
||||
</trans-unit>
|
||||
<trans-unit id="2218903673684131427" datatype="html">
|
||||
<source>An error occurred loading content: <x id="PH" equiv-text="err.message ?? err.toString()"/></source>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
|
||||
<context context-type="linenumber">495,497</context>
|
||||
<context context-type="linenumber">509,511</context>
|
||||
</context-group>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
|
||||
<context context-type="linenumber">841,843</context>
|
||||
<context context-type="linenumber">956,958</context>
|
||||
</context-group>
|
||||
</trans-unit>
|
||||
<trans-unit id="3200733026060976258" datatype="html">
|
||||
<source>Document changes detected</source>
|
||||
<trans-unit id="6357361810318120957" datatype="html">
|
||||
<source>Document was updated</source>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
|
||||
<context context-type="linenumber">555</context>
|
||||
<context context-type="linenumber">629</context>
|
||||
</context-group>
|
||||
</trans-unit>
|
||||
<trans-unit id="2887155916749964" datatype="html">
|
||||
<source>The version of this document in your browser session appears older than the existing version.</source>
|
||||
<trans-unit id="5154064822428631306" datatype="html">
|
||||
<source>Document was updated at <x id="PH" equiv-text="formattedModified"/>.</source>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
|
||||
<context context-type="linenumber">556</context>
|
||||
<context context-type="linenumber">630</context>
|
||||
</context-group>
|
||||
</trans-unit>
|
||||
<trans-unit id="237142428785956348" datatype="html">
|
||||
<source>Saving the document here may overwrite other changes that were made. To restore the existing version, discard your changes or close the document.</source>
|
||||
<trans-unit id="8462497568316256794" datatype="html">
|
||||
<source>Reload to discard your local unsaved edits and load the latest remote version.</source>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
|
||||
<context context-type="linenumber">557</context>
|
||||
<context context-type="linenumber">631</context>
|
||||
</context-group>
|
||||
</trans-unit>
|
||||
<trans-unit id="8720977247725652816" datatype="html">
|
||||
<source>Ok</source>
|
||||
<trans-unit id="7967484035994732534" datatype="html">
|
||||
<source>Reload</source>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
|
||||
<context context-type="linenumber">559</context>
|
||||
<context context-type="linenumber">633</context>
|
||||
</context-group>
|
||||
</trans-unit>
|
||||
<trans-unit id="2907037627372942104" datatype="html">
|
||||
<source>Document reloaded with latest changes.</source>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
|
||||
<context context-type="linenumber">689</context>
|
||||
</context-group>
|
||||
</trans-unit>
|
||||
<trans-unit id="6435639868943916539" datatype="html">
|
||||
<source>Document reloaded.</source>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
|
||||
<context context-type="linenumber">700</context>
|
||||
</context-group>
|
||||
</trans-unit>
|
||||
<trans-unit id="6142395741265832184" datatype="html">
|
||||
<source>Next document</source>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
|
||||
<context context-type="linenumber">687</context>
|
||||
<context context-type="linenumber">802</context>
|
||||
</context-group>
|
||||
</trans-unit>
|
||||
<trans-unit id="651985345816518480" datatype="html">
|
||||
<source>Previous document</source>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
|
||||
<context context-type="linenumber">697</context>
|
||||
<context context-type="linenumber">812</context>
|
||||
</context-group>
|
||||
</trans-unit>
|
||||
<trans-unit id="2885986061416655600" datatype="html">
|
||||
<source>Close document</source>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
|
||||
<context context-type="linenumber">705</context>
|
||||
<context context-type="linenumber">820</context>
|
||||
</context-group>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/services/open-documents.service.ts</context>
|
||||
@@ -7762,67 +7780,67 @@
|
||||
<source>Save document</source>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
|
||||
<context context-type="linenumber">712</context>
|
||||
<context context-type="linenumber">827</context>
|
||||
</context-group>
|
||||
</trans-unit>
|
||||
<trans-unit id="1784543155727940353" datatype="html">
|
||||
<source>Save and close / next</source>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
|
||||
<context context-type="linenumber">721</context>
|
||||
<context context-type="linenumber">836</context>
|
||||
</context-group>
|
||||
</trans-unit>
|
||||
<trans-unit id="7427704425579737895" datatype="html">
|
||||
<source>Error retrieving version content</source>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
|
||||
<context context-type="linenumber">825</context>
|
||||
<context context-type="linenumber">940</context>
|
||||
</context-group>
|
||||
</trans-unit>
|
||||
<trans-unit id="3456881259945295697" datatype="html">
|
||||
<source>Error retrieving suggestions.</source>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
|
||||
<context context-type="linenumber">882</context>
|
||||
<context context-type="linenumber">997</context>
|
||||
</context-group>
|
||||
</trans-unit>
|
||||
<trans-unit id="2194092841814123758" datatype="html">
|
||||
<source>Document "<x id="PH" equiv-text="newValues.title"/>" saved successfully.</source>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
|
||||
<context context-type="linenumber">1091</context>
|
||||
<context context-type="linenumber">1209</context>
|
||||
</context-group>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
|
||||
<context context-type="linenumber">1115</context>
|
||||
<context context-type="linenumber">1236</context>
|
||||
</context-group>
|
||||
</trans-unit>
|
||||
<trans-unit id="6626387786259219838" datatype="html">
|
||||
<source>Error saving document "<x id="PH" equiv-text="this.document.title"/>"</source>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
|
||||
<context context-type="linenumber">1121</context>
|
||||
<context context-type="linenumber">1242</context>
|
||||
</context-group>
|
||||
</trans-unit>
|
||||
<trans-unit id="448882439049417053" datatype="html">
|
||||
<source>Error saving document</source>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
|
||||
<context context-type="linenumber">1171</context>
|
||||
<context context-type="linenumber">1297</context>
|
||||
</context-group>
|
||||
</trans-unit>
|
||||
<trans-unit id="8410796510716511826" datatype="html">
|
||||
<source>Do you really want to move the document "<x id="PH" equiv-text="this.document.title"/>" to the trash?</source>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
|
||||
<context context-type="linenumber">1203</context>
|
||||
<context context-type="linenumber">1330</context>
|
||||
</context-group>
|
||||
</trans-unit>
|
||||
<trans-unit id="282586936710748252" datatype="html">
|
||||
<source>Documents can be restored prior to permanent deletion.</source>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
|
||||
<context context-type="linenumber">1204</context>
|
||||
<context context-type="linenumber">1331</context>
|
||||
</context-group>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.ts</context>
|
||||
@@ -7833,14 +7851,14 @@
|
||||
<source>Error deleting document</source>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
|
||||
<context context-type="linenumber">1225</context>
|
||||
<context context-type="linenumber">1352</context>
|
||||
</context-group>
|
||||
</trans-unit>
|
||||
<trans-unit id="619486176823357521" datatype="html">
|
||||
<source>Reprocess confirm</source>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
|
||||
<context context-type="linenumber">1245</context>
|
||||
<context context-type="linenumber">1372</context>
|
||||
</context-group>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.ts</context>
|
||||
@@ -7851,102 +7869,102 @@
|
||||
<source>This operation will permanently recreate the archive file for this document.</source>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
|
||||
<context context-type="linenumber">1246</context>
|
||||
<context context-type="linenumber">1373</context>
|
||||
</context-group>
|
||||
</trans-unit>
|
||||
<trans-unit id="302054111564709516" datatype="html">
|
||||
<source>The archive file will be re-generated with the current settings.</source>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
|
||||
<context context-type="linenumber">1247</context>
|
||||
<context context-type="linenumber">1374</context>
|
||||
</context-group>
|
||||
</trans-unit>
|
||||
<trans-unit id="8251197608401006898" datatype="html">
|
||||
<source>Reprocess operation for "<x id="PH" equiv-text="this.document.title"/>" will begin in the background. Close and re-open or reload this document after the operation has completed to see new content.</source>
|
||||
<trans-unit id="4700389117298802932" datatype="html">
|
||||
<source>Reprocess operation for "<x id="PH" equiv-text="this.document.title"/>" will begin in the background.</source>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
|
||||
<context context-type="linenumber">1257</context>
|
||||
<context context-type="linenumber">1384</context>
|
||||
</context-group>
|
||||
</trans-unit>
|
||||
<trans-unit id="4409560272830824468" datatype="html">
|
||||
<source>Error executing operation</source>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
|
||||
<context context-type="linenumber">1268</context>
|
||||
<context context-type="linenumber">1395</context>
|
||||
</context-group>
|
||||
</trans-unit>
|
||||
<trans-unit id="6030453331794586802" datatype="html">
|
||||
<source>Error downloading document</source>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
|
||||
<context context-type="linenumber">1331</context>
|
||||
<context context-type="linenumber">1458</context>
|
||||
</context-group>
|
||||
</trans-unit>
|
||||
<trans-unit id="4458954481601077369" datatype="html">
|
||||
<source>Page Fit</source>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
|
||||
<context context-type="linenumber">1411</context>
|
||||
<context context-type="linenumber">1538</context>
|
||||
</context-group>
|
||||
</trans-unit>
|
||||
<trans-unit id="4663705961777238777" datatype="html">
|
||||
<source>PDF edit operation for "<x id="PH" equiv-text="this.document.title"/>" will begin in the background.</source>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
|
||||
<context context-type="linenumber">1648</context>
|
||||
<context context-type="linenumber">1775</context>
|
||||
</context-group>
|
||||
</trans-unit>
|
||||
<trans-unit id="9043972994040261999" datatype="html">
|
||||
<source>Error executing PDF edit operation</source>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
|
||||
<context context-type="linenumber">1660</context>
|
||||
<context context-type="linenumber">1787</context>
|
||||
</context-group>
|
||||
</trans-unit>
|
||||
<trans-unit id="6172690334763056188" datatype="html">
|
||||
<source>Please enter the current password before attempting to remove it.</source>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
|
||||
<context context-type="linenumber">1671</context>
|
||||
<context context-type="linenumber">1798</context>
|
||||
</context-group>
|
||||
</trans-unit>
|
||||
<trans-unit id="968660764814228922" datatype="html">
|
||||
<source>Password removal operation for "<x id="PH" equiv-text="this.document.title"/>" will begin in the background.</source>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
|
||||
<context context-type="linenumber">1703</context>
|
||||
<context context-type="linenumber">1830</context>
|
||||
</context-group>
|
||||
</trans-unit>
|
||||
<trans-unit id="2282118435712883014" datatype="html">
|
||||
<source>Error executing password removal operation</source>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
|
||||
<context context-type="linenumber">1717</context>
|
||||
<context context-type="linenumber">1844</context>
|
||||
</context-group>
|
||||
</trans-unit>
|
||||
<trans-unit id="3740891324955700797" datatype="html">
|
||||
<source>Print failed.</source>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
|
||||
<context context-type="linenumber">1756</context>
|
||||
<context context-type="linenumber">1883</context>
|
||||
</context-group>
|
||||
</trans-unit>
|
||||
<trans-unit id="6457245677384603573" datatype="html">
|
||||
<source>Error loading document for printing.</source>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
|
||||
<context context-type="linenumber">1768</context>
|
||||
<context context-type="linenumber">1895</context>
|
||||
</context-group>
|
||||
</trans-unit>
|
||||
<trans-unit id="6085793215710522488" datatype="html">
|
||||
<source>An error occurred loading tiff: <x id="PH" equiv-text="err.toString()"/></source>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
|
||||
<context context-type="linenumber">1833</context>
|
||||
<context context-type="linenumber">1960</context>
|
||||
</context-group>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
|
||||
<context context-type="linenumber">1837</context>
|
||||
<context context-type="linenumber">1964</context>
|
||||
</context-group>
|
||||
</trans-unit>
|
||||
<trans-unit id="4958946940233632319" datatype="html">
|
||||
@@ -8411,8 +8429,8 @@
|
||||
<context context-type="linenumber">832</context>
|
||||
</context-group>
|
||||
</trans-unit>
|
||||
<trans-unit id="6390006284731990222" datatype="html">
|
||||
<source>This operation will permanently rotate the original version of <x id="PH" equiv-text="this.list.selected.size"/> document(s).</source>
|
||||
<trans-unit id="5203024009814367559" datatype="html">
|
||||
<source>This operation will add rotated versions of the <x id="PH" equiv-text="this.list.selected.size"/> document(s).</source>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.ts</context>
|
||||
<context context-type="linenumber">833</context>
|
||||
@@ -11182,42 +11200,42 @@
|
||||
<source>Document already exists.</source>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/services/websocket-status.service.ts</context>
|
||||
<context context-type="linenumber">24</context>
|
||||
<context context-type="linenumber">26</context>
|
||||
</context-group>
|
||||
</trans-unit>
|
||||
<trans-unit id="5103087968344279314" datatype="html">
|
||||
<source>Document already exists. Note: existing document is in the trash.</source>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/services/websocket-status.service.ts</context>
|
||||
<context context-type="linenumber">25</context>
|
||||
<context context-type="linenumber">27</context>
|
||||
</context-group>
|
||||
</trans-unit>
|
||||
<trans-unit id="6108404046106249255" datatype="html">
|
||||
<source>Document with ASN already exists.</source>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/services/websocket-status.service.ts</context>
|
||||
<context context-type="linenumber">26</context>
|
||||
<context context-type="linenumber">28</context>
|
||||
</context-group>
|
||||
</trans-unit>
|
||||
<trans-unit id="65951081560571094" datatype="html">
|
||||
<source>Document with ASN already exists. Note: existing document is in the trash.</source>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/services/websocket-status.service.ts</context>
|
||||
<context context-type="linenumber">27</context>
|
||||
<context context-type="linenumber">29</context>
|
||||
</context-group>
|
||||
</trans-unit>
|
||||
<trans-unit id="148389968432135849" datatype="html">
|
||||
<source>File not found.</source>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/services/websocket-status.service.ts</context>
|
||||
<context context-type="linenumber">28</context>
|
||||
<context context-type="linenumber">30</context>
|
||||
</context-group>
|
||||
</trans-unit>
|
||||
<trans-unit id="1520671543092565667" datatype="html">
|
||||
<source>Pre-consume script does not exist.</source>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/services/websocket-status.service.ts</context>
|
||||
<context context-type="linenumber">29</context>
|
||||
<context context-type="linenumber">31</context>
|
||||
</context-group>
|
||||
<note priority="1" from="description">Pre-Consume is a term that appears like that in the documentation as well and does not need a specific translation</note>
|
||||
</trans-unit>
|
||||
@@ -11225,7 +11243,7 @@
|
||||
<source>Error while executing pre-consume script.</source>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/services/websocket-status.service.ts</context>
|
||||
<context context-type="linenumber">30</context>
|
||||
<context context-type="linenumber">32</context>
|
||||
</context-group>
|
||||
<note priority="1" from="description">Pre-Consume is a term that appears like that in the documentation as well and does not need a specific translation</note>
|
||||
</trans-unit>
|
||||
@@ -11233,7 +11251,7 @@
|
||||
<source>Post-consume script does not exist.</source>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/services/websocket-status.service.ts</context>
|
||||
<context context-type="linenumber">31</context>
|
||||
<context context-type="linenumber">33</context>
|
||||
</context-group>
|
||||
<note priority="1" from="description">Post-Consume is a term that appears like that in the documentation as well and does not need a specific translation</note>
|
||||
</trans-unit>
|
||||
@@ -11241,7 +11259,7 @@
|
||||
<source>Error while executing post-consume script.</source>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/services/websocket-status.service.ts</context>
|
||||
<context context-type="linenumber">32</context>
|
||||
<context context-type="linenumber">34</context>
|
||||
</context-group>
|
||||
<note priority="1" from="description">Post-Consume is a term that appears like that in the documentation as well and does not need a specific translation</note>
|
||||
</trans-unit>
|
||||
@@ -11249,49 +11267,49 @@
|
||||
<source>Received new file.</source>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/services/websocket-status.service.ts</context>
|
||||
<context context-type="linenumber">33</context>
|
||||
<context context-type="linenumber">35</context>
|
||||
</context-group>
|
||||
</trans-unit>
|
||||
<trans-unit id="7337565919209746135" datatype="html">
|
||||
<source>File type not supported.</source>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/services/websocket-status.service.ts</context>
|
||||
<context context-type="linenumber">34</context>
|
||||
<context context-type="linenumber">36</context>
|
||||
</context-group>
|
||||
</trans-unit>
|
||||
<trans-unit id="5002399167376099234" datatype="html">
|
||||
<source>Processing document...</source>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/services/websocket-status.service.ts</context>
|
||||
<context context-type="linenumber">35</context>
|
||||
<context context-type="linenumber">37</context>
|
||||
</context-group>
|
||||
</trans-unit>
|
||||
<trans-unit id="1085975194762600381" datatype="html">
|
||||
<source>Generating thumbnail...</source>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/services/websocket-status.service.ts</context>
|
||||
<context context-type="linenumber">36</context>
|
||||
<context context-type="linenumber">38</context>
|
||||
</context-group>
|
||||
</trans-unit>
|
||||
<trans-unit id="3280851677698431426" datatype="html">
|
||||
<source>Retrieving date from document...</source>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/services/websocket-status.service.ts</context>
|
||||
<context context-type="linenumber">37</context>
|
||||
<context context-type="linenumber">39</context>
|
||||
</context-group>
|
||||
</trans-unit>
|
||||
<trans-unit id="7162102384876037296" datatype="html">
|
||||
<source>Saving document...</source>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/services/websocket-status.service.ts</context>
|
||||
<context context-type="linenumber">38</context>
|
||||
<context context-type="linenumber">40</context>
|
||||
</context-group>
|
||||
</trans-unit>
|
||||
<trans-unit id="4550450765009165976" datatype="html">
|
||||
<source>Finished.</source>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/services/websocket-status.service.ts</context>
|
||||
<context context-type="linenumber">39</context>
|
||||
<context context-type="linenumber">41</context>
|
||||
</context-group>
|
||||
</trans-unit>
|
||||
<trans-unit id="2501522447884928778" datatype="html">
|
||||
|
||||
@@ -277,7 +277,7 @@
|
||||
<div class="col">
|
||||
<select class="form-select" formControlName="pdfEditorDefaultEditMode">
|
||||
<option [ngValue]="PdfEditorEditMode.Create" i18n>Create new document(s)</option>
|
||||
<option [ngValue]="PdfEditorEditMode.Update" i18n>Update existing document</option>
|
||||
<option [ngValue]="PdfEditorEditMode.Update" i18n>Add document version</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -84,7 +84,7 @@
|
||||
<input type="radio" class="btn-check" [(ngModel)]="editMode" [value]="PdfEditorEditMode.Update" id="editModeUpdate" name="editmode" [disabled]="hasSplit()">
|
||||
<label for="editModeUpdate" class="btn btn-outline-primary btn-sm">
|
||||
<i-bs name="pencil"></i-bs>
|
||||
<span class="form-check-label ms-2" i18n>Update existing document</span>
|
||||
<span class="form-check-label ms-2" i18n>Add document version</span>
|
||||
</label>
|
||||
</div>
|
||||
@if (editMode === PdfEditorEditMode.Create) {
|
||||
|
||||
@@ -65,6 +65,7 @@ import { TagService } from 'src/app/services/rest/tag.service'
|
||||
import { UserService } from 'src/app/services/rest/user.service'
|
||||
import { SettingsService } from 'src/app/services/settings.service'
|
||||
import { ToastService } from 'src/app/services/toast.service'
|
||||
import { WebsocketStatusService } from 'src/app/services/websocket-status.service'
|
||||
import { environment } from 'src/environments/environment'
|
||||
import { ConfirmDialogComponent } from '../common/confirm-dialog/confirm-dialog.component'
|
||||
import { PasswordRemovalConfirmDialogComponent } from '../common/confirm-dialog/password-removal-confirm-dialog/password-removal-confirm-dialog.component'
|
||||
@@ -83,9 +84,9 @@ const doc: Document = {
|
||||
storage_path: 31,
|
||||
tags: [41, 42, 43],
|
||||
content: 'text content',
|
||||
added: new Date('May 4, 2014 03:24:00'),
|
||||
created: new Date('May 4, 2014 03:24:00'),
|
||||
modified: new Date('May 4, 2014 03:24:00'),
|
||||
added: new Date('May 4, 2014 03:24:00').toISOString(),
|
||||
created: new Date('May 4, 2014 03:24:00').toISOString(),
|
||||
modified: new Date('May 4, 2014 03:24:00').toISOString(),
|
||||
archive_serial_number: null,
|
||||
original_file_name: 'file.pdf',
|
||||
owner: null,
|
||||
@@ -327,6 +328,29 @@ describe('DocumentDetailComponent', () => {
|
||||
expect(component.activeNavID).toEqual(component.DocumentDetailNavIDs.Notes)
|
||||
})
|
||||
|
||||
it('should switch from preview to details when pdf preview enters the DOM', fakeAsync(() => {
|
||||
component.nav = {
|
||||
activeId: component.DocumentDetailNavIDs.Preview,
|
||||
select: jest.fn(),
|
||||
} as any
|
||||
;(component as any).pdfPreview = {
|
||||
nativeElement: { offsetParent: {} },
|
||||
}
|
||||
|
||||
tick()
|
||||
expect(component.nav.select).toHaveBeenCalledWith(
|
||||
component.DocumentDetailNavIDs.Details
|
||||
)
|
||||
}))
|
||||
|
||||
it('should forward title key up value to titleSubject', () => {
|
||||
const subjectSpy = jest.spyOn(component.titleSubject, 'next')
|
||||
|
||||
component.titleKeyUp({ target: { value: 'Updated title' } })
|
||||
|
||||
expect(subjectSpy).toHaveBeenCalledWith('Updated title')
|
||||
})
|
||||
|
||||
it('should change url on tab switch', () => {
|
||||
initNormally()
|
||||
const navigateSpy = jest.spyOn(router, 'navigate')
|
||||
@@ -524,7 +548,7 @@ describe('DocumentDetailComponent', () => {
|
||||
jest.spyOn(documentService, 'get').mockReturnValue(
|
||||
of({
|
||||
...doc,
|
||||
modified: new Date('2024-01-02T00:00:00Z'),
|
||||
modified: '2024-01-02T00:00:00Z',
|
||||
duplicate_documents: updatedDuplicates,
|
||||
})
|
||||
)
|
||||
@@ -1386,17 +1410,21 @@ describe('DocumentDetailComponent', () => {
|
||||
expect(errorSpy).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should warn when open document does not match doc retrieved from backend on init', () => {
|
||||
it('should show incoming update modal when open local draft is older than backend on init', () => {
|
||||
let openModal: NgbModalRef
|
||||
modalService.activeInstances.subscribe((modals) => (openModal = modals[0]))
|
||||
const modalSpy = jest.spyOn(modalService, 'open')
|
||||
const openDoc = Object.assign({}, doc)
|
||||
const openDoc = Object.assign({}, doc, {
|
||||
__changedFields: ['title'],
|
||||
})
|
||||
// simulate a document being modified elsewhere and db updated
|
||||
doc.modified = new Date()
|
||||
const remoteDoc = Object.assign({}, doc, {
|
||||
modified: new Date(new Date(doc.modified).getTime() + 1000).toISOString(),
|
||||
})
|
||||
jest
|
||||
.spyOn(activatedRoute, 'paramMap', 'get')
|
||||
.mockReturnValue(of(convertToParamMap({ id: 3, section: 'details' })))
|
||||
jest.spyOn(documentService, 'get').mockReturnValueOnce(of(doc))
|
||||
jest.spyOn(documentService, 'get').mockReturnValueOnce(of(remoteDoc))
|
||||
jest.spyOn(openDocumentsService, 'getOpenDocument').mockReturnValue(openDoc)
|
||||
jest.spyOn(customFieldsService, 'listAll').mockReturnValue(
|
||||
of({
|
||||
@@ -1406,11 +1434,185 @@ describe('DocumentDetailComponent', () => {
|
||||
})
|
||||
)
|
||||
fixture.detectChanges() // calls ngOnInit
|
||||
expect(modalSpy).toHaveBeenCalledWith(ConfirmDialogComponent)
|
||||
const closeSpy = jest.spyOn(openModal, 'close')
|
||||
expect(modalSpy).toHaveBeenCalledWith(ConfirmDialogComponent, {
|
||||
backdrop: 'static',
|
||||
})
|
||||
const confirmDialog = openModal.componentInstance as ConfirmDialogComponent
|
||||
confirmDialog.confirmClicked.next(confirmDialog)
|
||||
expect(closeSpy).toHaveBeenCalled()
|
||||
expect(confirmDialog.messageBold).toContain('Document was updated at')
|
||||
})
|
||||
|
||||
it('should react to websocket document updated notifications', () => {
|
||||
initNormally()
|
||||
const updateMessage = {
|
||||
document_id: component.documentId,
|
||||
modified: '2026-02-17T00:00:00Z',
|
||||
owner_id: 1,
|
||||
}
|
||||
const handleSpy = jest
|
||||
.spyOn(component as any, 'handleIncomingDocumentUpdated')
|
||||
.mockImplementation(() => {})
|
||||
const websocketStatusService = TestBed.inject(WebsocketStatusService)
|
||||
|
||||
websocketStatusService.handleDocumentUpdated(updateMessage)
|
||||
|
||||
expect(handleSpy).toHaveBeenCalledWith(updateMessage)
|
||||
})
|
||||
|
||||
it('should queue incoming update while network is active and flush after', () => {
|
||||
initNormally()
|
||||
const loadSpy = jest.spyOn(component as any, 'loadDocument')
|
||||
const toastSpy = jest.spyOn(toastService, 'showInfo')
|
||||
|
||||
component.networkActive = true
|
||||
;(component as any).handleIncomingDocumentUpdated({
|
||||
document_id: component.documentId,
|
||||
modified: '2026-02-17T00:00:00Z',
|
||||
})
|
||||
|
||||
expect(loadSpy).not.toHaveBeenCalled()
|
||||
|
||||
component.networkActive = false
|
||||
;(component as any).flushPendingIncomingUpdate()
|
||||
|
||||
expect(loadSpy).toHaveBeenCalledWith(component.documentId, true)
|
||||
expect(toastSpy).toHaveBeenCalledWith(
|
||||
'Document reloaded with latest changes.'
|
||||
)
|
||||
})
|
||||
|
||||
it('should ignore queued incoming update matching local save modified', () => {
|
||||
initNormally()
|
||||
const loadSpy = jest.spyOn(component as any, 'loadDocument')
|
||||
const toastSpy = jest.spyOn(toastService, 'showInfo')
|
||||
|
||||
component.networkActive = true
|
||||
;(component as any).lastLocalSaveModified = '2026-02-17T00:00:00+00:00'
|
||||
;(component as any).handleIncomingDocumentUpdated({
|
||||
document_id: component.documentId,
|
||||
modified: '2026-02-17T00:00:00+00:00',
|
||||
})
|
||||
|
||||
component.networkActive = false
|
||||
;(component as any).flushPendingIncomingUpdate()
|
||||
|
||||
expect(loadSpy).not.toHaveBeenCalled()
|
||||
expect(toastSpy).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should clear pdf source if preview URL is empty', () => {
|
||||
component.pdfSource = { url: '/preview', password: 'secret' } as any
|
||||
component.previewUrl = null
|
||||
;(component as any).updatePdfSource()
|
||||
|
||||
expect(component.pdfSource).toEqual({ url: null, password: undefined })
|
||||
})
|
||||
|
||||
it('should close incoming update modal if one is open', () => {
|
||||
const modalRef = { close: jest.fn() } as unknown as NgbModalRef
|
||||
;(component as any).incomingUpdateModal = modalRef
|
||||
;(component as any).closeIncomingUpdateModal()
|
||||
|
||||
expect(modalRef.close).toHaveBeenCalled()
|
||||
expect((component as any).incomingUpdateModal).toBeNull()
|
||||
})
|
||||
|
||||
it('should reload remote version when incoming update modal is confirmed', async () => {
|
||||
let openModal: NgbModalRef
|
||||
modalService.activeInstances.subscribe((modals) => (openModal = modals[0]))
|
||||
const reloadSpy = jest
|
||||
.spyOn(component as any, 'reloadRemoteVersion')
|
||||
.mockImplementation(() => {})
|
||||
|
||||
;(component as any).showIncomingUpdateModal('2026-02-17T00:00:00Z')
|
||||
|
||||
const dialog = openModal.componentInstance as ConfirmDialogComponent
|
||||
dialog.confirmClicked.next()
|
||||
await openModal.result
|
||||
|
||||
expect(dialog.buttonsEnabled).toBe(false)
|
||||
expect(reloadSpy).toHaveBeenCalled()
|
||||
expect((component as any).incomingUpdateModal).toBeNull()
|
||||
})
|
||||
|
||||
it('should overwrite open document state when loading remote version with force', () => {
|
||||
const openDoc = Object.assign({}, doc, {
|
||||
title: 'Locally edited title',
|
||||
__changedFields: ['title'],
|
||||
})
|
||||
const remoteDoc = Object.assign({}, doc, {
|
||||
title: 'Remote title',
|
||||
modified: '2026-02-17T00:00:00Z',
|
||||
})
|
||||
jest.spyOn(documentService, 'get').mockReturnValue(of(remoteDoc))
|
||||
jest.spyOn(documentService, 'getMetadata').mockReturnValue(
|
||||
of({
|
||||
has_archive_version: false,
|
||||
original_mime_type: 'application/pdf',
|
||||
})
|
||||
)
|
||||
jest.spyOn(documentService, 'getSuggestions').mockReturnValue(
|
||||
of({
|
||||
suggested_tags: [],
|
||||
suggested_document_types: [],
|
||||
suggested_correspondents: [],
|
||||
})
|
||||
)
|
||||
jest.spyOn(openDocumentsService, 'getOpenDocument').mockReturnValue(openDoc)
|
||||
const setDirtySpy = jest.spyOn(openDocumentsService, 'setDirty')
|
||||
const saveSpy = jest.spyOn(openDocumentsService, 'save')
|
||||
|
||||
;(component as any).loadDocument(doc.id, true)
|
||||
|
||||
expect(openDoc.title).toEqual('Remote title')
|
||||
expect(openDoc.__changedFields).toEqual([])
|
||||
expect(setDirtySpy).toHaveBeenCalledWith(openDoc, false)
|
||||
expect(saveSpy).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should ignore incoming update for a different document id', () => {
|
||||
initNormally()
|
||||
const loadSpy = jest.spyOn(component as any, 'loadDocument')
|
||||
|
||||
;(component as any).handleIncomingDocumentUpdated({
|
||||
document_id: component.documentId + 1,
|
||||
modified: '2026-02-17T00:00:00Z',
|
||||
})
|
||||
|
||||
expect(loadSpy).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should show incoming update modal when local document has unsaved edits', () => {
|
||||
initNormally()
|
||||
jest.spyOn(openDocumentsService, 'isDirty').mockReturnValue(true)
|
||||
const modalSpy = jest
|
||||
.spyOn(component as any, 'showIncomingUpdateModal')
|
||||
.mockImplementation(() => {})
|
||||
|
||||
;(component as any).handleIncomingDocumentUpdated({
|
||||
document_id: component.documentId,
|
||||
modified: '2026-02-17T00:00:00Z',
|
||||
})
|
||||
|
||||
expect(modalSpy).toHaveBeenCalledWith('2026-02-17T00:00:00Z')
|
||||
})
|
||||
|
||||
it('should reload current document and show toast when reloading remote version', () => {
|
||||
component.documentId = doc.id
|
||||
const closeModalSpy = jest
|
||||
.spyOn(component as any, 'closeIncomingUpdateModal')
|
||||
.mockImplementation(() => {})
|
||||
const loadSpy = jest
|
||||
.spyOn(component as any, 'loadDocument')
|
||||
.mockImplementation(() => {})
|
||||
const notifySpy = jest.spyOn(component.docChangeNotifier, 'next')
|
||||
const toastSpy = jest.spyOn(toastService, 'showInfo')
|
||||
|
||||
;(component as any).reloadRemoteVersion()
|
||||
|
||||
expect(closeModalSpy).toHaveBeenCalled()
|
||||
expect(notifySpy).toHaveBeenCalledWith(doc.id)
|
||||
expect(loadSpy).toHaveBeenCalledWith(doc.id, true)
|
||||
expect(toastSpy).toHaveBeenCalledWith('Document reloaded.')
|
||||
})
|
||||
|
||||
it('should change preview element by render type', () => {
|
||||
@@ -1721,6 +1923,14 @@ describe('DocumentDetailComponent', () => {
|
||||
expect(component.createDisabled(DataType.Tag)).toBeFalsy()
|
||||
})
|
||||
|
||||
it('should expose add permission via userCanAdd getter', () => {
|
||||
currentUserCan = true
|
||||
expect(component.userCanAdd).toBeTruthy()
|
||||
|
||||
currentUserCan = false
|
||||
expect(component.userCanAdd).toBeFalsy()
|
||||
})
|
||||
|
||||
it('should call tryRenderTiff when no archive and file is tiff', () => {
|
||||
initNormally()
|
||||
const tiffRenderSpy = jest.spyOn(
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
NgbDateStruct,
|
||||
NgbDropdownModule,
|
||||
NgbModal,
|
||||
NgbModalRef,
|
||||
NgbNav,
|
||||
NgbNavChangeEvent,
|
||||
NgbNavModule,
|
||||
@@ -80,6 +81,7 @@ import { TagService } from 'src/app/services/rest/tag.service'
|
||||
import { UserService } from 'src/app/services/rest/user.service'
|
||||
import { SettingsService } from 'src/app/services/settings.service'
|
||||
import { ToastService } from 'src/app/services/toast.service'
|
||||
import { WebsocketStatusService } from 'src/app/services/websocket-status.service'
|
||||
import { getFilenameFromContentDisposition } from 'src/app/utils/http'
|
||||
import { ISODateAdapter } from 'src/app/utils/ngb-iso-date-adapter'
|
||||
import * as UTIF from 'utif'
|
||||
@@ -143,6 +145,11 @@ enum ContentRenderType {
|
||||
TIFF = 'tiff',
|
||||
}
|
||||
|
||||
interface IncomingDocumentUpdate {
|
||||
document_id: number
|
||||
modified: string
|
||||
}
|
||||
|
||||
@Component({
|
||||
selector: 'pngx-document-detail',
|
||||
templateUrl: './document-detail.component.html',
|
||||
@@ -208,6 +215,7 @@ export class DocumentDetailComponent
|
||||
private componentRouterService = inject(ComponentRouterService)
|
||||
private deviceDetectorService = inject(DeviceDetectorService)
|
||||
private savedViewService = inject(SavedViewService)
|
||||
private readonly websocketStatusService = inject(WebsocketStatusService)
|
||||
|
||||
@ViewChild('inputTitle')
|
||||
titleInput: TextComponent
|
||||
@@ -267,6 +275,9 @@ export class DocumentDetailComponent
|
||||
isDirty$: Observable<boolean>
|
||||
unsubscribeNotifier: Subject<any> = new Subject()
|
||||
docChangeNotifier: Subject<any> = new Subject()
|
||||
private incomingUpdateModal: NgbModalRef
|
||||
private pendingIncomingUpdate: IncomingDocumentUpdate
|
||||
private lastLocalSaveModified: string | null = null
|
||||
|
||||
requiresPassword: boolean = false
|
||||
password: string
|
||||
@@ -475,9 +486,12 @@ export class DocumentDetailComponent
|
||||
)
|
||||
}
|
||||
|
||||
private loadDocument(documentId: number): void {
|
||||
private loadDocument(documentId: number, forceRemote: boolean = false): void {
|
||||
let redirectedToRoot = false
|
||||
this.closeIncomingUpdateModal()
|
||||
this.pendingIncomingUpdate = null
|
||||
this.selectedVersionId = documentId
|
||||
this.lastLocalSaveModified = null
|
||||
this.previewUrl = this.documentsService.getPreviewUrl(
|
||||
this.selectedVersionId
|
||||
)
|
||||
@@ -545,21 +559,25 @@ export class DocumentDetailComponent
|
||||
openDocument.duplicate_documents = doc.duplicate_documents
|
||||
this.openDocumentService.save()
|
||||
}
|
||||
const useDoc = openDocument || doc
|
||||
if (openDocument) {
|
||||
if (
|
||||
new Date(doc.modified) > new Date(openDocument.modified) &&
|
||||
!this.modalService.hasOpenModals()
|
||||
) {
|
||||
const modal = this.modalService.open(ConfirmDialogComponent)
|
||||
modal.componentInstance.title = $localize`Document changes detected`
|
||||
modal.componentInstance.messageBold = $localize`The version of this document in your browser session appears older than the existing version.`
|
||||
modal.componentInstance.message = $localize`Saving the document here may overwrite other changes that were made. To restore the existing version, discard your changes or close the document.`
|
||||
modal.componentInstance.cancelBtnClass = 'visually-hidden'
|
||||
modal.componentInstance.btnCaption = $localize`Ok`
|
||||
modal.componentInstance.confirmClicked.subscribe(() =>
|
||||
modal.close()
|
||||
)
|
||||
let useDoc = openDocument || doc
|
||||
if (openDocument && forceRemote) {
|
||||
Object.assign(openDocument, doc)
|
||||
openDocument.__changedFields = []
|
||||
this.openDocumentService.setDirty(openDocument, false)
|
||||
this.openDocumentService.save()
|
||||
useDoc = openDocument
|
||||
} else if (openDocument) {
|
||||
if (new Date(doc.modified) > new Date(openDocument.modified)) {
|
||||
if (this.hasLocalEdits(openDocument)) {
|
||||
this.showIncomingUpdateModal(doc.modified)
|
||||
} else {
|
||||
// No local edits to preserve, so keep the tab in sync automatically.
|
||||
Object.assign(openDocument, doc)
|
||||
openDocument.__changedFields = []
|
||||
this.openDocumentService.setDirty(openDocument, false)
|
||||
this.openDocumentService.save()
|
||||
useDoc = openDocument
|
||||
}
|
||||
}
|
||||
} else {
|
||||
this.openDocumentService
|
||||
@@ -590,6 +608,98 @@ export class DocumentDetailComponent
|
||||
})
|
||||
}
|
||||
|
||||
private hasLocalEdits(doc: Document): boolean {
|
||||
return (
|
||||
this.openDocumentService.isDirty(doc) || !!doc.__changedFields?.length
|
||||
)
|
||||
}
|
||||
|
||||
private showIncomingUpdateModal(modified: string): void {
|
||||
if (this.incomingUpdateModal) return
|
||||
|
||||
const modal = this.modalService.open(ConfirmDialogComponent, {
|
||||
backdrop: 'static',
|
||||
})
|
||||
this.incomingUpdateModal = modal
|
||||
|
||||
let formattedModified = null
|
||||
const parsed = new Date(modified)
|
||||
formattedModified = parsed.toLocaleString()
|
||||
|
||||
modal.componentInstance.title = $localize`Document was updated`
|
||||
modal.componentInstance.messageBold = $localize`Document was updated at ${formattedModified}.`
|
||||
modal.componentInstance.message = $localize`Reload to discard your local unsaved edits and load the latest remote version.`
|
||||
modal.componentInstance.btnClass = 'btn-warning'
|
||||
modal.componentInstance.btnCaption = $localize`Reload`
|
||||
modal.componentInstance.cancelBtnCaption = $localize`Dismiss`
|
||||
|
||||
modal.componentInstance.confirmClicked.pipe(first()).subscribe(() => {
|
||||
modal.componentInstance.buttonsEnabled = false
|
||||
modal.close()
|
||||
this.reloadRemoteVersion()
|
||||
})
|
||||
modal.result.finally(() => {
|
||||
this.incomingUpdateModal = null
|
||||
})
|
||||
}
|
||||
|
||||
private closeIncomingUpdateModal() {
|
||||
if (!this.incomingUpdateModal) return
|
||||
this.incomingUpdateModal.close()
|
||||
this.incomingUpdateModal = null
|
||||
}
|
||||
|
||||
private flushPendingIncomingUpdate() {
|
||||
if (!this.pendingIncomingUpdate || this.networkActive) return
|
||||
const pendingUpdate = this.pendingIncomingUpdate
|
||||
this.pendingIncomingUpdate = null
|
||||
this.handleIncomingDocumentUpdated(pendingUpdate)
|
||||
}
|
||||
|
||||
private handleIncomingDocumentUpdated(data: IncomingDocumentUpdate): void {
|
||||
if (
|
||||
!this.documentId ||
|
||||
!this.document ||
|
||||
data.document_id !== this.documentId
|
||||
)
|
||||
return
|
||||
if (this.networkActive) {
|
||||
this.pendingIncomingUpdate = data
|
||||
return
|
||||
}
|
||||
// If modified timestamp of the incoming update is the same as the last local save,
|
||||
// we assume this update is from our own save and dont notify
|
||||
const incomingModified = data.modified
|
||||
if (
|
||||
incomingModified &&
|
||||
this.lastLocalSaveModified &&
|
||||
incomingModified === this.lastLocalSaveModified
|
||||
) {
|
||||
this.lastLocalSaveModified = null
|
||||
return
|
||||
}
|
||||
this.lastLocalSaveModified = null
|
||||
|
||||
if (this.openDocumentService.isDirty(this.document)) {
|
||||
this.showIncomingUpdateModal(data.modified)
|
||||
} else {
|
||||
this.docChangeNotifier.next(this.documentId)
|
||||
this.loadDocument(this.documentId, true)
|
||||
this.toastService.showInfo(
|
||||
$localize`Document reloaded with latest changes.`
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private reloadRemoteVersion() {
|
||||
if (!this.documentId) return
|
||||
|
||||
this.closeIncomingUpdateModal()
|
||||
this.docChangeNotifier.next(this.documentId)
|
||||
this.loadDocument(this.documentId, true)
|
||||
this.toastService.showInfo($localize`Document reloaded.`)
|
||||
}
|
||||
|
||||
ngOnInit(): void {
|
||||
this.setZoom(
|
||||
this.settings.get(SETTINGS_KEYS.PDF_VIEWER_ZOOM_SETTING) as PdfZoomScale
|
||||
@@ -648,6 +758,11 @@ export class DocumentDetailComponent
|
||||
|
||||
this.getCustomFields()
|
||||
|
||||
this.websocketStatusService
|
||||
.onDocumentUpdated()
|
||||
.pipe(takeUntil(this.unsubscribeNotifier))
|
||||
.subscribe((data) => this.handleIncomingDocumentUpdated(data))
|
||||
|
||||
this.route.paramMap
|
||||
.pipe(
|
||||
filter(
|
||||
@@ -1033,6 +1148,7 @@ export class DocumentDetailComponent
|
||||
)
|
||||
.subscribe({
|
||||
next: (doc) => {
|
||||
this.closeIncomingUpdateModal()
|
||||
Object.assign(this.document, doc)
|
||||
doc['permissions_form'] = {
|
||||
owner: doc.owner,
|
||||
@@ -1079,6 +1195,8 @@ export class DocumentDetailComponent
|
||||
.pipe(first())
|
||||
.subscribe({
|
||||
next: (docValues) => {
|
||||
this.closeIncomingUpdateModal()
|
||||
this.lastLocalSaveModified = docValues.modified ?? null
|
||||
// in case data changed while saving eg removing inbox_tags
|
||||
this.documentForm.patchValue(docValues)
|
||||
const newValues = Object.assign({}, this.documentForm.value)
|
||||
@@ -1093,16 +1211,19 @@ export class DocumentDetailComponent
|
||||
this.networkActive = false
|
||||
this.error = null
|
||||
if (close) {
|
||||
this.pendingIncomingUpdate = null
|
||||
this.close(() =>
|
||||
this.openDocumentService.refreshDocument(this.documentId)
|
||||
)
|
||||
} else {
|
||||
this.openDocumentService.refreshDocument(this.documentId)
|
||||
this.flushPendingIncomingUpdate()
|
||||
}
|
||||
this.savedViewService.maybeRefreshDocumentCounts()
|
||||
},
|
||||
error: (error) => {
|
||||
this.networkActive = false
|
||||
this.lastLocalSaveModified = null
|
||||
const canEdit =
|
||||
this.permissionsService.currentUserHasObjectPermissions(
|
||||
PermissionAction.Change,
|
||||
@@ -1122,6 +1243,7 @@ export class DocumentDetailComponent
|
||||
error
|
||||
)
|
||||
}
|
||||
this.flushPendingIncomingUpdate()
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -1158,8 +1280,11 @@ export class DocumentDetailComponent
|
||||
.pipe(first())
|
||||
.subscribe({
|
||||
next: ({ updateResult, nextDocId, closeResult }) => {
|
||||
this.closeIncomingUpdateModal()
|
||||
this.error = null
|
||||
this.networkActive = false
|
||||
this.pendingIncomingUpdate = null
|
||||
this.lastLocalSaveModified = null
|
||||
if (closeResult && updateResult && nextDocId) {
|
||||
this.router.navigate(['documents', nextDocId])
|
||||
this.titleInput?.focus()
|
||||
@@ -1167,8 +1292,10 @@ export class DocumentDetailComponent
|
||||
},
|
||||
error: (error) => {
|
||||
this.networkActive = false
|
||||
this.lastLocalSaveModified = null
|
||||
this.error = error.error
|
||||
this.toastService.showError($localize`Error saving document`, error)
|
||||
this.flushPendingIncomingUpdate()
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -1254,7 +1381,7 @@ export class DocumentDetailComponent
|
||||
.subscribe({
|
||||
next: () => {
|
||||
this.toastService.showInfo(
|
||||
$localize`Reprocess operation for "${this.document.title}" will begin in the background. Close and re-open or reload this document after the operation has completed to see new content.`
|
||||
$localize`Reprocess operation for "${this.document.title}" will begin in the background.`
|
||||
)
|
||||
if (modal) {
|
||||
modal.close()
|
||||
|
||||
@@ -830,7 +830,7 @@ export class BulkEditorComponent
|
||||
})
|
||||
const rotateDialog = modal.componentInstance as RotateConfirmDialogComponent
|
||||
rotateDialog.title = $localize`Rotate confirm`
|
||||
rotateDialog.messageBold = $localize`This operation will permanently rotate the original version of ${this.list.selected.size} document(s).`
|
||||
rotateDialog.messageBold = $localize`This operation will add rotated versions of the ${this.list.selected.size} document(s).`
|
||||
rotateDialog.btnClass = 'btn-danger'
|
||||
rotateDialog.btnCaption = $localize`Proceed`
|
||||
rotateDialog.documentID = Array.from(this.list.selected)[0]
|
||||
|
||||
@@ -128,15 +128,15 @@ export interface Document extends ObjectWithPermissions {
|
||||
checksum?: string
|
||||
|
||||
// UTC
|
||||
created?: Date
|
||||
created?: string // ISO string
|
||||
|
||||
modified?: Date
|
||||
modified?: string // ISO string
|
||||
|
||||
added?: Date
|
||||
added?: string // ISO string
|
||||
|
||||
mime_type?: string
|
||||
|
||||
deleted_at?: Date
|
||||
deleted_at?: string // ISO string
|
||||
|
||||
original_file_name?: string
|
||||
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
export interface WebsocketDocumentUpdatedMessage {
|
||||
document_id: number
|
||||
modified: string
|
||||
owner_id?: number
|
||||
users_can_view?: number[]
|
||||
groups_can_view?: number[]
|
||||
}
|
||||
@@ -416,4 +416,42 @@ describe('ConsumerStatusService', () => {
|
||||
websocketStatusService.disconnect()
|
||||
expect(deleted).toBeTruthy()
|
||||
})
|
||||
|
||||
it('should trigger updated subject on document updated', () => {
|
||||
let updated = false
|
||||
websocketStatusService.onDocumentUpdated().subscribe((data) => {
|
||||
updated = true
|
||||
expect(data.document_id).toEqual(12)
|
||||
})
|
||||
|
||||
websocketStatusService.connect()
|
||||
server.send({
|
||||
type: WebsocketStatusType.DOCUMENT_UPDATED,
|
||||
data: {
|
||||
document_id: 12,
|
||||
modified: '2026-02-17T00:00:00Z',
|
||||
owner_id: 1,
|
||||
},
|
||||
})
|
||||
|
||||
websocketStatusService.disconnect()
|
||||
expect(updated).toBeTruthy()
|
||||
})
|
||||
|
||||
it('should ignore document updated events the user cannot view', () => {
|
||||
let updated = false
|
||||
websocketStatusService.onDocumentUpdated().subscribe(() => {
|
||||
updated = true
|
||||
})
|
||||
|
||||
websocketStatusService.handleDocumentUpdated({
|
||||
document_id: 12,
|
||||
modified: '2026-02-17T00:00:00Z',
|
||||
owner_id: 2,
|
||||
users_can_view: [],
|
||||
groups_can_view: [],
|
||||
})
|
||||
|
||||
expect(updated).toBeFalsy()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -2,6 +2,7 @@ import { Injectable, inject } from '@angular/core'
|
||||
import { Subject } from 'rxjs'
|
||||
import { environment } from 'src/environments/environment'
|
||||
import { User } from '../data/user'
|
||||
import { WebsocketDocumentUpdatedMessage } from '../data/websocket-document-updated-message'
|
||||
import { WebsocketDocumentsDeletedMessage } from '../data/websocket-documents-deleted-message'
|
||||
import { WebsocketProgressMessage } from '../data/websocket-progress-message'
|
||||
import { SettingsService } from './settings.service'
|
||||
@@ -9,6 +10,7 @@ import { SettingsService } from './settings.service'
|
||||
export enum WebsocketStatusType {
|
||||
STATUS_UPDATE = 'status_update',
|
||||
DOCUMENTS_DELETED = 'documents_deleted',
|
||||
DOCUMENT_UPDATED = 'document_updated',
|
||||
}
|
||||
|
||||
// see ProgressStatusOptions in src/documents/plugins/helpers.py
|
||||
@@ -100,17 +102,20 @@ export enum UploadState {
|
||||
providedIn: 'root',
|
||||
})
|
||||
export class WebsocketStatusService {
|
||||
private settingsService = inject(SettingsService)
|
||||
private readonly settingsService = inject(SettingsService)
|
||||
|
||||
private statusWebSocket: WebSocket
|
||||
|
||||
private consumerStatus: FileStatus[] = []
|
||||
|
||||
private documentDetectedSubject = new Subject<FileStatus>()
|
||||
private documentConsumptionFinishedSubject = new Subject<FileStatus>()
|
||||
private documentConsumptionFailedSubject = new Subject<FileStatus>()
|
||||
private documentDeletedSubject = new Subject<boolean>()
|
||||
private connectionStatusSubject = new Subject<boolean>()
|
||||
private readonly documentDetectedSubject = new Subject<FileStatus>()
|
||||
private readonly documentConsumptionFinishedSubject =
|
||||
new Subject<FileStatus>()
|
||||
private readonly documentConsumptionFailedSubject = new Subject<FileStatus>()
|
||||
private readonly documentDeletedSubject = new Subject<boolean>()
|
||||
private readonly documentUpdatedSubject =
|
||||
new Subject<WebsocketDocumentUpdatedMessage>()
|
||||
private readonly connectionStatusSubject = new Subject<boolean>()
|
||||
|
||||
private get(taskId: string, filename?: string) {
|
||||
let status =
|
||||
@@ -176,7 +181,10 @@ export class WebsocketStatusService {
|
||||
data: messageData,
|
||||
}: {
|
||||
type: WebsocketStatusType
|
||||
data: WebsocketProgressMessage | WebsocketDocumentsDeletedMessage
|
||||
data:
|
||||
| WebsocketProgressMessage
|
||||
| WebsocketDocumentsDeletedMessage
|
||||
| WebsocketDocumentUpdatedMessage
|
||||
} = JSON.parse(ev.data)
|
||||
|
||||
switch (type) {
|
||||
@@ -184,6 +192,12 @@ export class WebsocketStatusService {
|
||||
this.documentDeletedSubject.next(true)
|
||||
break
|
||||
|
||||
case WebsocketStatusType.DOCUMENT_UPDATED:
|
||||
this.handleDocumentUpdated(
|
||||
messageData as WebsocketDocumentUpdatedMessage
|
||||
)
|
||||
break
|
||||
|
||||
case WebsocketStatusType.STATUS_UPDATE:
|
||||
this.handleProgressUpdate(messageData as WebsocketProgressMessage)
|
||||
break
|
||||
@@ -191,7 +205,11 @@ export class WebsocketStatusService {
|
||||
}
|
||||
}
|
||||
|
||||
private canViewMessage(messageData: WebsocketProgressMessage): boolean {
|
||||
private canViewMessage(messageData: {
|
||||
owner_id?: number
|
||||
users_can_view?: number[]
|
||||
groups_can_view?: number[]
|
||||
}): boolean {
|
||||
// see paperless.consumers.StatusConsumer._can_view
|
||||
const user: User = this.settingsService.currentUser
|
||||
return (
|
||||
@@ -251,6 +269,15 @@ export class WebsocketStatusService {
|
||||
}
|
||||
}
|
||||
|
||||
handleDocumentUpdated(messageData: WebsocketDocumentUpdatedMessage) {
|
||||
// fallback if backend didn't restrict message
|
||||
if (!this.canViewMessage(messageData)) {
|
||||
return
|
||||
}
|
||||
|
||||
this.documentUpdatedSubject.next(messageData)
|
||||
}
|
||||
|
||||
fail(status: FileStatus, message: string) {
|
||||
status.message = message
|
||||
status.phase = FileStatusPhase.FAILED
|
||||
@@ -304,6 +331,10 @@ export class WebsocketStatusService {
|
||||
return this.documentDeletedSubject
|
||||
}
|
||||
|
||||
onDocumentUpdated() {
|
||||
return this.documentUpdatedSubject
|
||||
}
|
||||
|
||||
onConnectionStatus() {
|
||||
return this.connectionStatusSubject.asObservable()
|
||||
}
|
||||
|
||||
@@ -15,6 +15,7 @@ class DocumentsConfig(AppConfig):
|
||||
from documents.signals.handlers import add_to_index
|
||||
from documents.signals.handlers import run_workflows_added
|
||||
from documents.signals.handlers import run_workflows_updated
|
||||
from documents.signals.handlers import send_websocket_document_updated
|
||||
from documents.signals.handlers import set_correspondent
|
||||
from documents.signals.handlers import set_document_type
|
||||
from documents.signals.handlers import set_storage_path
|
||||
@@ -29,6 +30,7 @@ class DocumentsConfig(AppConfig):
|
||||
document_consumption_finished.connect(run_workflows_added)
|
||||
document_consumption_finished.connect(add_or_update_document_in_llm_index)
|
||||
document_updated.connect(run_workflows_updated)
|
||||
document_updated.connect(send_websocket_document_updated)
|
||||
|
||||
import documents.schema # noqa: F401
|
||||
|
||||
|
||||
@@ -6,11 +6,14 @@ Provides automatic progress bar and multiprocessing support with minimal boilerp
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import os
|
||||
from collections.abc import Callable
|
||||
from collections.abc import Iterable
|
||||
from collections.abc import Sized
|
||||
from concurrent.futures import ProcessPoolExecutor
|
||||
from concurrent.futures import as_completed
|
||||
from contextlib import contextmanager
|
||||
from dataclasses import dataclass
|
||||
from typing import TYPE_CHECKING
|
||||
from typing import Any
|
||||
@@ -22,7 +25,11 @@ from django import db
|
||||
from django.core.management import CommandError
|
||||
from django.db.models import QuerySet
|
||||
from django_rich.management import RichCommand
|
||||
from rich import box
|
||||
from rich.console import Console
|
||||
from rich.console import Group
|
||||
from rich.console import RenderableType
|
||||
from rich.live import Live
|
||||
from rich.progress import BarColumn
|
||||
from rich.progress import MofNCompleteColumn
|
||||
from rich.progress import Progress
|
||||
@@ -30,11 +37,11 @@ from rich.progress import SpinnerColumn
|
||||
from rich.progress import TextColumn
|
||||
from rich.progress import TimeElapsedColumn
|
||||
from rich.progress import TimeRemainingColumn
|
||||
from rich.table import Table
|
||||
from rich.text import Text
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Callable
|
||||
from collections.abc import Generator
|
||||
from collections.abc import Iterable
|
||||
from collections.abc import Sequence
|
||||
|
||||
from django.core.management import CommandParser
|
||||
@@ -43,6 +50,78 @@ T = TypeVar("T")
|
||||
R = TypeVar("R")
|
||||
|
||||
|
||||
@dataclass(slots=True, frozen=True)
|
||||
class _BufferedRecord:
|
||||
level: int
|
||||
name: str
|
||||
message: str
|
||||
|
||||
|
||||
class BufferingLogHandler(logging.Handler):
|
||||
"""Captures log records during a command run for deferred rendering.
|
||||
|
||||
Attach to a logger before a long operation and call ``render()``
|
||||
afterwards to emit the buffered records via Rich, optionally filtered
|
||||
by minimum level.
|
||||
"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
super().__init__()
|
||||
self._records: list[_BufferedRecord] = []
|
||||
|
||||
def emit(self, record: logging.LogRecord) -> None:
|
||||
self._records.append(
|
||||
_BufferedRecord(
|
||||
level=record.levelno,
|
||||
name=record.name,
|
||||
message=self.format(record),
|
||||
),
|
||||
)
|
||||
|
||||
def render(
|
||||
self,
|
||||
console: Console,
|
||||
*,
|
||||
min_level: int = logging.DEBUG,
|
||||
title: str = "Log Output",
|
||||
) -> None:
|
||||
records = [r for r in self._records if r.level >= min_level]
|
||||
if not records:
|
||||
return
|
||||
|
||||
table = Table(
|
||||
title=title,
|
||||
show_header=True,
|
||||
header_style="bold",
|
||||
show_lines=False,
|
||||
box=box.SIMPLE,
|
||||
)
|
||||
table.add_column("Level", style="bold", width=8)
|
||||
table.add_column("Logger", style="dim")
|
||||
table.add_column("Message", no_wrap=False)
|
||||
|
||||
_level_styles: dict[int, str] = {
|
||||
logging.DEBUG: "dim",
|
||||
logging.INFO: "cyan",
|
||||
logging.WARNING: "yellow",
|
||||
logging.ERROR: "red",
|
||||
logging.CRITICAL: "bold red",
|
||||
}
|
||||
|
||||
for record in records:
|
||||
style = _level_styles.get(record.level, "")
|
||||
table.add_row(
|
||||
Text(logging.getLevelName(record.level), style=style),
|
||||
record.name,
|
||||
record.message,
|
||||
)
|
||||
|
||||
console.print(table)
|
||||
|
||||
def clear(self) -> None:
|
||||
self._records.clear()
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class ProcessResult(Generic[T, R]):
|
||||
"""
|
||||
@@ -91,6 +170,23 @@ class PaperlessCommand(RichCommand):
|
||||
for result in self.process_parallel(process_doc, ids):
|
||||
if result.error:
|
||||
self.console.print(f"[red]Failed: {result.error}[/red]")
|
||||
|
||||
class Command(PaperlessCommand):
|
||||
help = "Import documents with live stats"
|
||||
|
||||
def handle(self, *args, **options):
|
||||
stats = ImportStats()
|
||||
|
||||
def render_stats() -> Table:
|
||||
... # build Rich Table from stats
|
||||
|
||||
for item in self.track_with_stats(
|
||||
items,
|
||||
description="Importing...",
|
||||
stats_renderer=render_stats,
|
||||
):
|
||||
result = import_item(item)
|
||||
stats.imported += 1
|
||||
"""
|
||||
|
||||
supports_progress_bar: ClassVar[bool] = True
|
||||
@@ -128,13 +224,11 @@ class PaperlessCommand(RichCommand):
|
||||
This is called by Django's command infrastructure after argument parsing
|
||||
but before handle(). We use it to set instance attributes from options.
|
||||
"""
|
||||
# Set progress bar state
|
||||
if self.supports_progress_bar:
|
||||
self.no_progress_bar = options.get("no_progress_bar", False)
|
||||
else:
|
||||
self.no_progress_bar = True
|
||||
|
||||
# Set multiprocessing state
|
||||
if self.supports_multiprocessing:
|
||||
self.process_count = options.get("processes", 1)
|
||||
if self.process_count < 1:
|
||||
@@ -144,9 +238,69 @@ class PaperlessCommand(RichCommand):
|
||||
|
||||
return super().execute(*args, **options)
|
||||
|
||||
@contextmanager
|
||||
def buffered_logging(
|
||||
self,
|
||||
*logger_names: str,
|
||||
level: int = logging.DEBUG,
|
||||
) -> Generator[BufferingLogHandler, None, None]:
|
||||
"""Context manager that captures log output from named loggers.
|
||||
|
||||
Installs a ``BufferingLogHandler`` on each named logger for the
|
||||
duration of the block, suppressing propagation to avoid interleaving
|
||||
with the Rich live display. The handler is removed on exit regardless
|
||||
of whether an exception occurred.
|
||||
|
||||
Usage::
|
||||
|
||||
with self.buffered_logging("paperless", "documents") as log_buf:
|
||||
# ... run progress loop ...
|
||||
if options["verbose"]:
|
||||
log_buf.render(self.console)
|
||||
"""
|
||||
handler = BufferingLogHandler()
|
||||
handler.setFormatter(logging.Formatter("%(message)s"))
|
||||
|
||||
loggers: list[logging.Logger] = []
|
||||
original_propagate: dict[str, bool] = {}
|
||||
|
||||
for name in logger_names:
|
||||
log = logging.getLogger(name)
|
||||
log.addHandler(handler)
|
||||
original_propagate[name] = log.propagate
|
||||
log.propagate = False
|
||||
loggers.append(log)
|
||||
|
||||
try:
|
||||
yield handler
|
||||
finally:
|
||||
for log in loggers:
|
||||
log.removeHandler(handler)
|
||||
log.propagate = original_propagate[log.name]
|
||||
|
||||
@staticmethod
|
||||
def _progress_columns() -> tuple[Any, ...]:
|
||||
"""
|
||||
Return the standard set of progress bar columns.
|
||||
|
||||
Extracted so both _create_progress (standalone) and track_with_stats
|
||||
(inside Live) use identical column configuration without duplication.
|
||||
"""
|
||||
return (
|
||||
SpinnerColumn(),
|
||||
TextColumn("[progress.description]{task.description}"),
|
||||
BarColumn(),
|
||||
MofNCompleteColumn(),
|
||||
TimeElapsedColumn(),
|
||||
TimeRemainingColumn(),
|
||||
)
|
||||
|
||||
def _create_progress(self, description: str) -> Progress:
|
||||
"""
|
||||
Create a configured Progress instance.
|
||||
Create a standalone Progress instance with its own stderr Console.
|
||||
|
||||
Use this for track(). For track_with_stats(), Progress is created
|
||||
directly inside a Live context instead.
|
||||
|
||||
Progress output is directed to stderr to match the convention that
|
||||
progress bars are transient UI feedback, not command output. This
|
||||
@@ -161,12 +315,7 @@ class PaperlessCommand(RichCommand):
|
||||
A Progress instance configured with appropriate columns.
|
||||
"""
|
||||
return Progress(
|
||||
SpinnerColumn(),
|
||||
TextColumn("[progress.description]{task.description}"),
|
||||
BarColumn(),
|
||||
MofNCompleteColumn(),
|
||||
TimeElapsedColumn(),
|
||||
TimeRemainingColumn(),
|
||||
*self._progress_columns(),
|
||||
console=Console(stderr=True),
|
||||
transient=False,
|
||||
)
|
||||
@@ -222,7 +371,6 @@ class PaperlessCommand(RichCommand):
|
||||
yield from iterable
|
||||
return
|
||||
|
||||
# Attempt to determine total if not provided
|
||||
if total is None:
|
||||
total = self._get_iterable_length(iterable)
|
||||
|
||||
@@ -232,6 +380,87 @@ class PaperlessCommand(RichCommand):
|
||||
yield item
|
||||
progress.advance(task_id)
|
||||
|
||||
def track_with_stats(
|
||||
self,
|
||||
iterable: Iterable[T],
|
||||
*,
|
||||
description: str = "Processing...",
|
||||
stats_renderer: Callable[[], RenderableType],
|
||||
total: int | None = None,
|
||||
) -> Generator[T, None, None]:
|
||||
"""
|
||||
Iterate over items with a progress bar and a live-updating stats display.
|
||||
|
||||
The progress bar and stats renderable are combined in a single Live
|
||||
context, so the stats panel re-renders in place below the progress bar
|
||||
after each item is processed.
|
||||
|
||||
Respects --no-progress-bar flag. When disabled, yields items without
|
||||
any display (stats are still updated by the caller's loop body, so
|
||||
they will be accurate for any post-loop summary the caller prints).
|
||||
|
||||
Args:
|
||||
iterable: The items to iterate over.
|
||||
description: Text to display alongside the progress bar.
|
||||
stats_renderer: Zero-argument callable that returns a Rich
|
||||
renderable. Called after each item to refresh the display.
|
||||
The caller typically closes over a mutable dataclass and
|
||||
rebuilds a Table from it on each call.
|
||||
total: Total number of items. If None, attempts to determine
|
||||
automatically via .count() (for querysets) or len().
|
||||
|
||||
Yields:
|
||||
Items from the iterable.
|
||||
|
||||
Example:
|
||||
@dataclass
|
||||
class Stats:
|
||||
processed: int = 0
|
||||
failed: int = 0
|
||||
|
||||
stats = Stats()
|
||||
|
||||
def render_stats() -> Table:
|
||||
table = Table(box=None)
|
||||
table.add_column("Processed")
|
||||
table.add_column("Failed")
|
||||
table.add_row(str(stats.processed), str(stats.failed))
|
||||
return table
|
||||
|
||||
for item in self.track_with_stats(
|
||||
items,
|
||||
description="Importing...",
|
||||
stats_renderer=render_stats,
|
||||
):
|
||||
try:
|
||||
import_item(item)
|
||||
stats.processed += 1
|
||||
except Exception:
|
||||
stats.failed += 1
|
||||
"""
|
||||
if self.no_progress_bar:
|
||||
yield from iterable
|
||||
return
|
||||
|
||||
if total is None:
|
||||
total = self._get_iterable_length(iterable)
|
||||
|
||||
stderr_console = Console(stderr=True)
|
||||
|
||||
# Progress is created without its own console so Live controls rendering.
|
||||
progress = Progress(*self._progress_columns())
|
||||
task_id = progress.add_task(description, total=total)
|
||||
|
||||
with Live(
|
||||
Group(progress, stats_renderer()),
|
||||
console=stderr_console,
|
||||
refresh_per_second=4,
|
||||
) as live:
|
||||
for item in iterable:
|
||||
yield item
|
||||
progress.advance(task_id)
|
||||
live.update(Group(progress, stats_renderer()))
|
||||
|
||||
def process_parallel(
|
||||
self,
|
||||
fn: Callable[[T], R],
|
||||
@@ -269,7 +498,7 @@ class PaperlessCommand(RichCommand):
|
||||
total = len(items)
|
||||
|
||||
if self.process_count == 1:
|
||||
# Sequential execution in main process - critical for testing
|
||||
# Sequential execution in main process - critical for testing, so we don't fork in fork, etc
|
||||
yield from self._process_sequential(fn, items, description, total)
|
||||
else:
|
||||
# Parallel execution with ProcessPoolExecutor
|
||||
@@ -298,6 +527,7 @@ class PaperlessCommand(RichCommand):
|
||||
total: int,
|
||||
) -> Generator[ProcessResult[T, R], None, None]:
|
||||
"""Process items in parallel using ProcessPoolExecutor."""
|
||||
|
||||
# Close database connections before forking - required for PostgreSQL
|
||||
db.connections.close_all()
|
||||
|
||||
|
||||
@@ -1,4 +1,12 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from dataclasses import dataclass
|
||||
from dataclasses import field
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from rich.table import Table
|
||||
from rich.text import Text
|
||||
|
||||
from documents.classifier import load_classifier
|
||||
from documents.management.commands.base import PaperlessCommand
|
||||
@@ -8,9 +16,162 @@ from documents.signals.handlers import set_document_type
|
||||
from documents.signals.handlers import set_storage_path
|
||||
from documents.signals.handlers import set_tags
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from rich.console import RenderableType
|
||||
|
||||
from documents.models import Correspondent
|
||||
from documents.models import DocumentType
|
||||
from documents.models import StoragePath
|
||||
from documents.models import Tag
|
||||
|
||||
logger = logging.getLogger("paperless.management.retagger")
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class RetaggerStats:
|
||||
"""Cumulative counters updated as the retagger processes documents.
|
||||
|
||||
Mutable by design -- fields are incremented in the processing loop.
|
||||
slots=True reduces per-instance memory overhead and speeds attribute access.
|
||||
"""
|
||||
|
||||
correspondents: int = 0
|
||||
document_types: int = 0
|
||||
tags_added: int = 0
|
||||
tags_removed: int = 0
|
||||
storage_paths: int = 0
|
||||
documents_processed: int = 0
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class DocumentSuggestion:
|
||||
"""Buffered classifier suggestions for a single document (suggest mode only).
|
||||
|
||||
Mutable by design -- fields are assigned incrementally as each setter runs.
|
||||
"""
|
||||
|
||||
document: Document
|
||||
correspondent: Correspondent | None = None
|
||||
document_type: DocumentType | None = None
|
||||
tags_to_add: frozenset[Tag] = field(default_factory=frozenset)
|
||||
tags_to_remove: frozenset[Tag] = field(default_factory=frozenset)
|
||||
storage_path: StoragePath | None = None
|
||||
|
||||
@property
|
||||
def has_suggestions(self) -> bool:
|
||||
return bool(
|
||||
self.correspondent is not None
|
||||
or self.document_type is not None
|
||||
or self.tags_to_add
|
||||
or self.tags_to_remove
|
||||
or self.storage_path is not None,
|
||||
)
|
||||
|
||||
|
||||
def _build_stats_table(stats: RetaggerStats, *, suggest: bool) -> Table:
|
||||
"""
|
||||
Build the live-updating stats table shown below the progress bar.
|
||||
|
||||
In suggest mode the labels read "would set / would add" to make clear
|
||||
that nothing has been written to the database.
|
||||
"""
|
||||
table = Table(box=None, padding=(0, 2), show_header=True, header_style="bold")
|
||||
|
||||
table.add_column("Documents")
|
||||
table.add_column("Correspondents")
|
||||
table.add_column("Doc Types")
|
||||
table.add_column("Tags (+)")
|
||||
table.add_column("Tags (-)")
|
||||
table.add_column("Storage Paths")
|
||||
|
||||
verb = "would set" if suggest else "set"
|
||||
|
||||
table.add_row(
|
||||
str(stats.documents_processed),
|
||||
f"{stats.correspondents} {verb}",
|
||||
f"{stats.document_types} {verb}",
|
||||
f"+{stats.tags_added}",
|
||||
f"-{stats.tags_removed}",
|
||||
f"{stats.storage_paths} {verb}",
|
||||
)
|
||||
|
||||
return table
|
||||
|
||||
|
||||
def _build_suggestion_table(
|
||||
suggestions: list[DocumentSuggestion],
|
||||
base_url: str | None,
|
||||
) -> Table:
|
||||
"""
|
||||
Build the final suggestion table printed after the progress bar completes.
|
||||
|
||||
Only documents with at least one suggestion are included.
|
||||
"""
|
||||
table = Table(
|
||||
title="Suggested Changes",
|
||||
show_header=True,
|
||||
header_style="bold cyan",
|
||||
show_lines=True,
|
||||
)
|
||||
|
||||
table.add_column("Document", style="bold", no_wrap=False, min_width=20)
|
||||
table.add_column("Correspondent")
|
||||
table.add_column("Doc Type")
|
||||
table.add_column("Tags")
|
||||
table.add_column("Storage Path")
|
||||
|
||||
for suggestion in suggestions:
|
||||
if not suggestion.has_suggestions:
|
||||
continue
|
||||
|
||||
doc = suggestion.document
|
||||
|
||||
if base_url:
|
||||
doc_cell = Text()
|
||||
doc_cell.append(str(doc))
|
||||
doc_cell.append(f"\n{base_url}/documents/{doc.pk}", style="dim")
|
||||
else:
|
||||
doc_cell = Text(f"{doc} [{doc.pk}]")
|
||||
|
||||
tag_parts: list[str] = []
|
||||
for tag in sorted(suggestion.tags_to_add, key=lambda t: t.name):
|
||||
tag_parts.append(f"[green]+{tag.name}[/green]")
|
||||
for tag in sorted(suggestion.tags_to_remove, key=lambda t: t.name):
|
||||
tag_parts.append(f"[red]-{tag.name}[/red]")
|
||||
tag_cell = Text.from_markup(", ".join(tag_parts)) if tag_parts else Text("-")
|
||||
|
||||
table.add_row(
|
||||
doc_cell,
|
||||
str(suggestion.correspondent) if suggestion.correspondent else "-",
|
||||
str(suggestion.document_type) if suggestion.document_type else "-",
|
||||
tag_cell,
|
||||
str(suggestion.storage_path) if suggestion.storage_path else "-",
|
||||
)
|
||||
|
||||
return table
|
||||
|
||||
|
||||
def _build_summary_table(stats: RetaggerStats) -> Table:
|
||||
"""Build the final applied-changes summary table."""
|
||||
table = Table(
|
||||
title="Retagger Summary",
|
||||
show_header=True,
|
||||
header_style="bold cyan",
|
||||
)
|
||||
|
||||
table.add_column("Metric", style="bold")
|
||||
table.add_column("Count", justify="right")
|
||||
|
||||
table.add_row("Documents processed", str(stats.documents_processed))
|
||||
table.add_row("Correspondents set", str(stats.correspondents))
|
||||
table.add_row("Document types set", str(stats.document_types))
|
||||
table.add_row("Tags added", str(stats.tags_added))
|
||||
table.add_row("Tags removed", str(stats.tags_removed))
|
||||
table.add_row("Storage paths set", str(stats.storage_paths))
|
||||
|
||||
return table
|
||||
|
||||
|
||||
class Command(PaperlessCommand):
|
||||
help = (
|
||||
"Using the current classification model, assigns correspondents, tags "
|
||||
@@ -19,7 +180,7 @@ class Command(PaperlessCommand):
|
||||
"modified) after their initial import."
|
||||
)
|
||||
|
||||
def add_arguments(self, parser):
|
||||
def add_arguments(self, parser) -> None:
|
||||
super().add_arguments(parser)
|
||||
parser.add_argument("-c", "--correspondent", default=False, action="store_true")
|
||||
parser.add_argument("-T", "--tags", default=False, action="store_true")
|
||||
@@ -31,9 +192,9 @@ class Command(PaperlessCommand):
|
||||
default=False,
|
||||
action="store_true",
|
||||
help=(
|
||||
"By default this command won't try to assign a correspondent "
|
||||
"if more than one matches the document. Use this flag if "
|
||||
"you'd rather it just pick the first one it finds."
|
||||
"By default this command will not try to assign a correspondent "
|
||||
"if more than one matches the document. Use this flag to pick "
|
||||
"the first match instead."
|
||||
),
|
||||
)
|
||||
parser.add_argument(
|
||||
@@ -42,91 +203,140 @@ class Command(PaperlessCommand):
|
||||
default=False,
|
||||
action="store_true",
|
||||
help=(
|
||||
"If set, the document retagger will overwrite any previously "
|
||||
"set correspondent, document and remove correspondents, types "
|
||||
"and tags that do not match anymore due to changed rules."
|
||||
"Overwrite any previously set correspondent, document type, and "
|
||||
"remove tags that no longer match due to changed rules."
|
||||
),
|
||||
)
|
||||
parser.add_argument(
|
||||
"--suggest",
|
||||
default=False,
|
||||
action="store_true",
|
||||
help="Return the suggestion, don't change anything.",
|
||||
help="Show what would be changed without applying anything.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--base-url",
|
||||
help="The base URL to use to build the link to the documents.",
|
||||
help="Base URL used to build document links in suggest output.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--id-range",
|
||||
help="A range of document ids on which the retagging should be applied.",
|
||||
help="Restrict retagging to documents within this ID range (inclusive).",
|
||||
nargs=2,
|
||||
type=int,
|
||||
)
|
||||
|
||||
def handle(self, *args, **options):
|
||||
def handle(self, *args, **options) -> None:
|
||||
suggest: bool = options["suggest"]
|
||||
overwrite: bool = options["overwrite"]
|
||||
use_first: bool = options["use_first"]
|
||||
base_url: str | None = options["base_url"]
|
||||
|
||||
do_correspondent: bool = options["correspondent"]
|
||||
do_document_type: bool = options["document_type"]
|
||||
do_tags: bool = options["tags"]
|
||||
do_storage_path: bool = options["storage_path"]
|
||||
|
||||
if not any([do_correspondent, do_document_type, do_tags, do_storage_path]):
|
||||
self.console.print(
|
||||
"[yellow]No classifier targets specified. "
|
||||
"Use -c, -T, -t, or -s to select what to retag.[/yellow]",
|
||||
)
|
||||
return
|
||||
|
||||
if options["inbox_only"]:
|
||||
queryset = Document.objects.filter(tags__is_inbox_tag=True)
|
||||
else:
|
||||
queryset = Document.objects.all()
|
||||
|
||||
if options["id_range"]:
|
||||
queryset = queryset.filter(
|
||||
id__range=(options["id_range"][0], options["id_range"][1]),
|
||||
)
|
||||
lo, hi = options["id_range"]
|
||||
queryset = queryset.filter(id__range=(lo, hi))
|
||||
|
||||
documents = queryset.distinct()
|
||||
|
||||
classifier = load_classifier()
|
||||
|
||||
for document in self.track(documents, description="Retagging..."):
|
||||
if options["correspondent"]:
|
||||
set_correspondent(
|
||||
sender=None,
|
||||
document=document,
|
||||
classifier=classifier,
|
||||
replace=options["overwrite"],
|
||||
use_first=options["use_first"],
|
||||
suggest=options["suggest"],
|
||||
base_url=options["base_url"],
|
||||
stdout=self.stdout,
|
||||
style_func=self.style,
|
||||
)
|
||||
stats = RetaggerStats()
|
||||
suggestions: list[DocumentSuggestion] = []
|
||||
|
||||
if options["document_type"]:
|
||||
set_document_type(
|
||||
sender=None,
|
||||
document=document,
|
||||
classifier=classifier,
|
||||
replace=options["overwrite"],
|
||||
use_first=options["use_first"],
|
||||
suggest=options["suggest"],
|
||||
base_url=options["base_url"],
|
||||
stdout=self.stdout,
|
||||
style_func=self.style,
|
||||
)
|
||||
def render_stats() -> RenderableType:
|
||||
return _build_stats_table(stats, suggest=suggest)
|
||||
|
||||
if options["tags"]:
|
||||
set_tags(
|
||||
sender=None,
|
||||
document=document,
|
||||
classifier=classifier,
|
||||
replace=options["overwrite"],
|
||||
suggest=options["suggest"],
|
||||
base_url=options["base_url"],
|
||||
stdout=self.stdout,
|
||||
style_func=self.style,
|
||||
)
|
||||
with self.buffered_logging(
|
||||
"paperless",
|
||||
"paperless.handlers",
|
||||
"documents",
|
||||
) as log_buf:
|
||||
for document in self.track_with_stats(
|
||||
documents,
|
||||
description="Retagging...",
|
||||
stats_renderer=render_stats,
|
||||
):
|
||||
suggestion = DocumentSuggestion(document=document)
|
||||
|
||||
if options["storage_path"]:
|
||||
set_storage_path(
|
||||
sender=None,
|
||||
document=document,
|
||||
classifier=classifier,
|
||||
replace=options["overwrite"],
|
||||
use_first=options["use_first"],
|
||||
suggest=options["suggest"],
|
||||
base_url=options["base_url"],
|
||||
stdout=self.stdout,
|
||||
style_func=self.style,
|
||||
)
|
||||
if do_correspondent:
|
||||
correspondent = set_correspondent(
|
||||
None,
|
||||
document,
|
||||
classifier=classifier,
|
||||
replace=overwrite,
|
||||
use_first=use_first,
|
||||
dry_run=suggest,
|
||||
)
|
||||
if correspondent is not None:
|
||||
stats.correspondents += 1
|
||||
suggestion.correspondent = correspondent
|
||||
|
||||
if do_document_type:
|
||||
document_type = set_document_type(
|
||||
None,
|
||||
document,
|
||||
classifier=classifier,
|
||||
replace=overwrite,
|
||||
use_first=use_first,
|
||||
dry_run=suggest,
|
||||
)
|
||||
if document_type is not None:
|
||||
stats.document_types += 1
|
||||
suggestion.document_type = document_type
|
||||
|
||||
if do_tags:
|
||||
tags_to_add, tags_to_remove = set_tags(
|
||||
None,
|
||||
document,
|
||||
classifier=classifier,
|
||||
replace=overwrite,
|
||||
dry_run=suggest,
|
||||
)
|
||||
stats.tags_added += len(tags_to_add)
|
||||
stats.tags_removed += len(tags_to_remove)
|
||||
suggestion.tags_to_add = frozenset(tags_to_add)
|
||||
suggestion.tags_to_remove = frozenset(tags_to_remove)
|
||||
|
||||
if do_storage_path:
|
||||
storage_path = set_storage_path(
|
||||
None,
|
||||
document,
|
||||
classifier=classifier,
|
||||
replace=overwrite,
|
||||
use_first=use_first,
|
||||
dry_run=suggest,
|
||||
)
|
||||
if storage_path is not None:
|
||||
stats.storage_paths += 1
|
||||
suggestion.storage_path = storage_path
|
||||
|
||||
stats.documents_processed += 1
|
||||
|
||||
if suggest:
|
||||
suggestions.append(suggestion)
|
||||
|
||||
# Post-loop output
|
||||
if suggest:
|
||||
visible = [s for s in suggestions if s.has_suggestions]
|
||||
if visible:
|
||||
self.console.print(_build_suggestion_table(visible, base_url))
|
||||
else:
|
||||
self.console.print("[green]No changes suggested.[/green]")
|
||||
else:
|
||||
self.console.print(_build_summary_table(stats))
|
||||
|
||||
log_buf.render(self.console, min_level=logging.INFO, title="Retagger Log")
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
# Generated by Django 5.2.7 on 2026-01-15 22:08
|
||||
# Generated by Django 5.2.11 on 2026-03-03 16:27
|
||||
|
||||
import datetime
|
||||
|
||||
@@ -21,6 +21,207 @@ class Migration(migrations.Migration):
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
]
|
||||
|
||||
replaces = [
|
||||
("documents", "0001_initial"),
|
||||
("documents", "0002_auto_20151226_1316"),
|
||||
("documents", "0003_sender"),
|
||||
("documents", "0004_auto_20160114_1844"),
|
||||
(
|
||||
"documents",
|
||||
"0004_auto_20160114_1844_squashed_0011_auto_20160303_1929",
|
||||
),
|
||||
("documents", "0005_auto_20160123_0313"),
|
||||
("documents", "0006_auto_20160123_0430"),
|
||||
("documents", "0007_auto_20160126_2114"),
|
||||
("documents", "0008_document_file_type"),
|
||||
("documents", "0009_auto_20160214_0040"),
|
||||
("documents", "0010_log"),
|
||||
("documents", "0011_auto_20160303_1929"),
|
||||
("documents", "0012_auto_20160305_0040"),
|
||||
("documents", "0013_auto_20160325_2111"),
|
||||
("documents", "0014_document_checksum"),
|
||||
("documents", "0015_add_insensitive_to_match"),
|
||||
(
|
||||
"documents",
|
||||
"0015_add_insensitive_to_match_squashed_0018_auto_20170715_1712",
|
||||
),
|
||||
("documents", "0016_auto_20170325_1558"),
|
||||
("documents", "0017_auto_20170512_0507"),
|
||||
("documents", "0018_auto_20170715_1712"),
|
||||
("documents", "0019_add_consumer_user"),
|
||||
("documents", "0020_document_added"),
|
||||
("documents", "0021_document_storage_type"),
|
||||
("documents", "0022_auto_20181007_1420"),
|
||||
("documents", "0023_document_current_filename"),
|
||||
("documents", "1000_update_paperless_all"),
|
||||
("documents", "1001_auto_20201109_1636"),
|
||||
("documents", "1002_auto_20201111_1105"),
|
||||
("documents", "1003_mime_types"),
|
||||
("documents", "1004_sanity_check_schedule"),
|
||||
("documents", "1005_checksums"),
|
||||
("documents", "1006_auto_20201208_2209"),
|
||||
(
|
||||
"documents",
|
||||
"1006_auto_20201208_2209_squashed_1011_auto_20210101_2340",
|
||||
),
|
||||
("documents", "1007_savedview_savedviewfilterrule"),
|
||||
("documents", "1008_auto_20201216_1736"),
|
||||
("documents", "1009_auto_20201216_2005"),
|
||||
("documents", "1010_auto_20210101_2159"),
|
||||
("documents", "1011_auto_20210101_2340"),
|
||||
("documents", "1012_fix_archive_files"),
|
||||
("documents", "1013_migrate_tag_colour"),
|
||||
("documents", "1014_auto_20210228_1614"),
|
||||
("documents", "1015_remove_null_characters"),
|
||||
("documents", "1016_auto_20210317_1351"),
|
||||
(
|
||||
"documents",
|
||||
"1016_auto_20210317_1351_squashed_1020_merge_20220518_1839",
|
||||
),
|
||||
("documents", "1017_alter_savedviewfilterrule_rule_type"),
|
||||
("documents", "1018_alter_savedviewfilterrule_value"),
|
||||
("documents", "1019_storagepath_document_storage_path"),
|
||||
("documents", "1019_uisettings"),
|
||||
("documents", "1020_merge_20220518_1839"),
|
||||
("documents", "1021_webp_thumbnail_conversion"),
|
||||
("documents", "1022_paperlesstask"),
|
||||
(
|
||||
"documents",
|
||||
"1022_paperlesstask_squashed_1036_alter_savedviewfilterrule_rule_type",
|
||||
),
|
||||
("documents", "1023_add_comments"),
|
||||
("documents", "1024_document_original_filename"),
|
||||
("documents", "1025_alter_savedviewfilterrule_rule_type"),
|
||||
("documents", "1026_transition_to_celery"),
|
||||
(
|
||||
"documents",
|
||||
"1027_remove_paperlesstask_attempted_task_and_more",
|
||||
),
|
||||
(
|
||||
"documents",
|
||||
"1028_remove_paperlesstask_task_args_and_more",
|
||||
),
|
||||
("documents", "1029_alter_document_archive_serial_number"),
|
||||
("documents", "1030_alter_paperlesstask_task_file_name"),
|
||||
(
|
||||
"documents",
|
||||
"1031_remove_savedview_user_correspondent_owner_and_more",
|
||||
),
|
||||
(
|
||||
"documents",
|
||||
"1032_alter_correspondent_matching_algorithm_and_more",
|
||||
),
|
||||
(
|
||||
"documents",
|
||||
"1033_alter_documenttype_options_alter_tag_options_and_more",
|
||||
),
|
||||
("documents", "1034_alter_savedviewfilterrule_rule_type"),
|
||||
("documents", "1035_rename_comment_note"),
|
||||
("documents", "1036_alter_savedviewfilterrule_rule_type"),
|
||||
("documents", "1037_webp_encrypted_thumbnail_conversion"),
|
||||
("documents", "1038_sharelink"),
|
||||
("documents", "1039_consumptiontemplate"),
|
||||
(
|
||||
"documents",
|
||||
"1040_customfield_customfieldinstance_and_more",
|
||||
),
|
||||
("documents", "1041_alter_consumptiontemplate_sources"),
|
||||
(
|
||||
"documents",
|
||||
"1042_consumptiontemplate_assign_custom_fields_and_more",
|
||||
),
|
||||
("documents", "1043_alter_savedviewfilterrule_rule_type"),
|
||||
(
|
||||
"documents",
|
||||
"1044_workflow_workflowaction_workflowtrigger_and_more",
|
||||
),
|
||||
(
|
||||
"documents",
|
||||
"1045_alter_customfieldinstance_value_monetary",
|
||||
),
|
||||
(
|
||||
"documents",
|
||||
"1045_alter_customfieldinstance_value_monetary_squashed_1049_document_deleted_at_document_restored_at",
|
||||
),
|
||||
(
|
||||
"documents",
|
||||
"1046_workflowaction_remove_all_correspondents_and_more",
|
||||
),
|
||||
("documents", "1047_savedview_display_mode_and_more"),
|
||||
("documents", "1048_alter_savedviewfilterrule_rule_type"),
|
||||
(
|
||||
"documents",
|
||||
"1049_document_deleted_at_document_restored_at",
|
||||
),
|
||||
("documents", "1050_customfield_extra_data_and_more"),
|
||||
(
|
||||
"documents",
|
||||
"1051_alter_correspondent_owner_alter_document_owner_and_more",
|
||||
),
|
||||
("documents", "1052_document_transaction_id"),
|
||||
("documents", "1053_document_page_count"),
|
||||
(
|
||||
"documents",
|
||||
"1054_customfieldinstance_value_monetary_amount_and_more",
|
||||
),
|
||||
("documents", "1055_alter_storagepath_path"),
|
||||
(
|
||||
"documents",
|
||||
"1056_customfieldinstance_deleted_at_and_more",
|
||||
),
|
||||
("documents", "1057_paperlesstask_owner"),
|
||||
(
|
||||
"documents",
|
||||
"1058_workflowtrigger_schedule_date_custom_field_and_more",
|
||||
),
|
||||
(
|
||||
"documents",
|
||||
"1059_workflowactionemail_workflowactionwebhook_and_more",
|
||||
),
|
||||
(
|
||||
"documents",
|
||||
"1060_alter_customfieldinstance_value_select",
|
||||
),
|
||||
("documents", "1061_workflowactionwebhook_as_json"),
|
||||
("documents", "1062_alter_savedviewfilterrule_rule_type"),
|
||||
(
|
||||
"documents",
|
||||
"1063_paperlesstask_type_alter_paperlesstask_task_name_and_more",
|
||||
),
|
||||
("documents", "1064_delete_log"),
|
||||
(
|
||||
"documents",
|
||||
"1065_workflowaction_assign_custom_fields_values",
|
||||
),
|
||||
(
|
||||
"documents",
|
||||
"1066_alter_workflowtrigger_schedule_offset_days",
|
||||
),
|
||||
("documents", "1067_alter_document_created"),
|
||||
("documents", "1068_alter_document_created"),
|
||||
(
|
||||
"documents",
|
||||
"1069_workflowtrigger_filter_has_storage_path_and_more",
|
||||
),
|
||||
(
|
||||
"documents",
|
||||
"1070_customfieldinstance_value_long_text_and_more",
|
||||
),
|
||||
(
|
||||
"documents",
|
||||
"1071_tag_tn_ancestors_count_tag_tn_ancestors_pks_and_more",
|
||||
),
|
||||
(
|
||||
"documents",
|
||||
"1072_workflowtrigger_filter_custom_field_query_and_more",
|
||||
),
|
||||
("documents", "1073_migrate_workflow_title_jinja"),
|
||||
(
|
||||
"documents",
|
||||
"1074_workflowrun_deleted_at_workflowrun_restored_at_and_more",
|
||||
),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name="WorkflowActionEmail",
|
||||
@@ -185,70 +386,6 @@ class Migration(migrations.Migration):
|
||||
"abstract": False,
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name="CustomField",
|
||||
fields=[
|
||||
(
|
||||
"id",
|
||||
models.AutoField(
|
||||
auto_created=True,
|
||||
primary_key=True,
|
||||
serialize=False,
|
||||
verbose_name="ID",
|
||||
),
|
||||
),
|
||||
(
|
||||
"created",
|
||||
models.DateTimeField(
|
||||
db_index=True,
|
||||
default=django.utils.timezone.now,
|
||||
editable=False,
|
||||
verbose_name="created",
|
||||
),
|
||||
),
|
||||
("name", models.CharField(max_length=128)),
|
||||
(
|
||||
"data_type",
|
||||
models.CharField(
|
||||
choices=[
|
||||
("string", "String"),
|
||||
("url", "URL"),
|
||||
("date", "Date"),
|
||||
("boolean", "Boolean"),
|
||||
("integer", "Integer"),
|
||||
("float", "Float"),
|
||||
("monetary", "Monetary"),
|
||||
("documentlink", "Document Link"),
|
||||
("select", "Select"),
|
||||
("longtext", "Long Text"),
|
||||
],
|
||||
editable=False,
|
||||
max_length=50,
|
||||
verbose_name="data type",
|
||||
),
|
||||
),
|
||||
(
|
||||
"extra_data",
|
||||
models.JSONField(
|
||||
blank=True,
|
||||
help_text="Extra data for the custom field, such as select options",
|
||||
null=True,
|
||||
verbose_name="extra data",
|
||||
),
|
||||
),
|
||||
],
|
||||
options={
|
||||
"verbose_name": "custom field",
|
||||
"verbose_name_plural": "custom fields",
|
||||
"ordering": ("created",),
|
||||
"constraints": [
|
||||
models.UniqueConstraint(
|
||||
fields=("name",),
|
||||
name="documents_customfield_unique_name",
|
||||
),
|
||||
],
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name="DocumentType",
|
||||
fields=[
|
||||
@@ -733,17 +870,6 @@ class Migration(migrations.Migration):
|
||||
verbose_name="correspondent",
|
||||
),
|
||||
),
|
||||
(
|
||||
"owner",
|
||||
models.ForeignKey(
|
||||
blank=True,
|
||||
default=None,
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.SET_NULL,
|
||||
to=settings.AUTH_USER_MODEL,
|
||||
verbose_name="owner",
|
||||
),
|
||||
),
|
||||
(
|
||||
"document_type",
|
||||
models.ForeignKey(
|
||||
@@ -767,12 +893,14 @@ class Migration(migrations.Migration):
|
||||
),
|
||||
),
|
||||
(
|
||||
"tags",
|
||||
models.ManyToManyField(
|
||||
"owner",
|
||||
models.ForeignKey(
|
||||
blank=True,
|
||||
related_name="documents",
|
||||
to="documents.tag",
|
||||
verbose_name="tags",
|
||||
default=None,
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.SET_NULL,
|
||||
to=settings.AUTH_USER_MODEL,
|
||||
verbose_name="owner",
|
||||
),
|
||||
),
|
||||
],
|
||||
@@ -782,6 +910,140 @@ class Migration(migrations.Migration):
|
||||
"ordering": ("-created",),
|
||||
},
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="document",
|
||||
name="tags",
|
||||
field=models.ManyToManyField(
|
||||
blank=True,
|
||||
related_name="documents",
|
||||
to="documents.tag",
|
||||
verbose_name="tags",
|
||||
),
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name="Note",
|
||||
fields=[
|
||||
(
|
||||
"id",
|
||||
models.AutoField(
|
||||
auto_created=True,
|
||||
primary_key=True,
|
||||
serialize=False,
|
||||
verbose_name="ID",
|
||||
),
|
||||
),
|
||||
("deleted_at", models.DateTimeField(blank=True, null=True)),
|
||||
("restored_at", models.DateTimeField(blank=True, null=True)),
|
||||
("transaction_id", models.UUIDField(blank=True, null=True)),
|
||||
(
|
||||
"note",
|
||||
models.TextField(
|
||||
blank=True,
|
||||
help_text="Note for the document",
|
||||
verbose_name="content",
|
||||
),
|
||||
),
|
||||
(
|
||||
"created",
|
||||
models.DateTimeField(
|
||||
db_index=True,
|
||||
default=django.utils.timezone.now,
|
||||
verbose_name="created",
|
||||
),
|
||||
),
|
||||
(
|
||||
"document",
|
||||
models.ForeignKey(
|
||||
blank=True,
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
related_name="notes",
|
||||
to="documents.document",
|
||||
verbose_name="document",
|
||||
),
|
||||
),
|
||||
(
|
||||
"user",
|
||||
models.ForeignKey(
|
||||
blank=True,
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.SET_NULL,
|
||||
related_name="notes",
|
||||
to=settings.AUTH_USER_MODEL,
|
||||
verbose_name="user",
|
||||
),
|
||||
),
|
||||
],
|
||||
options={
|
||||
"verbose_name": "note",
|
||||
"verbose_name_plural": "notes",
|
||||
"ordering": ("created",),
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name="CustomField",
|
||||
fields=[
|
||||
(
|
||||
"id",
|
||||
models.AutoField(
|
||||
auto_created=True,
|
||||
primary_key=True,
|
||||
serialize=False,
|
||||
verbose_name="ID",
|
||||
),
|
||||
),
|
||||
(
|
||||
"created",
|
||||
models.DateTimeField(
|
||||
db_index=True,
|
||||
default=django.utils.timezone.now,
|
||||
editable=False,
|
||||
verbose_name="created",
|
||||
),
|
||||
),
|
||||
("name", models.CharField(max_length=128)),
|
||||
(
|
||||
"data_type",
|
||||
models.CharField(
|
||||
choices=[
|
||||
("string", "String"),
|
||||
("url", "URL"),
|
||||
("date", "Date"),
|
||||
("boolean", "Boolean"),
|
||||
("integer", "Integer"),
|
||||
("float", "Float"),
|
||||
("monetary", "Monetary"),
|
||||
("documentlink", "Document Link"),
|
||||
("select", "Select"),
|
||||
("longtext", "Long Text"),
|
||||
],
|
||||
editable=False,
|
||||
max_length=50,
|
||||
verbose_name="data type",
|
||||
),
|
||||
),
|
||||
(
|
||||
"extra_data",
|
||||
models.JSONField(
|
||||
blank=True,
|
||||
help_text="Extra data for the custom field, such as select options",
|
||||
null=True,
|
||||
verbose_name="extra data",
|
||||
),
|
||||
),
|
||||
],
|
||||
options={
|
||||
"verbose_name": "custom field",
|
||||
"verbose_name_plural": "custom fields",
|
||||
"ordering": ("created",),
|
||||
"constraints": [
|
||||
models.UniqueConstraint(
|
||||
fields=("name",),
|
||||
name="documents_customfield_unique_name",
|
||||
),
|
||||
],
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name="CustomFieldInstance",
|
||||
fields=[
|
||||
@@ -880,66 +1142,6 @@ class Migration(migrations.Migration):
|
||||
"ordering": ("created",),
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name="Note",
|
||||
fields=[
|
||||
(
|
||||
"id",
|
||||
models.AutoField(
|
||||
auto_created=True,
|
||||
primary_key=True,
|
||||
serialize=False,
|
||||
verbose_name="ID",
|
||||
),
|
||||
),
|
||||
("deleted_at", models.DateTimeField(blank=True, null=True)),
|
||||
("restored_at", models.DateTimeField(blank=True, null=True)),
|
||||
("transaction_id", models.UUIDField(blank=True, null=True)),
|
||||
(
|
||||
"note",
|
||||
models.TextField(
|
||||
blank=True,
|
||||
help_text="Note for the document",
|
||||
verbose_name="content",
|
||||
),
|
||||
),
|
||||
(
|
||||
"created",
|
||||
models.DateTimeField(
|
||||
db_index=True,
|
||||
default=django.utils.timezone.now,
|
||||
verbose_name="created",
|
||||
),
|
||||
),
|
||||
(
|
||||
"document",
|
||||
models.ForeignKey(
|
||||
blank=True,
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
related_name="notes",
|
||||
to="documents.document",
|
||||
verbose_name="document",
|
||||
),
|
||||
),
|
||||
(
|
||||
"user",
|
||||
models.ForeignKey(
|
||||
blank=True,
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.SET_NULL,
|
||||
related_name="notes",
|
||||
to=settings.AUTH_USER_MODEL,
|
||||
verbose_name="user",
|
||||
),
|
||||
),
|
||||
],
|
||||
options={
|
||||
"verbose_name": "note",
|
||||
"verbose_name_plural": "notes",
|
||||
"ordering": ("created",),
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name="PaperlessTask",
|
||||
fields=[
|
||||
@@ -986,7 +1188,6 @@ class Migration(migrations.Migration):
|
||||
("train_classifier", "Train Classifier"),
|
||||
("check_sanity", "Check Sanity"),
|
||||
("index_optimize", "Index Optimize"),
|
||||
("llmindex_update", "LLM Index Update"),
|
||||
],
|
||||
help_text="Name of the task that was run",
|
||||
max_length=255,
|
||||
@@ -1380,6 +1581,7 @@ class Migration(migrations.Migration):
|
||||
verbose_name="Workflow Action Type",
|
||||
),
|
||||
),
|
||||
("order", models.PositiveIntegerField(default=0, verbose_name="order")),
|
||||
(
|
||||
"assign_title",
|
||||
models.TextField(
|
||||
@@ -1,4 +1,4 @@
|
||||
# Generated by Django 5.2.9 on 2026-01-20 18:46
|
||||
# Generated by Django 5.2.11 on 2026-03-03 16:27
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.db import migrations
|
||||
@@ -9,8 +9,14 @@ class Migration(migrations.Migration):
|
||||
initial = True
|
||||
|
||||
dependencies = [
|
||||
("documents", "0001_initial"),
|
||||
("paperless_mail", "0001_initial"),
|
||||
("documents", "0001_squashed"),
|
||||
("paperless_mail", "0001_squashed"),
|
||||
]
|
||||
|
||||
# This migration needs a "replaces", but it doesn't matter which.
|
||||
# Chose the last 2.20.x migration
|
||||
replaces = [
|
||||
("documents", "1075_workflowaction_order"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
@@ -6,7 +6,7 @@ from django.db import models
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
("documents", "0002_initial"),
|
||||
("documents", "0002_squashed"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
# Generated by Django 5.2.11 on 2026-03-03 16:42
|
||||
|
||||
from django.db import migrations
|
||||
from django.db import models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
("documents", "0013_document_root_document"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name="paperlesstask",
|
||||
name="task_name",
|
||||
field=models.CharField(
|
||||
choices=[
|
||||
("consume_file", "Consume File"),
|
||||
("train_classifier", "Train Classifier"),
|
||||
("check_sanity", "Check Sanity"),
|
||||
("index_optimize", "Index Optimize"),
|
||||
("llmindex_update", "LLM Index Update"),
|
||||
],
|
||||
help_text="Name of the task that was run",
|
||||
max_length=255,
|
||||
null=True,
|
||||
verbose_name="Task Name",
|
||||
),
|
||||
),
|
||||
]
|
||||
@@ -75,7 +75,7 @@ class MatchingModel(ModelWithOwner):
|
||||
|
||||
is_insensitive = models.BooleanField(_("is insensitive"), default=True)
|
||||
|
||||
class Meta:
|
||||
class Meta(ModelWithOwner.Meta):
|
||||
abstract = True
|
||||
ordering = ("name",)
|
||||
constraints = [
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import enum
|
||||
from collections.abc import Mapping
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from asgiref.sync import async_to_sync
|
||||
@@ -47,7 +48,7 @@ class BaseStatusManager:
|
||||
async_to_sync(self._channel.flush)
|
||||
self._channel = None
|
||||
|
||||
def send(self, payload: dict[str, str | int | None]) -> None:
|
||||
def send(self, payload: Mapping[str, object]) -> None:
|
||||
# Ensure the layer is open
|
||||
self.open()
|
||||
|
||||
@@ -73,26 +74,28 @@ class ProgressManager(BaseStatusManager):
|
||||
max_progress: int,
|
||||
extra_args: dict[str, str | int | None] | None = None,
|
||||
) -> None:
|
||||
payload = {
|
||||
"type": "status_update",
|
||||
"data": {
|
||||
"filename": self.filename,
|
||||
"task_id": self.task_id,
|
||||
"current_progress": current_progress,
|
||||
"max_progress": max_progress,
|
||||
"status": status,
|
||||
"message": message,
|
||||
},
|
||||
data: dict[str, object] = {
|
||||
"filename": self.filename,
|
||||
"task_id": self.task_id,
|
||||
"current_progress": current_progress,
|
||||
"max_progress": max_progress,
|
||||
"status": status,
|
||||
"message": message,
|
||||
}
|
||||
if extra_args is not None:
|
||||
payload["data"].update(extra_args)
|
||||
data.update(extra_args)
|
||||
|
||||
payload: dict[str, object] = {
|
||||
"type": "status_update",
|
||||
"data": data,
|
||||
}
|
||||
|
||||
self.send(payload)
|
||||
|
||||
|
||||
class DocumentsStatusManager(BaseStatusManager):
|
||||
def send_documents_deleted(self, documents: list[int]) -> None:
|
||||
payload = {
|
||||
payload: dict[str, object] = {
|
||||
"type": "documents_deleted",
|
||||
"data": {
|
||||
"documents": documents,
|
||||
@@ -100,3 +103,25 @@ class DocumentsStatusManager(BaseStatusManager):
|
||||
}
|
||||
|
||||
self.send(payload)
|
||||
|
||||
def send_document_updated(
|
||||
self,
|
||||
*,
|
||||
document_id: int,
|
||||
modified: str,
|
||||
owner_id: int | None = None,
|
||||
users_can_view: list[int] | None = None,
|
||||
groups_can_view: list[int] | None = None,
|
||||
) -> None:
|
||||
payload: dict[str, object] = {
|
||||
"type": "document_updated",
|
||||
"data": {
|
||||
"document_id": document_id,
|
||||
"modified": modified,
|
||||
"owner_id": owner_id,
|
||||
"users_can_view": users_can_view or [],
|
||||
"groups_can_view": groups_can_view or [],
|
||||
},
|
||||
}
|
||||
|
||||
self.send(payload)
|
||||
|
||||
@@ -4,6 +4,7 @@ import logging
|
||||
import shutil
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING
|
||||
from typing import Any
|
||||
|
||||
from celery import shared_task
|
||||
from celery import states
|
||||
@@ -23,6 +24,7 @@ from django.db.models import Q
|
||||
from django.dispatch import receiver
|
||||
from django.utils import timezone
|
||||
from filelock import FileLock
|
||||
from rest_framework import serializers
|
||||
|
||||
from documents import matching
|
||||
from documents.caching import clear_document_caches
|
||||
@@ -32,12 +34,14 @@ from documents.file_handling import create_source_path_directory
|
||||
from documents.file_handling import delete_empty_directories
|
||||
from documents.file_handling import generate_filename
|
||||
from documents.file_handling import generate_unique_filename
|
||||
from documents.models import Correspondent
|
||||
from documents.models import CustomField
|
||||
from documents.models import CustomFieldInstance
|
||||
from documents.models import Document
|
||||
from documents.models import MatchingModel
|
||||
from documents.models import DocumentType
|
||||
from documents.models import PaperlessTask
|
||||
from documents.models import SavedView
|
||||
from documents.models import StoragePath
|
||||
from documents.models import Tag
|
||||
from documents.models import UiSettings
|
||||
from documents.models import Workflow
|
||||
@@ -45,6 +49,7 @@ from documents.models import WorkflowAction
|
||||
from documents.models import WorkflowRun
|
||||
from documents.models import WorkflowTrigger
|
||||
from documents.permissions import get_objects_for_user_owner_aware
|
||||
from documents.plugins.helpers import DocumentsStatusManager
|
||||
from documents.templating.utils import convert_format_str_to_template_format
|
||||
from documents.workflows.actions import build_workflow_action_context
|
||||
from documents.workflows.actions import execute_email_action
|
||||
@@ -66,6 +71,7 @@ if TYPE_CHECKING:
|
||||
from documents.data_models import DocumentMetadataOverrides
|
||||
|
||||
logger = logging.getLogger("paperless.handlers")
|
||||
DRF_DATETIME_FIELD = serializers.DateTimeField()
|
||||
|
||||
|
||||
def add_inbox_tags(sender, document: Document, logging_group=None, **kwargs) -> None:
|
||||
@@ -81,47 +87,41 @@ def add_inbox_tags(sender, document: Document, logging_group=None, **kwargs) ->
|
||||
document.add_nested_tags(inbox_tags)
|
||||
|
||||
|
||||
def _suggestion_printer(
|
||||
stdout,
|
||||
style_func,
|
||||
suggestion_type: str,
|
||||
document: Document,
|
||||
selected: MatchingModel,
|
||||
base_url: str | None = None,
|
||||
) -> None:
|
||||
"""
|
||||
Smaller helper to reduce duplication when just outputting suggestions to the console
|
||||
"""
|
||||
doc_str = str(document)
|
||||
if base_url is not None:
|
||||
stdout.write(style_func.SUCCESS(doc_str))
|
||||
stdout.write(style_func.SUCCESS(f"{base_url}/documents/{document.pk}"))
|
||||
else:
|
||||
stdout.write(style_func.SUCCESS(f"{doc_str} [{document.pk}]"))
|
||||
stdout.write(f"Suggest {suggestion_type}: {selected}")
|
||||
|
||||
|
||||
def set_correspondent(
|
||||
sender,
|
||||
sender: object,
|
||||
document: Document,
|
||||
*,
|
||||
logging_group=None,
|
||||
logging_group: object = None,
|
||||
classifier: DocumentClassifier | None = None,
|
||||
replace=False,
|
||||
use_first=True,
|
||||
suggest=False,
|
||||
base_url=None,
|
||||
stdout=None,
|
||||
style_func=None,
|
||||
**kwargs,
|
||||
) -> None:
|
||||
replace: bool = False,
|
||||
use_first: bool = True,
|
||||
dry_run: bool = False,
|
||||
**kwargs: Any,
|
||||
) -> Correspondent | None:
|
||||
"""
|
||||
Assign a correspondent to a document based on classifier results.
|
||||
|
||||
Args:
|
||||
document: The document to classify.
|
||||
logging_group: Optional logging group for structured log output.
|
||||
classifier: The trained classifier. If None, only rule-based matching runs.
|
||||
replace: If True, overwrite an existing correspondent assignment.
|
||||
use_first: If True, pick the first match when multiple correspondents
|
||||
match. If False, skip assignment when multiple match.
|
||||
dry_run: If True, compute and return the selection without saving.
|
||||
**kwargs: Absorbed for Django signal compatibility (e.g. sender, signal).
|
||||
|
||||
Returns:
|
||||
The correspondent that was (or would be) assigned, or None if no match
|
||||
was found or assignment was skipped.
|
||||
"""
|
||||
if document.correspondent and not replace:
|
||||
return
|
||||
return None
|
||||
|
||||
potential_correspondents = matching.match_correspondents(document, classifier)
|
||||
|
||||
potential_count = len(potential_correspondents)
|
||||
selected = potential_correspondents[0] if potential_correspondents else None
|
||||
|
||||
if potential_count > 1:
|
||||
if use_first:
|
||||
logger.debug(
|
||||
@@ -135,49 +135,53 @@ def set_correspondent(
|
||||
f"not assigning any correspondent",
|
||||
extra={"group": logging_group},
|
||||
)
|
||||
return
|
||||
return None
|
||||
|
||||
if selected or replace:
|
||||
if suggest:
|
||||
_suggestion_printer(
|
||||
stdout,
|
||||
style_func,
|
||||
"correspondent",
|
||||
document,
|
||||
selected,
|
||||
base_url,
|
||||
)
|
||||
else:
|
||||
logger.info(
|
||||
f"Assigning correspondent {selected} to {document}",
|
||||
extra={"group": logging_group},
|
||||
)
|
||||
if (selected or replace) and not dry_run:
|
||||
logger.info(
|
||||
f"Assigning correspondent {selected} to {document}",
|
||||
extra={"group": logging_group},
|
||||
)
|
||||
document.correspondent = selected
|
||||
document.save(update_fields=("correspondent",))
|
||||
|
||||
document.correspondent = selected
|
||||
document.save(update_fields=("correspondent",))
|
||||
return selected
|
||||
|
||||
|
||||
def set_document_type(
|
||||
sender,
|
||||
sender: object,
|
||||
document: Document,
|
||||
*,
|
||||
logging_group=None,
|
||||
logging_group: object = None,
|
||||
classifier: DocumentClassifier | None = None,
|
||||
replace=False,
|
||||
use_first=True,
|
||||
suggest=False,
|
||||
base_url=None,
|
||||
stdout=None,
|
||||
style_func=None,
|
||||
**kwargs,
|
||||
) -> None:
|
||||
replace: bool = False,
|
||||
use_first: bool = True,
|
||||
dry_run: bool = False,
|
||||
**kwargs: Any,
|
||||
) -> DocumentType | None:
|
||||
"""
|
||||
Assign a document type to a document based on classifier results.
|
||||
|
||||
Args:
|
||||
document: The document to classify.
|
||||
logging_group: Optional logging group for structured log output.
|
||||
classifier: The trained classifier. If None, only rule-based matching runs.
|
||||
replace: If True, overwrite an existing document type assignment.
|
||||
use_first: If True, pick the first match when multiple types match.
|
||||
If False, skip assignment when multiple match.
|
||||
dry_run: If True, compute and return the selection without saving.
|
||||
**kwargs: Absorbed for Django signal compatibility (e.g. sender, signal).
|
||||
|
||||
Returns:
|
||||
The document type that was (or would be) assigned, or None if no match
|
||||
was found or assignment was skipped.
|
||||
"""
|
||||
if document.document_type and not replace:
|
||||
return
|
||||
return None
|
||||
|
||||
potential_document_type = matching.match_document_types(document, classifier)
|
||||
|
||||
potential_count = len(potential_document_type)
|
||||
selected = potential_document_type[0] if potential_document_type else None
|
||||
potential_document_types = matching.match_document_types(document, classifier)
|
||||
potential_count = len(potential_document_types)
|
||||
selected = potential_document_types[0] if potential_document_types else None
|
||||
|
||||
if potential_count > 1:
|
||||
if use_first:
|
||||
@@ -192,42 +196,64 @@ def set_document_type(
|
||||
f"not assigning any document type",
|
||||
extra={"group": logging_group},
|
||||
)
|
||||
return
|
||||
return None
|
||||
|
||||
if selected or replace:
|
||||
if suggest:
|
||||
_suggestion_printer(
|
||||
stdout,
|
||||
style_func,
|
||||
"document type",
|
||||
document,
|
||||
selected,
|
||||
base_url,
|
||||
)
|
||||
else:
|
||||
logger.info(
|
||||
f"Assigning document type {selected} to {document}",
|
||||
extra={"group": logging_group},
|
||||
)
|
||||
if (selected or replace) and not dry_run:
|
||||
logger.info(
|
||||
f"Assigning document type {selected} to {document}",
|
||||
extra={"group": logging_group},
|
||||
)
|
||||
document.document_type = selected
|
||||
document.save(update_fields=("document_type",))
|
||||
|
||||
document.document_type = selected
|
||||
document.save(update_fields=("document_type",))
|
||||
return selected
|
||||
|
||||
|
||||
def set_tags(
|
||||
sender,
|
||||
sender: object,
|
||||
document: Document,
|
||||
*,
|
||||
logging_group=None,
|
||||
logging_group: object = None,
|
||||
classifier: DocumentClassifier | None = None,
|
||||
replace=False,
|
||||
suggest=False,
|
||||
base_url=None,
|
||||
stdout=None,
|
||||
style_func=None,
|
||||
**kwargs,
|
||||
) -> None:
|
||||
replace: bool = False,
|
||||
dry_run: bool = False,
|
||||
**kwargs: Any,
|
||||
) -> tuple[set[Tag], set[Tag]]:
|
||||
"""
|
||||
Assign tags to a document based on classifier results.
|
||||
|
||||
When replace=True, existing auto-matched and rule-matched tags are removed
|
||||
before applying the new set (inbox tags and manually-added tags are preserved).
|
||||
|
||||
Args:
|
||||
document: The document to classify.
|
||||
logging_group: Optional logging group for structured log output.
|
||||
classifier: The trained classifier. If None, only rule-based matching runs.
|
||||
replace: If True, remove existing classifier-managed tags before applying
|
||||
new ones. Inbox tags and manually-added tags are always preserved.
|
||||
dry_run: If True, compute what would change without saving anything.
|
||||
**kwargs: Absorbed for Django signal compatibility (e.g. sender, signal).
|
||||
|
||||
Returns:
|
||||
A two-tuple of (tags_added, tags_removed). In non-replace mode,
|
||||
tags_removed is always an empty set. In dry_run mode, neither set
|
||||
is applied to the database.
|
||||
"""
|
||||
# Compute which tags would be removed under replace mode.
|
||||
# The filter mirrors the .delete() call below: keep inbox tags and
|
||||
# manually-added tags (match="" and not auto-matched).
|
||||
if replace:
|
||||
tags_to_remove: set[Tag] = set(
|
||||
document.tags.exclude(
|
||||
is_inbox_tag=True,
|
||||
).exclude(
|
||||
Q(match="") & ~Q(matching_algorithm=Tag.MATCH_AUTO),
|
||||
),
|
||||
)
|
||||
else:
|
||||
tags_to_remove = set()
|
||||
|
||||
if replace and not dry_run:
|
||||
Document.tags.through.objects.filter(document=document).exclude(
|
||||
Q(tag__is_inbox_tag=True),
|
||||
).exclude(
|
||||
@@ -235,65 +261,53 @@ def set_tags(
|
||||
).delete()
|
||||
|
||||
current_tags = set(document.tags.all())
|
||||
|
||||
matched_tags = matching.match_tags(document, classifier)
|
||||
tags_to_add = set(matched_tags) - current_tags
|
||||
|
||||
relevant_tags = set(matched_tags) - current_tags
|
||||
|
||||
if suggest:
|
||||
extra_tags = current_tags - set(matched_tags)
|
||||
extra_tags = [
|
||||
t for t in extra_tags if t.matching_algorithm == MatchingModel.MATCH_AUTO
|
||||
]
|
||||
if not relevant_tags and not extra_tags:
|
||||
return
|
||||
doc_str = style_func.SUCCESS(str(document))
|
||||
if base_url:
|
||||
stdout.write(doc_str)
|
||||
stdout.write(f"{base_url}/documents/{document.pk}")
|
||||
else:
|
||||
stdout.write(doc_str + style_func.SUCCESS(f" [{document.pk}]"))
|
||||
if relevant_tags:
|
||||
stdout.write("Suggest tags: " + ", ".join([t.name for t in relevant_tags]))
|
||||
if extra_tags:
|
||||
stdout.write("Extra tags: " + ", ".join([t.name for t in extra_tags]))
|
||||
else:
|
||||
if not relevant_tags:
|
||||
return
|
||||
|
||||
message = 'Tagging "{}" with "{}"'
|
||||
if tags_to_add and not dry_run:
|
||||
logger.info(
|
||||
message.format(document, ", ".join([t.name for t in relevant_tags])),
|
||||
f'Tagging "{document}" with "{", ".join(t.name for t in tags_to_add)}"',
|
||||
extra={"group": logging_group},
|
||||
)
|
||||
document.add_nested_tags(tags_to_add)
|
||||
|
||||
document.add_nested_tags(relevant_tags)
|
||||
return tags_to_add, tags_to_remove
|
||||
|
||||
|
||||
def set_storage_path(
|
||||
sender,
|
||||
sender: object,
|
||||
document: Document,
|
||||
*,
|
||||
logging_group=None,
|
||||
logging_group: object = None,
|
||||
classifier: DocumentClassifier | None = None,
|
||||
replace=False,
|
||||
use_first=True,
|
||||
suggest=False,
|
||||
base_url=None,
|
||||
stdout=None,
|
||||
style_func=None,
|
||||
**kwargs,
|
||||
) -> None:
|
||||
replace: bool = False,
|
||||
use_first: bool = True,
|
||||
dry_run: bool = False,
|
||||
**kwargs: Any,
|
||||
) -> StoragePath | None:
|
||||
"""
|
||||
Assign a storage path to a document based on classifier results.
|
||||
|
||||
Args:
|
||||
document: The document to classify.
|
||||
logging_group: Optional logging group for structured log output.
|
||||
classifier: The trained classifier. If None, only rule-based matching runs.
|
||||
replace: If True, overwrite an existing storage path assignment.
|
||||
use_first: If True, pick the first match when multiple paths match.
|
||||
If False, skip assignment when multiple match.
|
||||
dry_run: If True, compute and return the selection without saving.
|
||||
**kwargs: Absorbed for Django signal compatibility (e.g. sender, signal).
|
||||
|
||||
Returns:
|
||||
The storage path that was (or would be) assigned, or None if no match
|
||||
was found or assignment was skipped.
|
||||
"""
|
||||
if document.storage_path and not replace:
|
||||
return
|
||||
return None
|
||||
|
||||
potential_storage_path = matching.match_storage_paths(
|
||||
document,
|
||||
classifier,
|
||||
)
|
||||
|
||||
potential_count = len(potential_storage_path)
|
||||
selected = potential_storage_path[0] if potential_storage_path else None
|
||||
potential_storage_paths = matching.match_storage_paths(document, classifier)
|
||||
potential_count = len(potential_storage_paths)
|
||||
selected = potential_storage_paths[0] if potential_storage_paths else None
|
||||
|
||||
if potential_count > 1:
|
||||
if use_first:
|
||||
@@ -308,26 +322,17 @@ def set_storage_path(
|
||||
f"not assigning any storage directory",
|
||||
extra={"group": logging_group},
|
||||
)
|
||||
return
|
||||
return None
|
||||
|
||||
if selected or replace:
|
||||
if suggest:
|
||||
_suggestion_printer(
|
||||
stdout,
|
||||
style_func,
|
||||
"storage directory",
|
||||
document,
|
||||
selected,
|
||||
base_url,
|
||||
)
|
||||
else:
|
||||
logger.info(
|
||||
f"Assigning storage path {selected} to {document}",
|
||||
extra={"group": logging_group},
|
||||
)
|
||||
if (selected or replace) and not dry_run:
|
||||
logger.info(
|
||||
f"Assigning storage path {selected} to {document}",
|
||||
extra={"group": logging_group},
|
||||
)
|
||||
document.storage_path = selected
|
||||
document.save(update_fields=("storage_path",))
|
||||
|
||||
document.storage_path = selected
|
||||
document.save(update_fields=("storage_path",))
|
||||
return selected
|
||||
|
||||
|
||||
# see empty_trash in documents/tasks.py for signal handling
|
||||
@@ -762,6 +767,28 @@ def run_workflows_updated(
|
||||
)
|
||||
|
||||
|
||||
def send_websocket_document_updated(
|
||||
sender,
|
||||
document: Document,
|
||||
**kwargs,
|
||||
) -> None:
|
||||
# At this point, workflows may already have applied additional changes.
|
||||
document.refresh_from_db()
|
||||
|
||||
from documents.data_models import DocumentMetadataOverrides
|
||||
|
||||
doc_overrides = DocumentMetadataOverrides.from_document(document)
|
||||
|
||||
with DocumentsStatusManager() as status_mgr:
|
||||
status_mgr.send_document_updated(
|
||||
document_id=document.id,
|
||||
modified=DRF_DATETIME_FIELD.to_representation(document.modified),
|
||||
owner_id=doc_overrides.owner_id,
|
||||
users_can_view=doc_overrides.view_users,
|
||||
groups_can_view=doc_overrides.view_groups,
|
||||
)
|
||||
|
||||
|
||||
def run_workflows(
|
||||
trigger_type: WorkflowTrigger.WorkflowTriggerType,
|
||||
document: Document | ConsumableDocument,
|
||||
@@ -1043,7 +1070,11 @@ def add_or_update_document_in_llm_index(sender, document, **kwargs):
|
||||
|
||||
|
||||
@receiver(models.signals.post_delete, sender=Document)
|
||||
def delete_document_from_llm_index(sender, instance: Document, **kwargs):
|
||||
def delete_document_from_llm_index(
|
||||
sender: Any,
|
||||
instance: Document,
|
||||
**kwargs: Any,
|
||||
) -> None:
|
||||
"""
|
||||
Delete a document from the LLM index when it is deleted.
|
||||
"""
|
||||
|
||||
@@ -62,6 +62,7 @@ from documents.sanity_checker import SanityCheckFailedException
|
||||
from documents.signals import document_updated
|
||||
from documents.signals.handlers import cleanup_document_deletion
|
||||
from documents.signals.handlers import run_workflows
|
||||
from documents.signals.handlers import send_websocket_document_updated
|
||||
from documents.workflows.utils import get_workflows_for_trigger
|
||||
from paperless.config import AIConfig
|
||||
from paperless_ai.indexing import llm_index_add_or_update_document
|
||||
@@ -572,6 +573,11 @@ def check_scheduled_workflows() -> None:
|
||||
workflow_to_run=workflow,
|
||||
document=document,
|
||||
)
|
||||
# Scheduled workflows dont send document_updated signal, so send a websocket update here to ensure clients are updated
|
||||
send_websocket_document_updated(
|
||||
sender=None,
|
||||
document=document,
|
||||
)
|
||||
|
||||
|
||||
def update_document_parent_tags(tag: Tag, new_parent: Tag) -> None:
|
||||
|
||||
@@ -114,3 +114,14 @@ def authenticated_rest_api_client(rest_api_client: APIClient):
|
||||
user = UserModel.objects.create_user(username="testuser", password="password")
|
||||
rest_api_client.force_authenticate(user=user)
|
||||
yield rest_api_client
|
||||
|
||||
|
||||
@pytest.fixture(scope="session", autouse=True)
|
||||
def faker_session_locale():
|
||||
"""Set Faker locale for reproducibility."""
|
||||
return "en_US"
|
||||
|
||||
|
||||
@pytest.fixture(scope="session", autouse=True)
|
||||
def faker_seed():
|
||||
return 12345
|
||||
|
||||
@@ -1,17 +1,67 @@
|
||||
from factory import Faker
|
||||
"""
|
||||
Factory-boy factories for documents app models.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import factory
|
||||
from factory.django import DjangoModelFactory
|
||||
|
||||
from documents.models import Correspondent
|
||||
from documents.models import Document
|
||||
from documents.models import DocumentType
|
||||
from documents.models import MatchingModel
|
||||
from documents.models import StoragePath
|
||||
from documents.models import Tag
|
||||
|
||||
|
||||
class CorrespondentFactory(DjangoModelFactory):
|
||||
class Meta:
|
||||
model = Correspondent
|
||||
|
||||
name = Faker("name")
|
||||
name = factory.Sequence(lambda n: f"{factory.Faker('company')} {n}")
|
||||
match = ""
|
||||
matching_algorithm = MatchingModel.MATCH_NONE
|
||||
|
||||
|
||||
class DocumentTypeFactory(DjangoModelFactory):
|
||||
class Meta:
|
||||
model = DocumentType
|
||||
|
||||
name = factory.Sequence(lambda n: f"{factory.Faker('bs')} {n}")
|
||||
match = ""
|
||||
matching_algorithm = MatchingModel.MATCH_NONE
|
||||
|
||||
|
||||
class TagFactory(DjangoModelFactory):
|
||||
class Meta:
|
||||
model = Tag
|
||||
|
||||
name = factory.Sequence(lambda n: f"{factory.Faker('word')} {n}")
|
||||
match = ""
|
||||
matching_algorithm = MatchingModel.MATCH_NONE
|
||||
is_inbox_tag = False
|
||||
|
||||
|
||||
class StoragePathFactory(DjangoModelFactory):
|
||||
class Meta:
|
||||
model = StoragePath
|
||||
|
||||
name = factory.Sequence(
|
||||
lambda n: f"{factory.Faker('file_path', depth=2, extension='')} {n}",
|
||||
)
|
||||
path = factory.LazyAttribute(lambda o: f"{o.name}/{{title}}")
|
||||
match = ""
|
||||
matching_algorithm = MatchingModel.MATCH_NONE
|
||||
|
||||
|
||||
class DocumentFactory(DjangoModelFactory):
|
||||
class Meta:
|
||||
model = Document
|
||||
|
||||
title = factory.Faker("sentence", nb_words=4)
|
||||
checksum = factory.Faker("md5")
|
||||
content = factory.Faker("paragraph")
|
||||
correspondent = None
|
||||
document_type = None
|
||||
storage_path = None
|
||||
|
||||
@@ -1270,7 +1270,11 @@ class TestDocumentApi(DirectoriesMixin, DocumentConsumeDelayMixin, APITestCase):
|
||||
input_doc, overrides = self.get_last_consume_delay_call_args()
|
||||
|
||||
self.assertEqual(input_doc.original_file.name, "simple.pdf")
|
||||
self.assertIn(Path(settings.SCRATCH_DIR), input_doc.original_file.parents)
|
||||
self.assertTrue(
|
||||
input_doc.original_file.resolve(strict=False).is_relative_to(
|
||||
Path(settings.SCRATCH_DIR).resolve(strict=False),
|
||||
),
|
||||
)
|
||||
self.assertIsNone(overrides.title)
|
||||
self.assertIsNone(overrides.correspondent_id)
|
||||
self.assertIsNone(overrides.document_type_id)
|
||||
@@ -1351,7 +1355,11 @@ class TestDocumentApi(DirectoriesMixin, DocumentConsumeDelayMixin, APITestCase):
|
||||
input_doc, overrides = self.get_last_consume_delay_call_args()
|
||||
|
||||
self.assertEqual(input_doc.original_file.name, "simple.pdf")
|
||||
self.assertIn(Path(settings.SCRATCH_DIR), input_doc.original_file.parents)
|
||||
self.assertTrue(
|
||||
input_doc.original_file.resolve(strict=False).is_relative_to(
|
||||
Path(settings.SCRATCH_DIR).resolve(strict=False),
|
||||
),
|
||||
)
|
||||
self.assertIsNone(overrides.title)
|
||||
self.assertIsNone(overrides.correspondent_id)
|
||||
self.assertIsNone(overrides.document_type_id)
|
||||
|
||||
@@ -1,298 +1,442 @@
|
||||
"""
|
||||
Tests for the document_retagger management command.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
from django.core.management import call_command
|
||||
from django.core.management.base import CommandError
|
||||
from django.test import TestCase
|
||||
|
||||
from documents.models import Correspondent
|
||||
from documents.models import Document
|
||||
from documents.models import DocumentType
|
||||
from documents.models import MatchingModel
|
||||
from documents.models import StoragePath
|
||||
from documents.models import Tag
|
||||
from documents.tests.factories import CorrespondentFactory
|
||||
from documents.tests.factories import DocumentFactory
|
||||
from documents.tests.factories import DocumentTypeFactory
|
||||
from documents.tests.factories import StoragePathFactory
|
||||
from documents.tests.factories import TagFactory
|
||||
from documents.tests.utils import DirectoriesMixin
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Module-level type aliases
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
StoragePathTuple = tuple[StoragePath, StoragePath, StoragePath]
|
||||
TagTuple = tuple[Tag, Tag, Tag, Tag, Tag]
|
||||
CorrespondentTuple = tuple[Correspondent, Correspondent]
|
||||
DocumentTypeTuple = tuple[DocumentType, DocumentType]
|
||||
DocumentTuple = tuple[Document, Document, Document, Document]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Fixtures
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def storage_paths(db) -> StoragePathTuple:
|
||||
"""Three storage paths with varying match rules."""
|
||||
sp1 = StoragePathFactory(
|
||||
path="{created_data}/{title}",
|
||||
match="auto document",
|
||||
matching_algorithm=MatchingModel.MATCH_LITERAL,
|
||||
)
|
||||
sp2 = StoragePathFactory(
|
||||
path="{title}",
|
||||
match="^first|^unrelated",
|
||||
matching_algorithm=MatchingModel.MATCH_REGEX,
|
||||
)
|
||||
sp3 = StoragePathFactory(
|
||||
path="{title}",
|
||||
match="^blah",
|
||||
matching_algorithm=MatchingModel.MATCH_REGEX,
|
||||
)
|
||||
return sp1, sp2, sp3
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def tags(db) -> TagTuple:
|
||||
"""Tags covering the common matching scenarios."""
|
||||
tag_first = TagFactory(match="first", matching_algorithm=Tag.MATCH_ANY)
|
||||
tag_second = TagFactory(match="second", matching_algorithm=Tag.MATCH_ANY)
|
||||
tag_inbox = TagFactory(is_inbox_tag=True)
|
||||
tag_no_match = TagFactory()
|
||||
tag_auto = TagFactory(matching_algorithm=Tag.MATCH_AUTO)
|
||||
return tag_first, tag_second, tag_inbox, tag_no_match, tag_auto
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def correspondents(db) -> CorrespondentTuple:
|
||||
"""Two correspondents matching 'first' and 'second' content."""
|
||||
c_first = CorrespondentFactory(
|
||||
match="first",
|
||||
matching_algorithm=MatchingModel.MATCH_ANY,
|
||||
)
|
||||
c_second = CorrespondentFactory(
|
||||
match="second",
|
||||
matching_algorithm=MatchingModel.MATCH_ANY,
|
||||
)
|
||||
return c_first, c_second
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def document_types(db) -> DocumentTypeTuple:
|
||||
"""Two document types matching 'first' and 'second' content."""
|
||||
dt_first = DocumentTypeFactory(
|
||||
match="first",
|
||||
matching_algorithm=MatchingModel.MATCH_ANY,
|
||||
)
|
||||
dt_second = DocumentTypeFactory(
|
||||
match="second",
|
||||
matching_algorithm=MatchingModel.MATCH_ANY,
|
||||
)
|
||||
return dt_first, dt_second
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def documents(storage_paths: StoragePathTuple, tags: TagTuple) -> DocumentTuple:
|
||||
"""Four documents with varied content used across most retagger tests."""
|
||||
_, _, sp3 = storage_paths
|
||||
_, _, tag_inbox, tag_no_match, tag_auto = tags
|
||||
|
||||
d1 = DocumentFactory(checksum="A", title="A", content="first document")
|
||||
d2 = DocumentFactory(checksum="B", title="B", content="second document")
|
||||
d3 = DocumentFactory(
|
||||
checksum="C",
|
||||
title="C",
|
||||
content="unrelated document",
|
||||
storage_path=sp3,
|
||||
)
|
||||
d4 = DocumentFactory(checksum="D", title="D", content="auto document")
|
||||
|
||||
d3.tags.add(tag_inbox, tag_no_match)
|
||||
d4.tags.add(tag_auto)
|
||||
|
||||
return d1, d2, d3, d4
|
||||
|
||||
|
||||
def _get_docs() -> DocumentTuple:
|
||||
return (
|
||||
Document.objects.get(title="A"),
|
||||
Document.objects.get(title="B"),
|
||||
Document.objects.get(title="C"),
|
||||
Document.objects.get(title="D"),
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Tag assignment
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.management
|
||||
class TestRetagger(DirectoriesMixin, TestCase):
|
||||
def make_models(self) -> None:
|
||||
self.sp1 = StoragePath.objects.create(
|
||||
name="dummy a",
|
||||
path="{created_data}/{title}",
|
||||
match="auto document",
|
||||
matching_algorithm=StoragePath.MATCH_LITERAL,
|
||||
)
|
||||
self.sp2 = StoragePath.objects.create(
|
||||
name="dummy b",
|
||||
path="{title}",
|
||||
match="^first|^unrelated",
|
||||
matching_algorithm=StoragePath.MATCH_REGEX,
|
||||
)
|
||||
|
||||
self.sp3 = StoragePath.objects.create(
|
||||
name="dummy c",
|
||||
path="{title}",
|
||||
match="^blah",
|
||||
matching_algorithm=StoragePath.MATCH_REGEX,
|
||||
)
|
||||
|
||||
self.d1 = Document.objects.create(
|
||||
checksum="A",
|
||||
title="A",
|
||||
content="first document",
|
||||
)
|
||||
self.d2 = Document.objects.create(
|
||||
checksum="B",
|
||||
title="B",
|
||||
content="second document",
|
||||
)
|
||||
self.d3 = Document.objects.create(
|
||||
checksum="C",
|
||||
title="C",
|
||||
content="unrelated document",
|
||||
storage_path=self.sp3,
|
||||
)
|
||||
self.d4 = Document.objects.create(
|
||||
checksum="D",
|
||||
title="D",
|
||||
content="auto document",
|
||||
)
|
||||
|
||||
self.tag_first = Tag.objects.create(
|
||||
name="tag1",
|
||||
match="first",
|
||||
matching_algorithm=Tag.MATCH_ANY,
|
||||
)
|
||||
self.tag_second = Tag.objects.create(
|
||||
name="tag2",
|
||||
match="second",
|
||||
matching_algorithm=Tag.MATCH_ANY,
|
||||
)
|
||||
self.tag_inbox = Tag.objects.create(name="test", is_inbox_tag=True)
|
||||
self.tag_no_match = Tag.objects.create(name="test2")
|
||||
self.tag_auto = Tag.objects.create(
|
||||
name="tagauto",
|
||||
matching_algorithm=Tag.MATCH_AUTO,
|
||||
)
|
||||
|
||||
self.d3.tags.add(self.tag_inbox)
|
||||
self.d3.tags.add(self.tag_no_match)
|
||||
self.d4.tags.add(self.tag_auto)
|
||||
|
||||
self.correspondent_first = Correspondent.objects.create(
|
||||
name="c1",
|
||||
match="first",
|
||||
matching_algorithm=Correspondent.MATCH_ANY,
|
||||
)
|
||||
self.correspondent_second = Correspondent.objects.create(
|
||||
name="c2",
|
||||
match="second",
|
||||
matching_algorithm=Correspondent.MATCH_ANY,
|
||||
)
|
||||
|
||||
self.doctype_first = DocumentType.objects.create(
|
||||
name="dt1",
|
||||
match="first",
|
||||
matching_algorithm=DocumentType.MATCH_ANY,
|
||||
)
|
||||
self.doctype_second = DocumentType.objects.create(
|
||||
name="dt2",
|
||||
match="second",
|
||||
matching_algorithm=DocumentType.MATCH_ANY,
|
||||
)
|
||||
|
||||
def get_updated_docs(self):
|
||||
return (
|
||||
Document.objects.get(title="A"),
|
||||
Document.objects.get(title="B"),
|
||||
Document.objects.get(title="C"),
|
||||
Document.objects.get(title="D"),
|
||||
)
|
||||
|
||||
def setUp(self) -> None:
|
||||
super().setUp()
|
||||
self.make_models()
|
||||
|
||||
def test_add_tags(self) -> None:
|
||||
@pytest.mark.django_db
|
||||
class TestRetaggerTags(DirectoriesMixin):
|
||||
@pytest.mark.usefixtures("documents")
|
||||
def test_add_tags(self, tags: TagTuple) -> None:
|
||||
tag_first, tag_second, *_ = tags
|
||||
call_command("document_retagger", "--tags")
|
||||
d_first, d_second, d_unrelated, d_auto = self.get_updated_docs()
|
||||
d_first, d_second, d_unrelated, d_auto = _get_docs()
|
||||
|
||||
self.assertEqual(d_first.tags.count(), 1)
|
||||
self.assertEqual(d_second.tags.count(), 1)
|
||||
self.assertEqual(d_unrelated.tags.count(), 2)
|
||||
self.assertEqual(d_auto.tags.count(), 1)
|
||||
assert d_first.tags.count() == 1
|
||||
assert d_second.tags.count() == 1
|
||||
assert d_unrelated.tags.count() == 2
|
||||
assert d_auto.tags.count() == 1
|
||||
assert d_first.tags.first() == tag_first
|
||||
assert d_second.tags.first() == tag_second
|
||||
|
||||
self.assertEqual(d_first.tags.first(), self.tag_first)
|
||||
self.assertEqual(d_second.tags.first(), self.tag_second)
|
||||
|
||||
def test_add_type(self) -> None:
|
||||
call_command("document_retagger", "--document_type")
|
||||
d_first, d_second, _, _ = self.get_updated_docs()
|
||||
|
||||
self.assertEqual(d_first.document_type, self.doctype_first)
|
||||
self.assertEqual(d_second.document_type, self.doctype_second)
|
||||
|
||||
def test_add_correspondent(self) -> None:
|
||||
call_command("document_retagger", "--correspondent")
|
||||
d_first, d_second, _, _ = self.get_updated_docs()
|
||||
|
||||
self.assertEqual(d_first.correspondent, self.correspondent_first)
|
||||
self.assertEqual(d_second.correspondent, self.correspondent_second)
|
||||
|
||||
def test_overwrite_preserve_inbox(self) -> None:
|
||||
self.d1.tags.add(self.tag_second)
|
||||
def test_overwrite_removes_stale_tags_and_preserves_inbox(
|
||||
self,
|
||||
documents: DocumentTuple,
|
||||
tags: TagTuple,
|
||||
) -> None:
|
||||
d1, *_ = documents
|
||||
tag_first, tag_second, tag_inbox, tag_no_match, _ = tags
|
||||
d1.tags.add(tag_second)
|
||||
|
||||
call_command("document_retagger", "--tags", "--overwrite")
|
||||
|
||||
d_first, d_second, d_unrelated, d_auto = self.get_updated_docs()
|
||||
d_first, d_second, d_unrelated, d_auto = _get_docs()
|
||||
|
||||
self.assertIsNotNone(Tag.objects.get(id=self.tag_second.id))
|
||||
assert Tag.objects.filter(id=tag_second.id).exists()
|
||||
assert list(d_first.tags.values_list("id", flat=True)) == [tag_first.id]
|
||||
assert list(d_second.tags.values_list("id", flat=True)) == [tag_second.id]
|
||||
assert set(d_unrelated.tags.values_list("id", flat=True)) == {
|
||||
tag_inbox.id,
|
||||
tag_no_match.id,
|
||||
}
|
||||
assert d_auto.tags.count() == 0
|
||||
|
||||
self.assertCountEqual(
|
||||
[tag.id for tag in d_first.tags.all()],
|
||||
[self.tag_first.id],
|
||||
@pytest.mark.usefixtures("documents")
|
||||
@pytest.mark.parametrize(
|
||||
"extra_args",
|
||||
[
|
||||
pytest.param([], id="no_base_url"),
|
||||
pytest.param(["--base-url=http://localhost"], id="with_base_url"),
|
||||
],
|
||||
)
|
||||
def test_suggest_does_not_apply_tags(self, extra_args: list[str]) -> None:
|
||||
call_command("document_retagger", "--tags", "--suggest", *extra_args)
|
||||
d_first, d_second, _, d_auto = _get_docs()
|
||||
|
||||
assert d_first.tags.count() == 0
|
||||
assert d_second.tags.count() == 0
|
||||
assert d_auto.tags.count() == 1
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Document type assignment
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.management
|
||||
@pytest.mark.django_db
|
||||
class TestRetaggerDocumentType(DirectoriesMixin):
|
||||
@pytest.mark.usefixtures("documents")
|
||||
def test_add_type(self, document_types: DocumentTypeTuple) -> None:
|
||||
dt_first, dt_second = document_types
|
||||
call_command("document_retagger", "--document_type")
|
||||
d_first, d_second, _, _ = _get_docs()
|
||||
|
||||
assert d_first.document_type == dt_first
|
||||
assert d_second.document_type == dt_second
|
||||
|
||||
@pytest.mark.usefixtures("documents", "document_types")
|
||||
@pytest.mark.parametrize(
|
||||
"extra_args",
|
||||
[
|
||||
pytest.param([], id="no_base_url"),
|
||||
pytest.param(["--base-url=http://localhost"], id="with_base_url"),
|
||||
],
|
||||
)
|
||||
def test_suggest_does_not_apply_document_type(self, extra_args: list[str]) -> None:
|
||||
call_command("document_retagger", "--document_type", "--suggest", *extra_args)
|
||||
d_first, d_second, _, _ = _get_docs()
|
||||
|
||||
assert d_first.document_type is None
|
||||
assert d_second.document_type is None
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("use_first_flag", "expects_assignment"),
|
||||
[
|
||||
pytest.param(["--use-first"], True, id="use_first_assigns_first_match"),
|
||||
pytest.param([], False, id="no_use_first_skips_ambiguous_match"),
|
||||
],
|
||||
)
|
||||
def test_use_first_with_multiple_matches(
|
||||
self,
|
||||
use_first_flag: list[str],
|
||||
*,
|
||||
expects_assignment: bool,
|
||||
) -> None:
|
||||
DocumentTypeFactory(
|
||||
match="ambiguous",
|
||||
matching_algorithm=MatchingModel.MATCH_ANY,
|
||||
)
|
||||
self.assertCountEqual(
|
||||
[tag.id for tag in d_second.tags.all()],
|
||||
[self.tag_second.id],
|
||||
DocumentTypeFactory(
|
||||
match="ambiguous",
|
||||
matching_algorithm=MatchingModel.MATCH_ANY,
|
||||
)
|
||||
self.assertCountEqual(
|
||||
[tag.id for tag in d_unrelated.tags.all()],
|
||||
[self.tag_inbox.id, self.tag_no_match.id],
|
||||
doc = DocumentFactory(content="ambiguous content")
|
||||
|
||||
call_command("document_retagger", "--document_type", *use_first_flag)
|
||||
|
||||
doc.refresh_from_db()
|
||||
assert (doc.document_type is not None) is expects_assignment
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Correspondent assignment
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.management
|
||||
@pytest.mark.django_db
|
||||
class TestRetaggerCorrespondent(DirectoriesMixin):
|
||||
@pytest.mark.usefixtures("documents")
|
||||
def test_add_correspondent(self, correspondents: CorrespondentTuple) -> None:
|
||||
c_first, c_second = correspondents
|
||||
call_command("document_retagger", "--correspondent")
|
||||
d_first, d_second, _, _ = _get_docs()
|
||||
|
||||
assert d_first.correspondent == c_first
|
||||
assert d_second.correspondent == c_second
|
||||
|
||||
@pytest.mark.usefixtures("documents", "correspondents")
|
||||
@pytest.mark.parametrize(
|
||||
"extra_args",
|
||||
[
|
||||
pytest.param([], id="no_base_url"),
|
||||
pytest.param(["--base-url=http://localhost"], id="with_base_url"),
|
||||
],
|
||||
)
|
||||
def test_suggest_does_not_apply_correspondent(self, extra_args: list[str]) -> None:
|
||||
call_command("document_retagger", "--correspondent", "--suggest", *extra_args)
|
||||
d_first, d_second, _, _ = _get_docs()
|
||||
|
||||
assert d_first.correspondent is None
|
||||
assert d_second.correspondent is None
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("use_first_flag", "expects_assignment"),
|
||||
[
|
||||
pytest.param(["--use-first"], True, id="use_first_assigns_first_match"),
|
||||
pytest.param([], False, id="no_use_first_skips_ambiguous_match"),
|
||||
],
|
||||
)
|
||||
def test_use_first_with_multiple_matches(
|
||||
self,
|
||||
use_first_flag: list[str],
|
||||
*,
|
||||
expects_assignment: bool,
|
||||
) -> None:
|
||||
CorrespondentFactory(
|
||||
match="ambiguous",
|
||||
matching_algorithm=MatchingModel.MATCH_ANY,
|
||||
)
|
||||
self.assertEqual(d_auto.tags.count(), 0)
|
||||
|
||||
def test_add_tags_suggest(self) -> None:
|
||||
call_command("document_retagger", "--tags", "--suggest")
|
||||
d_first, d_second, _, d_auto = self.get_updated_docs()
|
||||
|
||||
self.assertEqual(d_first.tags.count(), 0)
|
||||
self.assertEqual(d_second.tags.count(), 0)
|
||||
self.assertEqual(d_auto.tags.count(), 1)
|
||||
|
||||
def test_add_type_suggest(self) -> None:
|
||||
call_command("document_retagger", "--document_type", "--suggest")
|
||||
d_first, d_second, _, _ = self.get_updated_docs()
|
||||
|
||||
self.assertIsNone(d_first.document_type)
|
||||
self.assertIsNone(d_second.document_type)
|
||||
|
||||
def test_add_correspondent_suggest(self) -> None:
|
||||
call_command("document_retagger", "--correspondent", "--suggest")
|
||||
d_first, d_second, _, _ = self.get_updated_docs()
|
||||
|
||||
self.assertIsNone(d_first.correspondent)
|
||||
self.assertIsNone(d_second.correspondent)
|
||||
|
||||
def test_add_tags_suggest_url(self) -> None:
|
||||
call_command(
|
||||
"document_retagger",
|
||||
"--tags",
|
||||
"--suggest",
|
||||
"--base-url=http://localhost",
|
||||
CorrespondentFactory(
|
||||
match="ambiguous",
|
||||
matching_algorithm=MatchingModel.MATCH_ANY,
|
||||
)
|
||||
d_first, d_second, _, d_auto = self.get_updated_docs()
|
||||
doc = DocumentFactory(content="ambiguous content")
|
||||
|
||||
self.assertEqual(d_first.tags.count(), 0)
|
||||
self.assertEqual(d_second.tags.count(), 0)
|
||||
self.assertEqual(d_auto.tags.count(), 1)
|
||||
call_command("document_retagger", "--correspondent", *use_first_flag)
|
||||
|
||||
def test_add_type_suggest_url(self) -> None:
|
||||
call_command(
|
||||
"document_retagger",
|
||||
"--document_type",
|
||||
"--suggest",
|
||||
"--base-url=http://localhost",
|
||||
)
|
||||
d_first, d_second, _, _ = self.get_updated_docs()
|
||||
doc.refresh_from_db()
|
||||
assert (doc.correspondent is not None) is expects_assignment
|
||||
|
||||
self.assertIsNone(d_first.document_type)
|
||||
self.assertIsNone(d_second.document_type)
|
||||
|
||||
def test_add_correspondent_suggest_url(self) -> None:
|
||||
call_command(
|
||||
"document_retagger",
|
||||
"--correspondent",
|
||||
"--suggest",
|
||||
"--base-url=http://localhost",
|
||||
)
|
||||
d_first, d_second, _, _ = self.get_updated_docs()
|
||||
# ---------------------------------------------------------------------------
|
||||
# Storage path assignment
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
self.assertIsNone(d_first.correspondent)
|
||||
self.assertIsNone(d_second.correspondent)
|
||||
|
||||
def test_add_storage_path(self) -> None:
|
||||
@pytest.mark.management
|
||||
@pytest.mark.django_db
|
||||
class TestRetaggerStoragePath(DirectoriesMixin):
|
||||
@pytest.mark.usefixtures("documents")
|
||||
def test_add_storage_path(self, storage_paths: StoragePathTuple) -> None:
|
||||
"""
|
||||
GIVEN:
|
||||
- 2 storage paths with documents which match them
|
||||
- 1 document which matches but has a storage path
|
||||
WHEN:
|
||||
- document retagger is called
|
||||
THEN:
|
||||
- Matching document's storage paths updated
|
||||
- Non-matching documents have no storage path
|
||||
- Existing storage patch left unchanged
|
||||
GIVEN documents matching various storage path rules
|
||||
WHEN document_retagger --storage_path is called
|
||||
THEN matching documents get the correct path; existing path is unchanged
|
||||
"""
|
||||
call_command(
|
||||
"document_retagger",
|
||||
"--storage_path",
|
||||
)
|
||||
d_first, d_second, d_unrelated, d_auto = self.get_updated_docs()
|
||||
sp1, sp2, sp3 = storage_paths
|
||||
call_command("document_retagger", "--storage_path")
|
||||
d_first, d_second, d_unrelated, d_auto = _get_docs()
|
||||
|
||||
self.assertEqual(d_first.storage_path, self.sp2)
|
||||
self.assertEqual(d_auto.storage_path, self.sp1)
|
||||
self.assertIsNone(d_second.storage_path)
|
||||
self.assertEqual(d_unrelated.storage_path, self.sp3)
|
||||
assert d_first.storage_path == sp2
|
||||
assert d_auto.storage_path == sp1
|
||||
assert d_second.storage_path is None
|
||||
assert d_unrelated.storage_path == sp3
|
||||
|
||||
def test_overwrite_storage_path(self) -> None:
|
||||
@pytest.mark.usefixtures("documents")
|
||||
def test_overwrite_storage_path(self, storage_paths: StoragePathTuple) -> None:
|
||||
"""
|
||||
GIVEN:
|
||||
- 2 storage paths with documents which match them
|
||||
- 1 document which matches but has a storage path
|
||||
WHEN:
|
||||
- document retagger is called with overwrite
|
||||
THEN:
|
||||
- Matching document's storage paths updated
|
||||
- Non-matching documents have no storage path
|
||||
- Existing storage patch overwritten
|
||||
GIVEN a document with an existing storage path that matches a different rule
|
||||
WHEN document_retagger --storage_path --overwrite is called
|
||||
THEN the existing path is replaced by the newly matched path
|
||||
"""
|
||||
sp1, sp2, _ = storage_paths
|
||||
call_command("document_retagger", "--storage_path", "--overwrite")
|
||||
d_first, d_second, d_unrelated, d_auto = self.get_updated_docs()
|
||||
d_first, d_second, d_unrelated, d_auto = _get_docs()
|
||||
|
||||
self.assertEqual(d_first.storage_path, self.sp2)
|
||||
self.assertEqual(d_auto.storage_path, self.sp1)
|
||||
self.assertIsNone(d_second.storage_path)
|
||||
self.assertEqual(d_unrelated.storage_path, self.sp2)
|
||||
assert d_first.storage_path == sp2
|
||||
assert d_auto.storage_path == sp1
|
||||
assert d_second.storage_path is None
|
||||
assert d_unrelated.storage_path == sp2
|
||||
|
||||
def test_id_range_parameter(self) -> None:
|
||||
commandOutput = ""
|
||||
Document.objects.create(
|
||||
checksum="E",
|
||||
title="E",
|
||||
content="NOT the first document",
|
||||
@pytest.mark.parametrize(
|
||||
("use_first_flag", "expects_assignment"),
|
||||
[
|
||||
pytest.param(["--use-first"], True, id="use_first_assigns_first_match"),
|
||||
pytest.param([], False, id="no_use_first_skips_ambiguous_match"),
|
||||
],
|
||||
)
|
||||
def test_use_first_with_multiple_matches(
|
||||
self,
|
||||
use_first_flag: list[str],
|
||||
*,
|
||||
expects_assignment: bool,
|
||||
) -> None:
|
||||
StoragePathFactory(
|
||||
match="ambiguous",
|
||||
matching_algorithm=MatchingModel.MATCH_ANY,
|
||||
)
|
||||
call_command("document_retagger", "--tags", "--id-range", "1", "2")
|
||||
# The retagger shouldn`t apply the 'first' tag to our new document
|
||||
self.assertEqual(Document.objects.filter(tags__id=self.tag_first.id).count(), 1)
|
||||
StoragePathFactory(
|
||||
match="ambiguous",
|
||||
matching_algorithm=MatchingModel.MATCH_ANY,
|
||||
)
|
||||
doc = DocumentFactory(content="ambiguous content")
|
||||
|
||||
try:
|
||||
commandOutput = call_command("document_retagger", "--tags", "--id-range")
|
||||
except CommandError:
|
||||
# Just ignore the error
|
||||
None
|
||||
self.assertIn(commandOutput, "Error: argument --id-range: expected 2 arguments")
|
||||
call_command("document_retagger", "--storage_path", *use_first_flag)
|
||||
|
||||
try:
|
||||
commandOutput = call_command(
|
||||
"document_retagger",
|
||||
"--tags",
|
||||
"--id-range",
|
||||
"a",
|
||||
"b",
|
||||
)
|
||||
except CommandError:
|
||||
# Just ignore the error
|
||||
None
|
||||
self.assertIn(commandOutput, "error: argument --id-range: invalid int value:")
|
||||
doc.refresh_from_db()
|
||||
assert (doc.storage_path is not None) is expects_assignment
|
||||
|
||||
call_command("document_retagger", "--tags", "--id-range", "1", "9999")
|
||||
# Now we should have 2 documents
|
||||
self.assertEqual(Document.objects.filter(tags__id=self.tag_first.id).count(), 2)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# ID range filtering
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.management
|
||||
@pytest.mark.django_db
|
||||
class TestRetaggerIdRange(DirectoriesMixin):
|
||||
@pytest.mark.usefixtures("documents")
|
||||
@pytest.mark.parametrize(
|
||||
("id_range_args", "expected_count"),
|
||||
[
|
||||
pytest.param(["1", "2"], 1, id="narrow_range_limits_scope"),
|
||||
pytest.param(["1", "9999"], 2, id="wide_range_tags_all_matches"),
|
||||
],
|
||||
)
|
||||
def test_id_range_limits_scope(
|
||||
self,
|
||||
tags: TagTuple,
|
||||
id_range_args: list[str],
|
||||
expected_count: int,
|
||||
) -> None:
|
||||
DocumentFactory(content="NOT the first document")
|
||||
call_command("document_retagger", "--tags", "--id-range", *id_range_args)
|
||||
tag_first, *_ = tags
|
||||
assert Document.objects.filter(tags__id=tag_first.id).count() == expected_count
|
||||
|
||||
@pytest.mark.usefixtures("documents")
|
||||
@pytest.mark.parametrize(
|
||||
"args",
|
||||
[
|
||||
pytest.param(["--tags", "--id-range"], id="missing_both_values"),
|
||||
pytest.param(["--tags", "--id-range", "a", "b"], id="non_integer_values"),
|
||||
],
|
||||
)
|
||||
def test_id_range_invalid_arguments_raise(self, args: list[str]) -> None:
|
||||
with pytest.raises((CommandError, SystemExit)):
|
||||
call_command("document_retagger", *args)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Edge cases
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.management
|
||||
@pytest.mark.django_db
|
||||
class TestRetaggerEdgeCases(DirectoriesMixin):
|
||||
@pytest.mark.usefixtures("documents")
|
||||
def test_no_targets_exits_cleanly(self) -> None:
|
||||
"""Calling the retagger with no classifier targets should not raise."""
|
||||
call_command("document_retagger")
|
||||
|
||||
@pytest.mark.usefixtures("documents")
|
||||
def test_inbox_only_skips_non_inbox_documents(self) -> None:
|
||||
"""--inbox-only must restrict processing to documents with an inbox tag."""
|
||||
call_command("document_retagger", "--tags", "--inbox-only")
|
||||
d_first, _, d_unrelated, _ = _get_docs()
|
||||
|
||||
assert d_first.tags.count() == 0
|
||||
assert d_unrelated.tags.count() == 2
|
||||
|
||||
@@ -643,7 +643,9 @@ class TestWorkflows(
|
||||
|
||||
expected_str = f"Document did not match {w}"
|
||||
self.assertIn(expected_str, cm.output[0])
|
||||
expected_str = f"Document path {test_file} does not match"
|
||||
expected_str = (
|
||||
f"Document path {Path(test_file).resolve(strict=False)} does not match"
|
||||
)
|
||||
self.assertIn(expected_str, cm.output[1])
|
||||
|
||||
def test_workflow_no_match_mail_rule(self) -> None:
|
||||
@@ -2010,6 +2012,36 @@ class TestWorkflows(
|
||||
doc.refresh_from_db()
|
||||
self.assertEqual(doc.owner, self.user2)
|
||||
|
||||
@mock.patch("documents.tasks.send_websocket_document_updated")
|
||||
def test_workflow_scheduled_trigger_sends_websocket_update(
|
||||
self,
|
||||
mock_send_websocket_document_updated,
|
||||
) -> None:
|
||||
trigger = WorkflowTrigger.objects.create(
|
||||
type=WorkflowTrigger.WorkflowTriggerType.SCHEDULED,
|
||||
schedule_offset_days=1,
|
||||
schedule_date_field=WorkflowTrigger.ScheduleDateField.CREATED,
|
||||
)
|
||||
action = WorkflowAction.objects.create(assign_owner=self.user2)
|
||||
workflow = Workflow.objects.create(name="Workflow 1", order=0)
|
||||
workflow.triggers.add(trigger)
|
||||
workflow.actions.add(action)
|
||||
|
||||
doc = Document.objects.create(
|
||||
title="sample test",
|
||||
correspondent=self.c,
|
||||
original_filename="sample.pdf",
|
||||
created=timezone.now() - timedelta(days=2),
|
||||
)
|
||||
|
||||
tasks.check_scheduled_workflows()
|
||||
|
||||
self.assertEqual(mock_send_websocket_document_updated.call_count, 1)
|
||||
self.assertEqual(
|
||||
mock_send_websocket_document_updated.call_args.kwargs["document"].pk,
|
||||
doc.pk,
|
||||
)
|
||||
|
||||
def test_workflow_scheduled_trigger_added(self) -> None:
|
||||
"""
|
||||
GIVEN:
|
||||
|
||||
@@ -2,7 +2,7 @@ msgid ""
|
||||
msgstr ""
|
||||
"Project-Id-Version: paperless-ngx\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"POT-Creation-Date: 2026-03-02 16:22+0000\n"
|
||||
"POT-Creation-Date: 2026-03-04 00:28+0000\n"
|
||||
"PO-Revision-Date: 2022-02-17 04:17\n"
|
||||
"Last-Translator: \n"
|
||||
"Language-Team: English\n"
|
||||
@@ -1856,151 +1856,151 @@ msgstr ""
|
||||
msgid "paperless application settings"
|
||||
msgstr ""
|
||||
|
||||
#: paperless/settings/__init__.py:746
|
||||
#: paperless/settings/__init__.py:752
|
||||
msgid "English (US)"
|
||||
msgstr ""
|
||||
|
||||
#: paperless/settings/__init__.py:747
|
||||
#: paperless/settings/__init__.py:753
|
||||
msgid "Arabic"
|
||||
msgstr ""
|
||||
|
||||
#: paperless/settings/__init__.py:748
|
||||
#: paperless/settings/__init__.py:754
|
||||
msgid "Afrikaans"
|
||||
msgstr ""
|
||||
|
||||
#: paperless/settings/__init__.py:749
|
||||
#: paperless/settings/__init__.py:755
|
||||
msgid "Belarusian"
|
||||
msgstr ""
|
||||
|
||||
#: paperless/settings/__init__.py:750
|
||||
#: paperless/settings/__init__.py:756
|
||||
msgid "Bulgarian"
|
||||
msgstr ""
|
||||
|
||||
#: paperless/settings/__init__.py:751
|
||||
#: paperless/settings/__init__.py:757
|
||||
msgid "Catalan"
|
||||
msgstr ""
|
||||
|
||||
#: paperless/settings/__init__.py:752
|
||||
#: paperless/settings/__init__.py:758
|
||||
msgid "Czech"
|
||||
msgstr ""
|
||||
|
||||
#: paperless/settings/__init__.py:753
|
||||
#: paperless/settings/__init__.py:759
|
||||
msgid "Danish"
|
||||
msgstr ""
|
||||
|
||||
#: paperless/settings/__init__.py:754
|
||||
#: paperless/settings/__init__.py:760
|
||||
msgid "German"
|
||||
msgstr ""
|
||||
|
||||
#: paperless/settings/__init__.py:755
|
||||
#: paperless/settings/__init__.py:761
|
||||
msgid "Greek"
|
||||
msgstr ""
|
||||
|
||||
#: paperless/settings/__init__.py:756
|
||||
#: paperless/settings/__init__.py:762
|
||||
msgid "English (GB)"
|
||||
msgstr ""
|
||||
|
||||
#: paperless/settings/__init__.py:757
|
||||
#: paperless/settings/__init__.py:763
|
||||
msgid "Spanish"
|
||||
msgstr ""
|
||||
|
||||
#: paperless/settings/__init__.py:758
|
||||
#: paperless/settings/__init__.py:764
|
||||
msgid "Persian"
|
||||
msgstr ""
|
||||
|
||||
#: paperless/settings/__init__.py:759
|
||||
#: paperless/settings/__init__.py:765
|
||||
msgid "Finnish"
|
||||
msgstr ""
|
||||
|
||||
#: paperless/settings/__init__.py:760
|
||||
#: paperless/settings/__init__.py:766
|
||||
msgid "French"
|
||||
msgstr ""
|
||||
|
||||
#: paperless/settings/__init__.py:761
|
||||
#: paperless/settings/__init__.py:767
|
||||
msgid "Hungarian"
|
||||
msgstr ""
|
||||
|
||||
#: paperless/settings/__init__.py:762
|
||||
#: paperless/settings/__init__.py:768
|
||||
msgid "Indonesian"
|
||||
msgstr ""
|
||||
|
||||
#: paperless/settings/__init__.py:763
|
||||
#: paperless/settings/__init__.py:769
|
||||
msgid "Italian"
|
||||
msgstr ""
|
||||
|
||||
#: paperless/settings/__init__.py:764
|
||||
#: paperless/settings/__init__.py:770
|
||||
msgid "Japanese"
|
||||
msgstr ""
|
||||
|
||||
#: paperless/settings/__init__.py:765
|
||||
#: paperless/settings/__init__.py:771
|
||||
msgid "Korean"
|
||||
msgstr ""
|
||||
|
||||
#: paperless/settings/__init__.py:766
|
||||
#: paperless/settings/__init__.py:772
|
||||
msgid "Luxembourgish"
|
||||
msgstr ""
|
||||
|
||||
#: paperless/settings/__init__.py:767
|
||||
#: paperless/settings/__init__.py:773
|
||||
msgid "Norwegian"
|
||||
msgstr ""
|
||||
|
||||
#: paperless/settings/__init__.py:768
|
||||
#: paperless/settings/__init__.py:774
|
||||
msgid "Dutch"
|
||||
msgstr ""
|
||||
|
||||
#: paperless/settings/__init__.py:769
|
||||
#: paperless/settings/__init__.py:775
|
||||
msgid "Polish"
|
||||
msgstr ""
|
||||
|
||||
#: paperless/settings/__init__.py:770
|
||||
#: paperless/settings/__init__.py:776
|
||||
msgid "Portuguese (Brazil)"
|
||||
msgstr ""
|
||||
|
||||
#: paperless/settings/__init__.py:771
|
||||
#: paperless/settings/__init__.py:777
|
||||
msgid "Portuguese"
|
||||
msgstr ""
|
||||
|
||||
#: paperless/settings/__init__.py:772
|
||||
#: paperless/settings/__init__.py:778
|
||||
msgid "Romanian"
|
||||
msgstr ""
|
||||
|
||||
#: paperless/settings/__init__.py:773
|
||||
#: paperless/settings/__init__.py:779
|
||||
msgid "Russian"
|
||||
msgstr ""
|
||||
|
||||
#: paperless/settings/__init__.py:774
|
||||
#: paperless/settings/__init__.py:780
|
||||
msgid "Slovak"
|
||||
msgstr ""
|
||||
|
||||
#: paperless/settings/__init__.py:775
|
||||
#: paperless/settings/__init__.py:781
|
||||
msgid "Slovenian"
|
||||
msgstr ""
|
||||
|
||||
#: paperless/settings/__init__.py:776
|
||||
#: paperless/settings/__init__.py:782
|
||||
msgid "Serbian"
|
||||
msgstr ""
|
||||
|
||||
#: paperless/settings/__init__.py:777
|
||||
#: paperless/settings/__init__.py:783
|
||||
msgid "Swedish"
|
||||
msgstr ""
|
||||
|
||||
#: paperless/settings/__init__.py:778
|
||||
#: paperless/settings/__init__.py:784
|
||||
msgid "Turkish"
|
||||
msgstr ""
|
||||
|
||||
#: paperless/settings/__init__.py:779
|
||||
#: paperless/settings/__init__.py:785
|
||||
msgid "Ukrainian"
|
||||
msgstr ""
|
||||
|
||||
#: paperless/settings/__init__.py:780
|
||||
#: paperless/settings/__init__.py:786
|
||||
msgid "Vietnamese"
|
||||
msgstr ""
|
||||
|
||||
#: paperless/settings/__init__.py:781
|
||||
#: paperless/settings/__init__.py:787
|
||||
msgid "Chinese Simplified"
|
||||
msgstr ""
|
||||
|
||||
#: paperless/settings/__init__.py:782
|
||||
#: paperless/settings/__init__.py:788
|
||||
msgid "Chinese Traditional"
|
||||
msgstr ""
|
||||
|
||||
|
||||
@@ -204,6 +204,61 @@ def audit_log_check(app_configs, **kwargs):
|
||||
return result
|
||||
|
||||
|
||||
@register()
|
||||
def check_v3_minimum_upgrade_version(
|
||||
app_configs: object,
|
||||
**kwargs: object,
|
||||
) -> list[Error]:
|
||||
"""Enforce that upgrades to v3 must start from v2.20.9.
|
||||
|
||||
v3 squashes all prior migrations into 0001_squashed and 0002_squashed.
|
||||
If a user skips v2.20.9, the data migration in 1075_workflowaction_order
|
||||
never runs and the squash may apply schema changes against an incomplete
|
||||
database state.
|
||||
"""
|
||||
from django.db import DatabaseError
|
||||
from django.db import OperationalError
|
||||
|
||||
try:
|
||||
all_tables = connections["default"].introspection.table_names()
|
||||
|
||||
if "django_migrations" not in all_tables:
|
||||
return []
|
||||
|
||||
with connections["default"].cursor() as cursor:
|
||||
cursor.execute(
|
||||
"SELECT name FROM django_migrations WHERE app = %s",
|
||||
["documents"],
|
||||
)
|
||||
applied: set[str] = {row[0] for row in cursor.fetchall()}
|
||||
|
||||
if not applied:
|
||||
return []
|
||||
|
||||
# Already in a valid v3 state
|
||||
if {"0001_squashed", "0002_squashed"} & applied:
|
||||
return []
|
||||
|
||||
# On v2.20.9 exactly — squash will pick up cleanly from here
|
||||
if "1075_workflowaction_order" in applied:
|
||||
return []
|
||||
|
||||
except (DatabaseError, OperationalError):
|
||||
return []
|
||||
|
||||
return [
|
||||
Error(
|
||||
"Cannot upgrade to Paperless-ngx v3 from this version.",
|
||||
hint=(
|
||||
"Upgrading to v3 can only be performed from v2.20.9."
|
||||
"Please upgrade to v2.20.9, run migrations, then upgrade to v3."
|
||||
"See https://docs.paperless-ngx.com/setup/#upgrading for details."
|
||||
),
|
||||
id="paperless.E002",
|
||||
),
|
||||
]
|
||||
|
||||
|
||||
@register()
|
||||
def check_deprecated_db_settings(
|
||||
app_configs: object,
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import json
|
||||
from typing import Any
|
||||
|
||||
from asgiref.sync import async_to_sync
|
||||
from channels.exceptions import AcceptConnection
|
||||
@@ -52,3 +53,10 @@ class StatusConsumer(WebsocketConsumer):
|
||||
self.close()
|
||||
else:
|
||||
self.send(json.dumps(event))
|
||||
|
||||
def document_updated(self, event: Any) -> None:
|
||||
if not self._authenticated():
|
||||
self.close()
|
||||
else:
|
||||
if self._can_view(event["data"]):
|
||||
self.send(json.dumps(event))
|
||||
|
||||
@@ -130,7 +130,7 @@ def _parse_redis_url(env_redis: str | None) -> tuple[str, str]:
|
||||
if "unix" in env_redis.lower():
|
||||
# channels_redis socket format, looks like:
|
||||
# "unix:///path/to/redis.sock"
|
||||
_, path = env_redis.split(":")
|
||||
_, path = env_redis.split(":", 1)
|
||||
# Optionally setting a db number
|
||||
if "?db=" in env_redis:
|
||||
path, number = path.split("?db=")
|
||||
@@ -141,7 +141,7 @@ def _parse_redis_url(env_redis: str | None) -> tuple[str, str]:
|
||||
elif "+socket" in env_redis.lower():
|
||||
# celery socket style, looks like:
|
||||
# "redis+socket:///path/to/redis.sock"
|
||||
_, path = env_redis.split(":")
|
||||
_, path = env_redis.split(":", 1)
|
||||
if "?virtual_host=" in env_redis:
|
||||
# Virtual host (aka db number)
|
||||
path, number = path.split("?virtual_host=")
|
||||
@@ -493,18 +493,24 @@ TEMPLATES = [
|
||||
},
|
||||
]
|
||||
|
||||
_CHANNELS_BACKEND = os.environ.get(
|
||||
"PAPERLESS_CHANNELS_BACKEND",
|
||||
"channels_redis.pubsub.RedisPubSubChannelLayer",
|
||||
)
|
||||
CHANNEL_LAYERS = {
|
||||
"default": {
|
||||
"BACKEND": "channels_redis.pubsub.RedisPubSubChannelLayer",
|
||||
"CONFIG": {
|
||||
"hosts": [_CHANNELS_REDIS_URL],
|
||||
"capacity": 2000, # default 100
|
||||
"expiry": 15, # default 60
|
||||
"prefix": _REDIS_KEY_PREFIX,
|
||||
},
|
||||
"BACKEND": _CHANNELS_BACKEND,
|
||||
},
|
||||
}
|
||||
|
||||
if _CHANNELS_BACKEND.startswith("channels_redis."):
|
||||
CHANNEL_LAYERS["default"]["CONFIG"] = {
|
||||
"hosts": [_CHANNELS_REDIS_URL],
|
||||
"capacity": 2000, # default 100
|
||||
"expiry": 15, # default 60
|
||||
"prefix": _REDIS_KEY_PREFIX,
|
||||
}
|
||||
|
||||
###############################################################################
|
||||
# Email (SMTP) Backend #
|
||||
###############################################################################
|
||||
|
||||
@@ -3,6 +3,7 @@ from pathlib import Path
|
||||
from unittest import mock
|
||||
|
||||
import pytest
|
||||
from django.core.checks import Error
|
||||
from django.core.checks import Warning
|
||||
from django.test import TestCase
|
||||
from django.test import override_settings
|
||||
@@ -13,6 +14,7 @@ from documents.tests.utils import FileSystemAssertsMixin
|
||||
from paperless.checks import audit_log_check
|
||||
from paperless.checks import binaries_check
|
||||
from paperless.checks import check_deprecated_db_settings
|
||||
from paperless.checks import check_v3_minimum_upgrade_version
|
||||
from paperless.checks import debug_mode_check
|
||||
from paperless.checks import paths_check
|
||||
from paperless.checks import settings_values_check
|
||||
@@ -395,3 +397,240 @@ class TestDeprecatedDbSettings:
|
||||
|
||||
assert len(result) == 1
|
||||
assert "PAPERLESS_DBSSLCERT" in result[0].msg
|
||||
|
||||
|
||||
class TestV3MinimumUpgradeVersionCheck:
|
||||
"""Test suite for check_v3_minimum_upgrade_version system check."""
|
||||
|
||||
@pytest.fixture
|
||||
def build_conn_mock(self, mocker: MockerFixture):
|
||||
"""Factory fixture that builds a connections['default'] mock.
|
||||
|
||||
Usage::
|
||||
|
||||
conn = build_conn_mock(tables=["django_migrations"], applied=["1075_..."])
|
||||
"""
|
||||
|
||||
def _build(tables: list[str], applied: list[str]) -> mock.MagicMock:
|
||||
conn = mocker.MagicMock()
|
||||
conn.introspection.table_names.return_value = tables
|
||||
cursor = conn.cursor.return_value.__enter__.return_value
|
||||
cursor.fetchall.return_value = [(name,) for name in applied]
|
||||
return conn
|
||||
|
||||
return _build
|
||||
|
||||
def test_no_migrations_table_fresh_install(
|
||||
self,
|
||||
mocker: MockerFixture,
|
||||
build_conn_mock,
|
||||
) -> None:
|
||||
"""
|
||||
GIVEN:
|
||||
- No django_migrations table exists in the database
|
||||
WHEN:
|
||||
- The v3 upgrade check runs
|
||||
THEN:
|
||||
- No errors are reported (fresh install, nothing to enforce)
|
||||
"""
|
||||
mocker.patch.dict(
|
||||
"paperless.checks.connections",
|
||||
{"default": build_conn_mock([], [])},
|
||||
)
|
||||
assert check_v3_minimum_upgrade_version(None) == []
|
||||
|
||||
def test_no_documents_migrations_fresh_install(
|
||||
self,
|
||||
mocker: MockerFixture,
|
||||
build_conn_mock,
|
||||
) -> None:
|
||||
"""
|
||||
GIVEN:
|
||||
- django_migrations table exists but has no documents app rows
|
||||
WHEN:
|
||||
- The v3 upgrade check runs
|
||||
THEN:
|
||||
- No errors are reported (fresh install, nothing to enforce)
|
||||
"""
|
||||
mocker.patch.dict(
|
||||
"paperless.checks.connections",
|
||||
{"default": build_conn_mock(["django_migrations"], [])},
|
||||
)
|
||||
assert check_v3_minimum_upgrade_version(None) == []
|
||||
|
||||
def test_v3_state_with_0001_squashed(
|
||||
self,
|
||||
mocker: MockerFixture,
|
||||
build_conn_mock,
|
||||
) -> None:
|
||||
"""
|
||||
GIVEN:
|
||||
- 0001_squashed is recorded in django_migrations
|
||||
WHEN:
|
||||
- The v3 upgrade check runs
|
||||
THEN:
|
||||
- No errors are reported (DB is already in a valid v3 state)
|
||||
"""
|
||||
mocker.patch.dict(
|
||||
"paperless.checks.connections",
|
||||
{
|
||||
"default": build_conn_mock(
|
||||
["django_migrations"],
|
||||
["0001_squashed", "0002_squashed", "0003_workflowaction_order"],
|
||||
),
|
||||
},
|
||||
)
|
||||
assert check_v3_minimum_upgrade_version(None) == []
|
||||
|
||||
def test_v3_state_with_0002_squashed_only(
|
||||
self,
|
||||
mocker: MockerFixture,
|
||||
build_conn_mock,
|
||||
) -> None:
|
||||
"""
|
||||
GIVEN:
|
||||
- Only 0002_squashed is recorded in django_migrations
|
||||
WHEN:
|
||||
- The v3 upgrade check runs
|
||||
THEN:
|
||||
- No errors are reported (0002_squashed alone confirms a valid v3 state)
|
||||
"""
|
||||
mocker.patch.dict(
|
||||
"paperless.checks.connections",
|
||||
{"default": build_conn_mock(["django_migrations"], ["0002_squashed"])},
|
||||
)
|
||||
assert check_v3_minimum_upgrade_version(None) == []
|
||||
|
||||
def test_v2_20_9_state_ready_to_upgrade(
|
||||
self,
|
||||
mocker: MockerFixture,
|
||||
build_conn_mock,
|
||||
) -> None:
|
||||
"""
|
||||
GIVEN:
|
||||
- 1075_workflowaction_order (the last v2.20.9 migration) is in the DB
|
||||
WHEN:
|
||||
- The v3 upgrade check runs
|
||||
THEN:
|
||||
- No errors are reported (squash will pick up cleanly from this state)
|
||||
"""
|
||||
mocker.patch.dict(
|
||||
"paperless.checks.connections",
|
||||
{
|
||||
"default": build_conn_mock(
|
||||
["django_migrations"],
|
||||
[
|
||||
"1074_workflowrun_deleted_at_workflowrun_restored_at_and_more",
|
||||
"1075_workflowaction_order",
|
||||
],
|
||||
),
|
||||
},
|
||||
)
|
||||
assert check_v3_minimum_upgrade_version(None) == []
|
||||
|
||||
def test_v2_20_8_raises_error(
|
||||
self,
|
||||
mocker: MockerFixture,
|
||||
build_conn_mock,
|
||||
) -> None:
|
||||
"""
|
||||
GIVEN:
|
||||
- 1074 (last v2.20.8 migration) is applied but 1075 is not
|
||||
WHEN:
|
||||
- The v3 upgrade check runs
|
||||
THEN:
|
||||
- An Error with id paperless.E002 is returned
|
||||
"""
|
||||
mocker.patch.dict(
|
||||
"paperless.checks.connections",
|
||||
{
|
||||
"default": build_conn_mock(
|
||||
["django_migrations"],
|
||||
["1074_workflowrun_deleted_at_workflowrun_restored_at_and_more"],
|
||||
),
|
||||
},
|
||||
)
|
||||
result = check_v3_minimum_upgrade_version(None)
|
||||
assert len(result) == 1
|
||||
assert isinstance(result[0], Error)
|
||||
assert result[0].id == "paperless.E002"
|
||||
|
||||
def test_very_old_version_raises_error(
|
||||
self,
|
||||
mocker: MockerFixture,
|
||||
build_conn_mock,
|
||||
) -> None:
|
||||
"""
|
||||
GIVEN:
|
||||
- Only old migrations (well below v2.20.9) are applied
|
||||
WHEN:
|
||||
- The v3 upgrade check runs
|
||||
THEN:
|
||||
- An Error with id paperless.E002 is returned
|
||||
"""
|
||||
mocker.patch.dict(
|
||||
"paperless.checks.connections",
|
||||
{
|
||||
"default": build_conn_mock(
|
||||
["django_migrations"],
|
||||
["1000_update_paperless_all", "1022_paperlesstask"],
|
||||
),
|
||||
},
|
||||
)
|
||||
result = check_v3_minimum_upgrade_version(None)
|
||||
assert len(result) == 1
|
||||
assert isinstance(result[0], Error)
|
||||
assert result[0].id == "paperless.E002"
|
||||
|
||||
def test_error_hint_mentions_v2_20_9(
|
||||
self,
|
||||
mocker: MockerFixture,
|
||||
build_conn_mock,
|
||||
) -> None:
|
||||
"""
|
||||
GIVEN:
|
||||
- DB is on an old v2 version (pre-v2.20.9)
|
||||
WHEN:
|
||||
- The v3 upgrade check runs
|
||||
THEN:
|
||||
- The error hint explicitly references v2.20.9 so users know what to do
|
||||
"""
|
||||
mocker.patch.dict(
|
||||
"paperless.checks.connections",
|
||||
{"default": build_conn_mock(["django_migrations"], ["1022_paperlesstask"])},
|
||||
)
|
||||
result = check_v3_minimum_upgrade_version(None)
|
||||
assert len(result) == 1
|
||||
assert "v2.20.9" in result[0].hint
|
||||
|
||||
def test_db_error_is_swallowed(self, mocker: MockerFixture) -> None:
|
||||
"""
|
||||
GIVEN:
|
||||
- A DatabaseError is raised when querying the DB
|
||||
WHEN:
|
||||
- The v3 upgrade check runs
|
||||
THEN:
|
||||
- No exception propagates and an empty list is returned
|
||||
"""
|
||||
from django.db import DatabaseError
|
||||
|
||||
conn = mocker.MagicMock()
|
||||
conn.introspection.table_names.side_effect = DatabaseError("connection refused")
|
||||
mocker.patch.dict("paperless.checks.connections", {"default": conn})
|
||||
assert check_v3_minimum_upgrade_version(None) == []
|
||||
|
||||
def test_operational_error_is_swallowed(self, mocker: MockerFixture) -> None:
|
||||
"""
|
||||
GIVEN:
|
||||
- An OperationalError is raised when querying the DB
|
||||
WHEN:
|
||||
- The v3 upgrade check runs
|
||||
THEN:
|
||||
- No exception propagates and an empty list is returned
|
||||
"""
|
||||
from django.db import OperationalError
|
||||
|
||||
conn = mocker.MagicMock()
|
||||
conn.introspection.table_names.side_effect = OperationalError("DB unavailable")
|
||||
mocker.patch.dict("paperless.checks.connections", {"default": conn})
|
||||
assert check_v3_minimum_upgrade_version(None) == []
|
||||
|
||||
@@ -48,6 +48,20 @@ class TestWebSockets(TestCase):
|
||||
mock_close.assert_called_once()
|
||||
mock_close.reset_mock()
|
||||
|
||||
message = {
|
||||
"type": "document_updated",
|
||||
"data": {"document_id": 10, "modified": "2026-02-17T00:00:00Z"},
|
||||
}
|
||||
|
||||
await channel_layer.group_send(
|
||||
"status_updates",
|
||||
message,
|
||||
)
|
||||
await communicator.receive_nothing()
|
||||
|
||||
mock_close.assert_called_once()
|
||||
mock_close.reset_mock()
|
||||
|
||||
message = {"type": "documents_deleted", "data": {"documents": [1, 2, 3]}}
|
||||
|
||||
await channel_layer.group_send(
|
||||
@@ -158,6 +172,40 @@ class TestWebSockets(TestCase):
|
||||
|
||||
await communicator.disconnect()
|
||||
|
||||
@mock.patch("paperless.consumers.StatusConsumer._can_view")
|
||||
@mock.patch("paperless.consumers.StatusConsumer._authenticated")
|
||||
async def test_receive_document_updated(self, _authenticated, _can_view) -> None:
|
||||
_authenticated.return_value = True
|
||||
_can_view.return_value = True
|
||||
|
||||
communicator = WebsocketCommunicator(application, "/ws/status/")
|
||||
connected, _ = await communicator.connect()
|
||||
self.assertTrue(connected)
|
||||
|
||||
message = {
|
||||
"type": "document_updated",
|
||||
"data": {
|
||||
"document_id": 10,
|
||||
"modified": "2026-02-17T00:00:00Z",
|
||||
"owner_id": 1,
|
||||
"users_can_view": [1],
|
||||
"groups_can_view": [],
|
||||
},
|
||||
}
|
||||
|
||||
channel_layer = get_channel_layer()
|
||||
assert channel_layer is not None
|
||||
await channel_layer.group_send(
|
||||
"status_updates",
|
||||
message,
|
||||
)
|
||||
|
||||
response = await communicator.receive_json_from()
|
||||
|
||||
self.assertEqual(response, message)
|
||||
|
||||
await communicator.disconnect()
|
||||
|
||||
@mock.patch("channels.layers.InMemoryChannelLayer.group_send")
|
||||
def test_manager_send_progress(self, mock_group_send) -> None:
|
||||
with ProgressManager(task_id="test") as manager:
|
||||
@@ -190,7 +238,10 @@ class TestWebSockets(TestCase):
|
||||
)
|
||||
|
||||
@mock.patch("channels.layers.InMemoryChannelLayer.group_send")
|
||||
def test_manager_send_documents_deleted(self, mock_group_send) -> None:
|
||||
def test_manager_send_documents_deleted(
|
||||
self,
|
||||
mock_group_send: mock.MagicMock,
|
||||
) -> None:
|
||||
with DocumentsStatusManager() as manager:
|
||||
manager.send_documents_deleted([1, 2, 3])
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
# Generated by Django 5.2.9 on 2026-01-20 18:46
|
||||
# Generated by Django 5.2.11 on 2026-03-03 16:27
|
||||
|
||||
import django.db.models.deletion
|
||||
import django.utils.timezone
|
||||
@@ -15,6 +15,50 @@ class Migration(migrations.Migration):
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
]
|
||||
|
||||
replaces = [
|
||||
("paperless_mail", "0001_initial"),
|
||||
("paperless_mail", "0001_initial_squashed_0009_mailrule_assign_tags"),
|
||||
("paperless_mail", "0002_auto_20201117_1334"),
|
||||
("paperless_mail", "0003_auto_20201118_1940"),
|
||||
("paperless_mail", "0004_mailrule_order"),
|
||||
("paperless_mail", "0005_help_texts"),
|
||||
("paperless_mail", "0006_auto_20210101_2340"),
|
||||
("paperless_mail", "0007_auto_20210106_0138"),
|
||||
("paperless_mail", "0008_auto_20210516_0940"),
|
||||
("paperless_mail", "0009_alter_mailrule_action_alter_mailrule_folder"),
|
||||
("paperless_mail", "0009_mailrule_assign_tags"),
|
||||
("paperless_mail", "0010_auto_20220311_1602"),
|
||||
("paperless_mail", "0011_remove_mailrule_assign_tag"),
|
||||
(
|
||||
"paperless_mail",
|
||||
"0011_remove_mailrule_assign_tag_squashed_0024_alter_mailrule_name_and_more",
|
||||
),
|
||||
("paperless_mail", "0012_alter_mailrule_assign_tags"),
|
||||
("paperless_mail", "0013_merge_20220412_1051"),
|
||||
("paperless_mail", "0014_alter_mailrule_action"),
|
||||
("paperless_mail", "0015_alter_mailrule_action"),
|
||||
("paperless_mail", "0016_mailrule_consumption_scope"),
|
||||
("paperless_mail", "0017_mailaccount_owner_mailrule_owner"),
|
||||
("paperless_mail", "0018_processedmail"),
|
||||
("paperless_mail", "0019_mailrule_filter_to"),
|
||||
("paperless_mail", "0020_mailaccount_is_token"),
|
||||
("paperless_mail", "0021_alter_mailaccount_password"),
|
||||
("paperless_mail", "0022_mailrule_assign_owner_from_rule_and_more"),
|
||||
("paperless_mail", "0023_remove_mailrule_filter_attachment_filename_and_more"),
|
||||
("paperless_mail", "0024_alter_mailrule_name_and_more"),
|
||||
(
|
||||
"paperless_mail",
|
||||
"0025_alter_mailaccount_owner_alter_mailrule_owner_and_more",
|
||||
),
|
||||
("paperless_mail", "0026_mailrule_enabled"),
|
||||
(
|
||||
"paperless_mail",
|
||||
"0027_mailaccount_expiration_mailaccount_account_type_and_more",
|
||||
),
|
||||
("paperless_mail", "0028_alter_mailaccount_password_and_more"),
|
||||
("paperless_mail", "0029_mailrule_pdf_layout"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name="MailAccount",
|
||||
@@ -6,7 +6,7 @@ from django.db import models
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
("paperless_mail", "0001_initial"),
|
||||
("paperless_mail", "0001_squashed"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
|
||||
10
uv.lock
generated
10
uv.lock
generated
@@ -1342,11 +1342,11 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "faker"
|
||||
version = "40.1.2"
|
||||
version = "40.5.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/5e/77/1c3ff07b6739b9a1d23ca01ec0a90a309a33b78e345a3eb52f9ce9240e36/faker-40.1.2.tar.gz", hash = "sha256:b76a68163aa5f171d260fc24827a8349bc1db672f6a665359e8d0095e8135d30", size = 1949802, upload-time = "2026-01-13T20:51:49.917Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/03/2a/96fff3edcb10f6505143448a4b91535f77b74865cec45be52690ee280443/faker-40.5.1.tar.gz", hash = "sha256:70222361cd82aa10cb86066d1a4e8f47f2bcdc919615c412045a69c4e6da0cd3", size = 1952684, upload-time = "2026-02-23T21:34:38.362Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/46/ec/91a434c8a53d40c3598966621dea9c50512bec6ce8e76fa1751015e74cef/faker-40.1.2-py3-none-any.whl", hash = "sha256:93503165c165d330260e4379fd6dc07c94da90c611ed3191a0174d2ab9966a42", size = 1985633, upload-time = "2026-01-13T20:51:47.982Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4d/a9/1eed4db92d0aec2f9bfdf1faae0ab0418b5e121dda5701f118a7a4f0cd6a/faker-40.5.1-py3-none-any.whl", hash = "sha256:c69640c1e13bad49b4bcebcbf1b52f9f1a872b6ea186c248ada34d798f1661bf", size = 1987053, upload-time = "2026-02-23T21:34:36.418Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -3121,6 +3121,7 @@ webserver = [
|
||||
dev = [
|
||||
{ name = "daphne", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" },
|
||||
{ name = "factory-boy", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" },
|
||||
{ name = "faker", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" },
|
||||
{ name = "imagehash", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" },
|
||||
{ name = "prek", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" },
|
||||
{ name = "pytest", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" },
|
||||
@@ -3145,6 +3146,7 @@ lint = [
|
||||
testing = [
|
||||
{ name = "daphne", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" },
|
||||
{ name = "factory-boy", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" },
|
||||
{ name = "faker", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" },
|
||||
{ name = "imagehash", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" },
|
||||
{ name = "pytest", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" },
|
||||
{ name = "pytest-cov", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" },
|
||||
@@ -3257,6 +3259,7 @@ provides-extras = ["mariadb", "postgres", "webserver"]
|
||||
dev = [
|
||||
{ name = "daphne" },
|
||||
{ name = "factory-boy", specifier = "~=3.3.1" },
|
||||
{ name = "faker", specifier = "~=40.5.1" },
|
||||
{ name = "imagehash" },
|
||||
{ name = "prek", specifier = "~=0.3.0" },
|
||||
{ name = "pytest", specifier = "~=9.0.0" },
|
||||
@@ -3279,6 +3282,7 @@ lint = [
|
||||
testing = [
|
||||
{ name = "daphne" },
|
||||
{ name = "factory-boy", specifier = "~=3.3.1" },
|
||||
{ name = "faker", specifier = "~=40.5.1" },
|
||||
{ name = "imagehash" },
|
||||
{ name = "pytest", specifier = "~=9.0.0" },
|
||||
{ name = "pytest-cov", specifier = "~=7.0.0" },
|
||||
|
||||
Reference in New Issue
Block a user