mirror of
https://github.com/paperless-ngx/paperless-ngx.git
synced 2026-02-24 00:59:35 -06:00
Compare commits
2 Commits
dependabot
...
dev
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e08287f791 | ||
|
|
c4ea332c61 |
@@ -700,15 +700,11 @@ src/documents/signals/handlers.py:0: error: Function is missing a type annotatio
|
|||||||
src/documents/signals/handlers.py:0: error: Function is missing a type annotation for one or more arguments [no-untyped-def]
|
src/documents/signals/handlers.py:0: error: Function is missing a type annotation for one or more arguments [no-untyped-def]
|
||||||
src/documents/signals/handlers.py:0: error: Function is missing a type annotation for one or more arguments [no-untyped-def]
|
src/documents/signals/handlers.py:0: error: Function is missing a type annotation for one or more arguments [no-untyped-def]
|
||||||
src/documents/signals/handlers.py:0: error: Function is missing a type annotation for one or more arguments [no-untyped-def]
|
src/documents/signals/handlers.py:0: error: Function is missing a type annotation for one or more arguments [no-untyped-def]
|
||||||
src/documents/signals/handlers.py:0: error: Function is missing a type annotation for one or more arguments [no-untyped-def]
|
|
||||||
src/documents/signals/handlers.py:0: error: Incompatible return value type (got "tuple[DocumentMetadataOverrides | None, str]", expected "tuple[DocumentMetadataOverrides, str] | None") [return-value]
|
|
||||||
src/documents/signals/handlers.py:0: error: Incompatible types in assignment (expression has type "list[Tag]", variable has type "set[Tag]") [assignment]
|
src/documents/signals/handlers.py:0: error: Incompatible types in assignment (expression has type "list[Tag]", variable has type "set[Tag]") [assignment]
|
||||||
src/documents/signals/handlers.py:0: error: Incompatible types in assignment (expression has type "tuple[Any, Any, Any]", variable has type "tuple[Any, Any]") [assignment]
|
src/documents/signals/handlers.py:0: error: Incompatible types in assignment (expression has type "tuple[Any, Any, Any]", variable has type "tuple[Any, Any]") [assignment]
|
||||||
src/documents/signals/handlers.py:0: error: Item "ConsumableDocument" of "Document | ConsumableDocument" has no attribute "refresh_from_db" [union-attr]
|
|
||||||
src/documents/signals/handlers.py:0: error: Item "ConsumableDocument" of "Document | ConsumableDocument" has no attribute "save" [union-attr]
|
src/documents/signals/handlers.py:0: error: Item "ConsumableDocument" of "Document | ConsumableDocument" has no attribute "save" [union-attr]
|
||||||
src/documents/signals/handlers.py:0: error: Item "ConsumableDocument" of "Document | ConsumableDocument" has no attribute "source_path" [union-attr]
|
src/documents/signals/handlers.py:0: error: Item "ConsumableDocument" of "Document | ConsumableDocument" has no attribute "source_path" [union-attr]
|
||||||
src/documents/signals/handlers.py:0: error: Item "ConsumableDocument" of "Document | ConsumableDocument" has no attribute "tags" [union-attr]
|
src/documents/signals/handlers.py:0: error: Item "ConsumableDocument" of "Document | ConsumableDocument" has no attribute "tags" [union-attr]
|
||||||
src/documents/signals/handlers.py:0: error: Item "ConsumableDocument" of "Document | ConsumableDocument" has no attribute "tags" [union-attr]
|
|
||||||
src/documents/signals/handlers.py:0: error: Item "ConsumableDocument" of "Document | ConsumableDocument" has no attribute "title" [union-attr]
|
src/documents/signals/handlers.py:0: error: Item "ConsumableDocument" of "Document | ConsumableDocument" has no attribute "title" [union-attr]
|
||||||
src/documents/signals/handlers.py:0: error: Item "None" of "Any | None" has no attribute "get" [union-attr]
|
src/documents/signals/handlers.py:0: error: Item "None" of "Any | None" has no attribute "get" [union-attr]
|
||||||
src/documents/signals/handlers.py:0: error: Item "None" of "Any | None" has no attribute "get" [union-attr]
|
src/documents/signals/handlers.py:0: error: Item "None" of "Any | None" has no attribute "get" [union-attr]
|
||||||
|
|||||||
@@ -30,7 +30,7 @@ RUN set -eux \
|
|||||||
# Purpose: Installs s6-overlay and rootfs
|
# Purpose: Installs s6-overlay and rootfs
|
||||||
# Comments:
|
# Comments:
|
||||||
# - Don't leave anything extra in here either
|
# - 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.0-python3.12-trixie-slim AS s6-overlay-base
|
||||||
|
|
||||||
WORKDIR /usr/src/s6
|
WORKDIR /usr/src/s6
|
||||||
|
|
||||||
|
|||||||
@@ -564,6 +564,18 @@ For security reasons, webhooks can be limited to specific ports and disallowed f
|
|||||||
[configuration settings](configuration.md#workflow-webhooks) to change this behavior. If you are allowing non-admins to create workflows,
|
[configuration settings](configuration.md#workflow-webhooks) to change this behavior. If you are allowing non-admins to create workflows,
|
||||||
you may want to adjust these settings to prevent abuse.
|
you may want to adjust these settings to prevent abuse.
|
||||||
|
|
||||||
|
##### Move to Trash {#workflow-action-move-to-trash}
|
||||||
|
|
||||||
|
"Move to Trash" actions move the document to the trash. The document can be restored
|
||||||
|
from the trash until the trash is emptied (after the configured delay or manually).
|
||||||
|
|
||||||
|
The "Move to Trash" action will always be executed at the end of the workflow run,
|
||||||
|
regardless of its position in the action list. After a "Move to Trash" action is executed
|
||||||
|
no other workflow will be executed on the document.
|
||||||
|
|
||||||
|
If a "Move to Trash" action is executed in a consume pipeline, the consumption
|
||||||
|
will be aborted and the file will be deleted.
|
||||||
|
|
||||||
#### Workflow placeholders
|
#### Workflow placeholders
|
||||||
|
|
||||||
Titles and webhook payloads can be generated by workflows using [Jinja templates](https://jinja.palletsprojects.com/en/3.1.x/templates/).
|
Titles and webhook payloads can be generated by workflows using [Jinja templates](https://jinja.palletsprojects.com/en/3.1.x/templates/).
|
||||||
|
|||||||
@@ -5355,6 +5355,13 @@
|
|||||||
<context context-type="linenumber">445</context>
|
<context context-type="linenumber">445</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
|
<trans-unit id="7902569198692046993" datatype="html">
|
||||||
|
<source>The document will be moved to the trash at the end of the workflow run.</source>
|
||||||
|
<context-group purpose="location">
|
||||||
|
<context context-type="sourcefile">src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.html</context>
|
||||||
|
<context context-type="linenumber">454</context>
|
||||||
|
</context-group>
|
||||||
|
</trans-unit>
|
||||||
<trans-unit id="4626030417479279989" datatype="html">
|
<trans-unit id="4626030417479279989" datatype="html">
|
||||||
<source>Consume Folder</source>
|
<source>Consume Folder</source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
@@ -5457,109 +5464,124 @@
|
|||||||
<context context-type="linenumber">144</context>
|
<context context-type="linenumber">144</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
|
<trans-unit id="2048798344356757326" datatype="html">
|
||||||
|
<source>Move to trash</source>
|
||||||
|
<context-group purpose="location">
|
||||||
|
<context context-type="sourcefile">src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.ts</context>
|
||||||
|
<context context-type="linenumber">148</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">1087</context>
|
||||||
|
</context-group>
|
||||||
|
<context-group purpose="location">
|
||||||
|
<context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.ts</context>
|
||||||
|
<context context-type="linenumber">760</context>
|
||||||
|
</context-group>
|
||||||
|
</trans-unit>
|
||||||
<trans-unit id="4522609911791833187" datatype="html">
|
<trans-unit id="4522609911791833187" datatype="html">
|
||||||
<source>Has any of these tags</source>
|
<source>Has any of these tags</source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.ts</context>
|
<context context-type="sourcefile">src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.ts</context>
|
||||||
<context context-type="linenumber">213</context>
|
<context context-type="linenumber">217</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="4166903555074156852" datatype="html">
|
<trans-unit id="4166903555074156852" datatype="html">
|
||||||
<source>Has all of these tags</source>
|
<source>Has all of these tags</source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.ts</context>
|
<context context-type="sourcefile">src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.ts</context>
|
||||||
<context context-type="linenumber">220</context>
|
<context context-type="linenumber">224</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="6624363795312783141" datatype="html">
|
<trans-unit id="6624363795312783141" datatype="html">
|
||||||
<source>Does not have these tags</source>
|
<source>Does not have these tags</source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.ts</context>
|
<context context-type="sourcefile">src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.ts</context>
|
||||||
<context context-type="linenumber">227</context>
|
<context context-type="linenumber">231</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="7168528512669831184" datatype="html">
|
<trans-unit id="7168528512669831184" datatype="html">
|
||||||
<source>Has any of these correspondents</source>
|
<source>Has any of these correspondents</source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.ts</context>
|
<context context-type="sourcefile">src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.ts</context>
|
||||||
<context context-type="linenumber">234</context>
|
<context context-type="linenumber">238</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="5281365940563983618" datatype="html">
|
<trans-unit id="5281365940563983618" datatype="html">
|
||||||
<source>Has correspondent</source>
|
<source>Has correspondent</source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.ts</context>
|
<context context-type="sourcefile">src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.ts</context>
|
||||||
<context context-type="linenumber">242</context>
|
<context context-type="linenumber">246</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="6884498632428600393" datatype="html">
|
<trans-unit id="6884498632428600393" datatype="html">
|
||||||
<source>Does not have correspondents</source>
|
<source>Does not have correspondents</source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.ts</context>
|
<context context-type="sourcefile">src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.ts</context>
|
||||||
<context context-type="linenumber">250</context>
|
<context context-type="linenumber">254</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="4806713133917046341" datatype="html">
|
<trans-unit id="4806713133917046341" datatype="html">
|
||||||
<source>Has document type</source>
|
<source>Has document type</source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.ts</context>
|
<context context-type="sourcefile">src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.ts</context>
|
||||||
<context context-type="linenumber">258</context>
|
<context context-type="linenumber">262</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="8801397520369995032" datatype="html">
|
<trans-unit id="8801397520369995032" datatype="html">
|
||||||
<source>Has any of these document types</source>
|
<source>Has any of these document types</source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.ts</context>
|
<context context-type="sourcefile">src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.ts</context>
|
||||||
<context context-type="linenumber">266</context>
|
<context context-type="linenumber">270</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="1507843981661822403" datatype="html">
|
<trans-unit id="1507843981661822403" datatype="html">
|
||||||
<source>Does not have document types</source>
|
<source>Does not have document types</source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.ts</context>
|
<context context-type="sourcefile">src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.ts</context>
|
||||||
<context context-type="linenumber">274</context>
|
<context context-type="linenumber">278</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="4277260190522078330" datatype="html">
|
<trans-unit id="4277260190522078330" datatype="html">
|
||||||
<source>Has storage path</source>
|
<source>Has storage path</source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.ts</context>
|
<context context-type="sourcefile">src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.ts</context>
|
||||||
<context context-type="linenumber">282</context>
|
<context context-type="linenumber">286</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="8858580062214623097" datatype="html">
|
<trans-unit id="8858580062214623097" datatype="html">
|
||||||
<source>Has any of these storage paths</source>
|
<source>Has any of these storage paths</source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.ts</context>
|
<context context-type="sourcefile">src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.ts</context>
|
||||||
<context context-type="linenumber">290</context>
|
<context context-type="linenumber">294</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="6070943364927280151" datatype="html">
|
<trans-unit id="6070943364927280151" datatype="html">
|
||||||
<source>Does not have storage paths</source>
|
<source>Does not have storage paths</source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.ts</context>
|
<context context-type="sourcefile">src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.ts</context>
|
||||||
<context context-type="linenumber">298</context>
|
<context context-type="linenumber">302</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="6250799006816371860" datatype="html">
|
<trans-unit id="6250799006816371860" datatype="html">
|
||||||
<source>Matches custom field query</source>
|
<source>Matches custom field query</source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.ts</context>
|
<context context-type="sourcefile">src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.ts</context>
|
||||||
<context context-type="linenumber">306</context>
|
<context context-type="linenumber">310</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="3138206142174978019" datatype="html">
|
<trans-unit id="3138206142174978019" datatype="html">
|
||||||
<source>Create new workflow</source>
|
<source>Create new workflow</source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.ts</context>
|
<context context-type="sourcefile">src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.ts</context>
|
||||||
<context context-type="linenumber">535</context>
|
<context context-type="linenumber">539</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="5996779210524133604" datatype="html">
|
<trans-unit id="5996779210524133604" datatype="html">
|
||||||
<source>Edit workflow</source>
|
<source>Edit workflow</source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.ts</context>
|
<context context-type="sourcefile">src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.ts</context>
|
||||||
<context context-type="linenumber">539</context>
|
<context context-type="linenumber">543</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="5457837313196342910" datatype="html">
|
<trans-unit id="5457837313196342910" datatype="html">
|
||||||
@@ -7773,17 +7795,6 @@
|
|||||||
<context context-type="linenumber">758</context>
|
<context context-type="linenumber">758</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="2048798344356757326" datatype="html">
|
|
||||||
<source>Move to trash</source>
|
|
||||||
<context-group purpose="location">
|
|
||||||
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
|
|
||||||
<context context-type="linenumber">1087</context>
|
|
||||||
</context-group>
|
|
||||||
<context-group purpose="location">
|
|
||||||
<context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.ts</context>
|
|
||||||
<context context-type="linenumber">760</context>
|
|
||||||
</context-group>
|
|
||||||
</trans-unit>
|
|
||||||
<trans-unit id="7295637485862454066" datatype="html">
|
<trans-unit id="7295637485862454066" datatype="html">
|
||||||
<source>Error deleting document</source>
|
<source>Error deleting document</source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
|
|||||||
@@ -448,6 +448,13 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
|
@case (WorkflowActionType.MoveToTrash) {
|
||||||
|
<div class="row">
|
||||||
|
<div class="col">
|
||||||
|
<p class="text-muted small" i18n>The document will be moved to the trash at the end of the workflow run.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
</ng-template>
|
</ng-template>
|
||||||
|
|||||||
@@ -143,6 +143,10 @@ export const WORKFLOW_ACTION_OPTIONS = [
|
|||||||
id: WorkflowActionType.PasswordRemoval,
|
id: WorkflowActionType.PasswordRemoval,
|
||||||
name: $localize`Password removal`,
|
name: $localize`Password removal`,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
id: WorkflowActionType.MoveToTrash,
|
||||||
|
name: $localize`Move to trash`,
|
||||||
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
export enum TriggerFilterType {
|
export enum TriggerFilterType {
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ export enum WorkflowActionType {
|
|||||||
Email = 3,
|
Email = 3,
|
||||||
Webhook = 4,
|
Webhook = 4,
|
||||||
PasswordRemoval = 5,
|
PasswordRemoval = 5,
|
||||||
|
MoveToTrash = 6,
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface WorkflowActionEmail extends ObjectWithId {
|
export interface WorkflowActionEmail extends ObjectWithId {
|
||||||
|
|||||||
29
src/documents/migrations/0012_alter_workflowaction_type.py
Normal file
29
src/documents/migrations/0012_alter_workflowaction_type.py
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
# Generated by Django 5.2.11 on 2026-02-14 19:19
|
||||||
|
|
||||||
|
from django.db import migrations
|
||||||
|
from django.db import models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
dependencies = [
|
||||||
|
("documents", "0011_optimize_integer_field_sizes"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name="workflowaction",
|
||||||
|
name="type",
|
||||||
|
field=models.PositiveSmallIntegerField(
|
||||||
|
choices=[
|
||||||
|
(1, "Assignment"),
|
||||||
|
(2, "Removal"),
|
||||||
|
(3, "Email"),
|
||||||
|
(4, "Webhook"),
|
||||||
|
(5, "Password removal"),
|
||||||
|
(6, "Move to trash"),
|
||||||
|
],
|
||||||
|
default=1,
|
||||||
|
verbose_name="Workflow Action Type",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -1409,6 +1409,10 @@ class WorkflowAction(models.Model):
|
|||||||
5,
|
5,
|
||||||
_("Password removal"),
|
_("Password removal"),
|
||||||
)
|
)
|
||||||
|
MOVE_TO_TRASH = (
|
||||||
|
6,
|
||||||
|
_("Move to trash"),
|
||||||
|
)
|
||||||
|
|
||||||
type = models.PositiveSmallIntegerField(
|
type = models.PositiveSmallIntegerField(
|
||||||
_("Workflow Action Type"),
|
_("Workflow Action Type"),
|
||||||
|
|||||||
@@ -48,6 +48,7 @@ from documents.permissions import get_objects_for_user_owner_aware
|
|||||||
from documents.templating.utils import convert_format_str_to_template_format
|
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 build_workflow_action_context
|
||||||
from documents.workflows.actions import execute_email_action
|
from documents.workflows.actions import execute_email_action
|
||||||
|
from documents.workflows.actions import execute_move_to_trash_action
|
||||||
from documents.workflows.actions import execute_password_removal_action
|
from documents.workflows.actions import execute_password_removal_action
|
||||||
from documents.workflows.actions import execute_webhook_action
|
from documents.workflows.actions import execute_webhook_action
|
||||||
from documents.workflows.mutations import apply_assignment_to_document
|
from documents.workflows.mutations import apply_assignment_to_document
|
||||||
@@ -58,6 +59,8 @@ from documents.workflows.utils import get_workflows_for_trigger
|
|||||||
from paperless.config import AIConfig
|
from paperless.config import AIConfig
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
|
import uuid
|
||||||
|
|
||||||
from documents.classifier import DocumentClassifier
|
from documents.classifier import DocumentClassifier
|
||||||
from documents.data_models import ConsumableDocument
|
from documents.data_models import ConsumableDocument
|
||||||
from documents.data_models import DocumentMetadataOverrides
|
from documents.data_models import DocumentMetadataOverrides
|
||||||
@@ -727,7 +730,7 @@ def add_to_index(sender, document, **kwargs) -> None:
|
|||||||
def run_workflows_added(
|
def run_workflows_added(
|
||||||
sender,
|
sender,
|
||||||
document: Document,
|
document: Document,
|
||||||
logging_group=None,
|
logging_group: uuid.UUID | None = None,
|
||||||
original_file=None,
|
original_file=None,
|
||||||
**kwargs,
|
**kwargs,
|
||||||
) -> None:
|
) -> None:
|
||||||
@@ -743,7 +746,7 @@ def run_workflows_added(
|
|||||||
def run_workflows_updated(
|
def run_workflows_updated(
|
||||||
sender,
|
sender,
|
||||||
document: Document,
|
document: Document,
|
||||||
logging_group=None,
|
logging_group: uuid.UUID | None = None,
|
||||||
**kwargs,
|
**kwargs,
|
||||||
) -> None:
|
) -> None:
|
||||||
run_workflows(
|
run_workflows(
|
||||||
@@ -757,7 +760,7 @@ def run_workflows(
|
|||||||
trigger_type: WorkflowTrigger.WorkflowTriggerType,
|
trigger_type: WorkflowTrigger.WorkflowTriggerType,
|
||||||
document: Document | ConsumableDocument,
|
document: Document | ConsumableDocument,
|
||||||
workflow_to_run: Workflow | None = None,
|
workflow_to_run: Workflow | None = None,
|
||||||
logging_group=None,
|
logging_group: uuid.UUID | None = None,
|
||||||
overrides: DocumentMetadataOverrides | None = None,
|
overrides: DocumentMetadataOverrides | None = None,
|
||||||
original_file: Path | None = None,
|
original_file: Path | None = None,
|
||||||
) -> tuple[DocumentMetadataOverrides, str] | None:
|
) -> tuple[DocumentMetadataOverrides, str] | None:
|
||||||
@@ -783,14 +786,33 @@ def run_workflows(
|
|||||||
|
|
||||||
for workflow in workflows:
|
for workflow in workflows:
|
||||||
if not use_overrides:
|
if not use_overrides:
|
||||||
# This can be called from bulk_update_documents, which may be running multiple times
|
if TYPE_CHECKING:
|
||||||
# Refresh this so the matching data is fresh and instance fields are re-freshed
|
assert isinstance(document, Document)
|
||||||
# Otherwise, this instance might be behind and overwrite the work another process did
|
try:
|
||||||
document.refresh_from_db()
|
# This can be called from bulk_update_documents, which may be running multiple times
|
||||||
doc_tag_ids = list(document.tags.values_list("pk", flat=True))
|
# Refresh this so the matching data is fresh and instance fields are re-freshed
|
||||||
|
# Otherwise, this instance might be behind and overwrite the work another process did
|
||||||
|
document.refresh_from_db()
|
||||||
|
doc_tag_ids = list(document.tags.values_list("pk", flat=True))
|
||||||
|
except Document.DoesNotExist:
|
||||||
|
# Document was hard deleted by a previous workflow or another process
|
||||||
|
logger.info(
|
||||||
|
"Document no longer exists, skipping remaining workflows",
|
||||||
|
extra={"group": logging_group},
|
||||||
|
)
|
||||||
|
break
|
||||||
|
|
||||||
|
# Check if document was soft deleted (moved to trash)
|
||||||
|
if document.is_deleted:
|
||||||
|
logger.info(
|
||||||
|
"Document was moved to trash, skipping remaining workflows",
|
||||||
|
extra={"group": logging_group},
|
||||||
|
)
|
||||||
|
break
|
||||||
|
|
||||||
if matching.document_matches_workflow(document, workflow, trigger_type):
|
if matching.document_matches_workflow(document, workflow, trigger_type):
|
||||||
action: WorkflowAction
|
action: WorkflowAction
|
||||||
|
has_move_to_trash_action = False
|
||||||
for action in workflow.actions.order_by("order", "pk"):
|
for action in workflow.actions.order_by("order", "pk"):
|
||||||
message = f"Applying {action} from {workflow}"
|
message = f"Applying {action} from {workflow}"
|
||||||
if not use_overrides:
|
if not use_overrides:
|
||||||
@@ -834,6 +856,8 @@ def run_workflows(
|
|||||||
)
|
)
|
||||||
elif action.type == WorkflowAction.WorkflowActionType.PASSWORD_REMOVAL:
|
elif action.type == WorkflowAction.WorkflowActionType.PASSWORD_REMOVAL:
|
||||||
execute_password_removal_action(action, document, logging_group)
|
execute_password_removal_action(action, document, logging_group)
|
||||||
|
elif action.type == WorkflowAction.WorkflowActionType.MOVE_TO_TRASH:
|
||||||
|
has_move_to_trash_action = True
|
||||||
|
|
||||||
if not use_overrides:
|
if not use_overrides:
|
||||||
# limit title to 128 characters
|
# limit title to 128 characters
|
||||||
@@ -848,7 +872,12 @@ def run_workflows(
|
|||||||
document=document if not use_overrides else None,
|
document=document if not use_overrides else None,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
if has_move_to_trash_action:
|
||||||
|
execute_move_to_trash_action(action, document, logging_group)
|
||||||
|
|
||||||
if use_overrides:
|
if use_overrides:
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
assert overrides is not None
|
||||||
return overrides, "\n".join(messages)
|
return overrides, "\n".join(messages)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -896,3 +896,210 @@ class TestApiWorkflows(DirectoriesMixin, APITestCase):
|
|||||||
"Passwords are required",
|
"Passwords are required",
|
||||||
str(response.data["non_field_errors"][0]),
|
str(response.data["non_field_errors"][0]),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def test_trash_action_validation(self) -> None:
|
||||||
|
"""
|
||||||
|
GIVEN:
|
||||||
|
- API request to create a workflow with a trash action
|
||||||
|
WHEN:
|
||||||
|
- API is called
|
||||||
|
THEN:
|
||||||
|
- Correct HTTP response
|
||||||
|
"""
|
||||||
|
response = self.client.post(
|
||||||
|
self.ENDPOINT,
|
||||||
|
json.dumps(
|
||||||
|
{
|
||||||
|
"name": "Workflow 2",
|
||||||
|
"order": 1,
|
||||||
|
"triggers": [
|
||||||
|
{
|
||||||
|
"type": WorkflowTrigger.WorkflowTriggerType.CONSUMPTION,
|
||||||
|
"sources": [DocumentSource.ApiUpload],
|
||||||
|
"filter_filename": "*",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
"actions": [
|
||||||
|
{
|
||||||
|
"type": WorkflowAction.WorkflowActionType.MOVE_TO_TRASH,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
content_type="application/json",
|
||||||
|
)
|
||||||
|
self.assertEqual(response.status_code, status.HTTP_201_CREATED)
|
||||||
|
|
||||||
|
response = self.client.post(
|
||||||
|
self.ENDPOINT,
|
||||||
|
json.dumps(
|
||||||
|
{
|
||||||
|
"name": "Workflow 3",
|
||||||
|
"order": 2,
|
||||||
|
"triggers": [
|
||||||
|
{
|
||||||
|
"type": WorkflowTrigger.WorkflowTriggerType.CONSUMPTION,
|
||||||
|
"sources": [DocumentSource.ApiUpload],
|
||||||
|
"filter_filename": "*",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
"actions": [
|
||||||
|
{
|
||||||
|
"type": WorkflowAction.WorkflowActionType.MOVE_TO_TRASH,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
content_type="application/json",
|
||||||
|
)
|
||||||
|
self.assertEqual(response.status_code, status.HTTP_201_CREATED)
|
||||||
|
|
||||||
|
def test_trash_action_as_last_action_valid(self) -> None:
|
||||||
|
"""
|
||||||
|
GIVEN:
|
||||||
|
- API request to create a workflow with multiple actions
|
||||||
|
- Move to trash action is the last action
|
||||||
|
WHEN:
|
||||||
|
- API is called
|
||||||
|
THEN:
|
||||||
|
- Workflow is created successfully
|
||||||
|
"""
|
||||||
|
response = self.client.post(
|
||||||
|
self.ENDPOINT,
|
||||||
|
json.dumps(
|
||||||
|
{
|
||||||
|
"name": "Workflow with Move to Trash Last",
|
||||||
|
"order": 1,
|
||||||
|
"triggers": [
|
||||||
|
{
|
||||||
|
"type": WorkflowTrigger.WorkflowTriggerType.CONSUMPTION,
|
||||||
|
"sources": [DocumentSource.ApiUpload],
|
||||||
|
"filter_filename": "*",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
"actions": [
|
||||||
|
{
|
||||||
|
"type": WorkflowAction.WorkflowActionType.ASSIGNMENT,
|
||||||
|
"assign_title": "Assigned Title",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": WorkflowAction.WorkflowActionType.REMOVAL,
|
||||||
|
"remove_all_tags": True,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": WorkflowAction.WorkflowActionType.MOVE_TO_TRASH,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
content_type="application/json",
|
||||||
|
)
|
||||||
|
self.assertEqual(response.status_code, status.HTTP_201_CREATED)
|
||||||
|
|
||||||
|
def test_update_workflow_add_trash_at_end_valid(self) -> None:
|
||||||
|
"""
|
||||||
|
GIVEN:
|
||||||
|
- Existing workflow without trash action
|
||||||
|
WHEN:
|
||||||
|
- PATCH to add trash action at end
|
||||||
|
THEN:
|
||||||
|
- HTTP 200 success
|
||||||
|
"""
|
||||||
|
response = self.client.post(
|
||||||
|
self.ENDPOINT,
|
||||||
|
json.dumps(
|
||||||
|
{
|
||||||
|
"name": "Workflow to Add Move to Trash",
|
||||||
|
"order": 1,
|
||||||
|
"triggers": [
|
||||||
|
{
|
||||||
|
"type": WorkflowTrigger.WorkflowTriggerType.CONSUMPTION,
|
||||||
|
"sources": [DocumentSource.ApiUpload],
|
||||||
|
"filter_filename": "*",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
"actions": [
|
||||||
|
{
|
||||||
|
"type": WorkflowAction.WorkflowActionType.ASSIGNMENT,
|
||||||
|
"assign_title": "First Action",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
content_type="application/json",
|
||||||
|
)
|
||||||
|
self.assertEqual(response.status_code, status.HTTP_201_CREATED)
|
||||||
|
workflow_id = response.data["id"]
|
||||||
|
|
||||||
|
response = self.client.patch(
|
||||||
|
f"{self.ENDPOINT}{workflow_id}/",
|
||||||
|
json.dumps(
|
||||||
|
{
|
||||||
|
"actions": [
|
||||||
|
{
|
||||||
|
"type": WorkflowAction.WorkflowActionType.ASSIGNMENT,
|
||||||
|
"assign_title": "First Action",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": WorkflowAction.WorkflowActionType.MOVE_TO_TRASH,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
content_type="application/json",
|
||||||
|
)
|
||||||
|
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||||
|
|
||||||
|
def test_update_workflow_remove_trash_action_valid(self) -> None:
|
||||||
|
"""
|
||||||
|
GIVEN:
|
||||||
|
- Existing workflow with trash action
|
||||||
|
WHEN:
|
||||||
|
- PATCH to remove trash action
|
||||||
|
THEN:
|
||||||
|
- HTTP 200 success
|
||||||
|
"""
|
||||||
|
response = self.client.post(
|
||||||
|
self.ENDPOINT,
|
||||||
|
json.dumps(
|
||||||
|
{
|
||||||
|
"name": "Workflow to Remove move to trash",
|
||||||
|
"order": 1,
|
||||||
|
"triggers": [
|
||||||
|
{
|
||||||
|
"type": WorkflowTrigger.WorkflowTriggerType.CONSUMPTION,
|
||||||
|
"sources": [DocumentSource.ApiUpload],
|
||||||
|
"filter_filename": "*",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
"actions": [
|
||||||
|
{
|
||||||
|
"type": WorkflowAction.WorkflowActionType.ASSIGNMENT,
|
||||||
|
"assign_title": "First Action",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": WorkflowAction.WorkflowActionType.MOVE_TO_TRASH,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
content_type="application/json",
|
||||||
|
)
|
||||||
|
self.assertEqual(response.status_code, status.HTTP_201_CREATED)
|
||||||
|
workflow_id = response.data["id"]
|
||||||
|
|
||||||
|
response = self.client.patch(
|
||||||
|
f"{self.ENDPOINT}{workflow_id}/",
|
||||||
|
json.dumps(
|
||||||
|
{
|
||||||
|
"actions": [
|
||||||
|
{
|
||||||
|
"type": WorkflowAction.WorkflowActionType.ASSIGNMENT,
|
||||||
|
"assign_title": "Only Action",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
content_type="application/json",
|
||||||
|
)
|
||||||
|
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||||
|
|||||||
@@ -3,9 +3,11 @@ import json
|
|||||||
import shutil
|
import shutil
|
||||||
import socket
|
import socket
|
||||||
import tempfile
|
import tempfile
|
||||||
|
from collections.abc import Callable
|
||||||
from datetime import timedelta
|
from datetime import timedelta
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import TYPE_CHECKING
|
from typing import TYPE_CHECKING
|
||||||
|
from typing import Any
|
||||||
from unittest import mock
|
from unittest import mock
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
@@ -55,6 +57,7 @@ from documents.models import WorkflowActionEmail
|
|||||||
from documents.models import WorkflowActionWebhook
|
from documents.models import WorkflowActionWebhook
|
||||||
from documents.models import WorkflowRun
|
from documents.models import WorkflowRun
|
||||||
from documents.models import WorkflowTrigger
|
from documents.models import WorkflowTrigger
|
||||||
|
from documents.plugins.base import StopConsumeTaskError
|
||||||
from documents.serialisers import WorkflowTriggerSerializer
|
from documents.serialisers import WorkflowTriggerSerializer
|
||||||
from documents.signals import document_consumption_finished
|
from documents.signals import document_consumption_finished
|
||||||
from documents.tests.utils import DirectoriesMixin
|
from documents.tests.utils import DirectoriesMixin
|
||||||
@@ -3914,6 +3917,427 @@ class TestWorkflows(
|
|||||||
)
|
)
|
||||||
assert mock_remove_password.call_count == 2
|
assert mock_remove_password.call_count == 2
|
||||||
|
|
||||||
|
def test_workflow_trash_action_soft_delete(self):
|
||||||
|
"""
|
||||||
|
GIVEN:
|
||||||
|
- Document updated workflow with delete action
|
||||||
|
WHEN:
|
||||||
|
- Document that matches is updated
|
||||||
|
THEN:
|
||||||
|
- Document is moved to trash (soft deleted)
|
||||||
|
"""
|
||||||
|
trigger = WorkflowTrigger.objects.create(
|
||||||
|
type=WorkflowTrigger.WorkflowTriggerType.DOCUMENT_UPDATED,
|
||||||
|
)
|
||||||
|
action = WorkflowAction.objects.create(
|
||||||
|
type=WorkflowAction.WorkflowActionType.MOVE_TO_TRASH,
|
||||||
|
)
|
||||||
|
w = Workflow.objects.create(
|
||||||
|
name="Workflow 1",
|
||||||
|
order=0,
|
||||||
|
)
|
||||||
|
w.triggers.add(trigger)
|
||||||
|
w.actions.add(action)
|
||||||
|
w.save()
|
||||||
|
|
||||||
|
doc = Document.objects.create(
|
||||||
|
title="sample test",
|
||||||
|
correspondent=self.c,
|
||||||
|
original_filename="sample.pdf",
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(Document.objects.count(), 1)
|
||||||
|
self.assertEqual(Document.deleted_objects.count(), 0)
|
||||||
|
|
||||||
|
run_workflows(WorkflowTrigger.WorkflowTriggerType.DOCUMENT_UPDATED, doc)
|
||||||
|
|
||||||
|
self.assertEqual(Document.objects.count(), 0)
|
||||||
|
self.assertEqual(Document.deleted_objects.count(), 1)
|
||||||
|
|
||||||
|
@override_settings(
|
||||||
|
PAPERLESS_EMAIL_HOST="localhost",
|
||||||
|
EMAIL_ENABLED=True,
|
||||||
|
PAPERLESS_URL="http://localhost:8000",
|
||||||
|
)
|
||||||
|
@mock.patch("django.core.mail.message.EmailMessage.send")
|
||||||
|
def test_workflow_trash_with_email_action(self, mock_email_send):
|
||||||
|
"""
|
||||||
|
GIVEN:
|
||||||
|
- Workflow with email action, then move to trash action
|
||||||
|
WHEN:
|
||||||
|
- Document matches and workflow runs
|
||||||
|
THEN:
|
||||||
|
- Email is sent first
|
||||||
|
- Document is moved to trash (soft deleted)
|
||||||
|
"""
|
||||||
|
mock_email_send.return_value = 1
|
||||||
|
|
||||||
|
trigger = WorkflowTrigger.objects.create(
|
||||||
|
type=WorkflowTrigger.WorkflowTriggerType.DOCUMENT_UPDATED,
|
||||||
|
)
|
||||||
|
email_action = WorkflowActionEmail.objects.create(
|
||||||
|
subject="Document deleted: {doc_title}",
|
||||||
|
body="Document {doc_title} will be deleted",
|
||||||
|
to="user@example.com",
|
||||||
|
include_document=False,
|
||||||
|
)
|
||||||
|
email_workflow_action = WorkflowAction.objects.create(
|
||||||
|
type=WorkflowAction.WorkflowActionType.EMAIL,
|
||||||
|
email=email_action,
|
||||||
|
)
|
||||||
|
trash_workflow_action = WorkflowAction.objects.create(
|
||||||
|
type=WorkflowAction.WorkflowActionType.MOVE_TO_TRASH,
|
||||||
|
)
|
||||||
|
w = Workflow.objects.create(
|
||||||
|
name="Workflow with email then move to trash",
|
||||||
|
order=0,
|
||||||
|
)
|
||||||
|
w.triggers.add(trigger)
|
||||||
|
w.actions.add(email_workflow_action, trash_workflow_action)
|
||||||
|
w.save()
|
||||||
|
|
||||||
|
doc = Document.objects.create(
|
||||||
|
title="sample test",
|
||||||
|
correspondent=self.c,
|
||||||
|
original_filename="sample.pdf",
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(Document.objects.count(), 1)
|
||||||
|
self.assertEqual(Document.deleted_objects.count(), 0)
|
||||||
|
|
||||||
|
run_workflows(WorkflowTrigger.WorkflowTriggerType.DOCUMENT_UPDATED, doc)
|
||||||
|
|
||||||
|
mock_email_send.assert_called_once()
|
||||||
|
self.assertEqual(Document.objects.count(), 0)
|
||||||
|
self.assertEqual(Document.deleted_objects.count(), 1)
|
||||||
|
|
||||||
|
@override_settings(
|
||||||
|
PAPERLESS_URL="http://localhost:8000",
|
||||||
|
)
|
||||||
|
@mock.patch("documents.workflows.webhooks.send_webhook.delay")
|
||||||
|
def test_workflow_trash_with_webhook_action(self, mock_webhook_delay):
|
||||||
|
"""
|
||||||
|
GIVEN:
|
||||||
|
- Workflow with webhook action (include_document=True), then move to trash action
|
||||||
|
WHEN:
|
||||||
|
- Document matches and workflow runs
|
||||||
|
THEN:
|
||||||
|
- Webhook .delay() is called with complete data including file bytes
|
||||||
|
- Document is moved to trash (soft deleted)
|
||||||
|
- Webhook task has all necessary data and doesn't rely on document existence
|
||||||
|
"""
|
||||||
|
trigger = WorkflowTrigger.objects.create(
|
||||||
|
type=WorkflowTrigger.WorkflowTriggerType.DOCUMENT_UPDATED,
|
||||||
|
)
|
||||||
|
webhook_action = WorkflowActionWebhook.objects.create(
|
||||||
|
use_params=True,
|
||||||
|
params={
|
||||||
|
"title": "{{doc_title}}",
|
||||||
|
"message": "Document being deleted",
|
||||||
|
},
|
||||||
|
url="https://paperless-ngx.com/webhook",
|
||||||
|
include_document=True,
|
||||||
|
)
|
||||||
|
webhook_workflow_action = WorkflowAction.objects.create(
|
||||||
|
type=WorkflowAction.WorkflowActionType.WEBHOOK,
|
||||||
|
webhook=webhook_action,
|
||||||
|
)
|
||||||
|
trash_workflow_action = WorkflowAction.objects.create(
|
||||||
|
type=WorkflowAction.WorkflowActionType.MOVE_TO_TRASH,
|
||||||
|
)
|
||||||
|
w = Workflow.objects.create(
|
||||||
|
name="Workflow with webhook then move to trash",
|
||||||
|
order=0,
|
||||||
|
)
|
||||||
|
w.triggers.add(trigger)
|
||||||
|
w.actions.add(webhook_workflow_action, trash_workflow_action)
|
||||||
|
w.save()
|
||||||
|
|
||||||
|
test_file = shutil.copy(
|
||||||
|
self.SAMPLE_DIR / "simple.pdf",
|
||||||
|
self.dirs.scratch_dir / "simple.pdf",
|
||||||
|
)
|
||||||
|
|
||||||
|
doc = Document.objects.create(
|
||||||
|
title="sample test",
|
||||||
|
correspondent=self.c,
|
||||||
|
original_filename="simple.pdf",
|
||||||
|
filename=test_file,
|
||||||
|
mime_type="application/pdf",
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(Document.objects.count(), 1)
|
||||||
|
self.assertEqual(Document.deleted_objects.count(), 0)
|
||||||
|
|
||||||
|
run_workflows(WorkflowTrigger.WorkflowTriggerType.DOCUMENT_UPDATED, doc)
|
||||||
|
|
||||||
|
mock_webhook_delay.assert_called_once()
|
||||||
|
call_kwargs = mock_webhook_delay.call_args[1]
|
||||||
|
self.assertEqual(call_kwargs["url"], "https://paperless-ngx.com/webhook")
|
||||||
|
self.assertEqual(
|
||||||
|
call_kwargs["data"],
|
||||||
|
{"title": "sample test", "message": "Document being deleted"},
|
||||||
|
)
|
||||||
|
self.assertIsNotNone(call_kwargs["files"])
|
||||||
|
self.assertIn("file", call_kwargs["files"])
|
||||||
|
self.assertEqual(call_kwargs["files"]["file"][0], "simple.pdf")
|
||||||
|
self.assertEqual(call_kwargs["files"]["file"][2], "application/pdf")
|
||||||
|
self.assertIsInstance(call_kwargs["files"]["file"][1], bytes)
|
||||||
|
|
||||||
|
self.assertEqual(Document.objects.count(), 0)
|
||||||
|
self.assertEqual(Document.deleted_objects.count(), 1)
|
||||||
|
|
||||||
|
@override_settings(
|
||||||
|
PAPERLESS_EMAIL_HOST="localhost",
|
||||||
|
EMAIL_ENABLED=True,
|
||||||
|
PAPERLESS_URL="http://localhost:8000",
|
||||||
|
)
|
||||||
|
@mock.patch("django.core.mail.message.EmailMessage.send")
|
||||||
|
def test_workflow_trash_after_email_failure(self, mock_email_send) -> None:
|
||||||
|
"""
|
||||||
|
GIVEN:
|
||||||
|
- Workflow with email action (that fails), then move to trash action
|
||||||
|
WHEN:
|
||||||
|
- Document matches and workflow runs
|
||||||
|
- Email action raises exception
|
||||||
|
THEN:
|
||||||
|
- Email failure is logged
|
||||||
|
- Move to Trash still executes successfully (soft delete)
|
||||||
|
"""
|
||||||
|
mock_email_send.side_effect = Exception("Email server error")
|
||||||
|
|
||||||
|
trigger = WorkflowTrigger.objects.create(
|
||||||
|
type=WorkflowTrigger.WorkflowTriggerType.DOCUMENT_UPDATED,
|
||||||
|
)
|
||||||
|
email_action = WorkflowActionEmail.objects.create(
|
||||||
|
subject="Document deleted: {doc_title}",
|
||||||
|
body="Document {doc_title} will be deleted",
|
||||||
|
to="user@example.com",
|
||||||
|
include_document=False,
|
||||||
|
)
|
||||||
|
email_workflow_action = WorkflowAction.objects.create(
|
||||||
|
type=WorkflowAction.WorkflowActionType.EMAIL,
|
||||||
|
email=email_action,
|
||||||
|
)
|
||||||
|
trash_workflow_action = WorkflowAction.objects.create(
|
||||||
|
type=WorkflowAction.WorkflowActionType.MOVE_TO_TRASH,
|
||||||
|
)
|
||||||
|
w = Workflow.objects.create(
|
||||||
|
name="Workflow with failing email then move to trash",
|
||||||
|
order=0,
|
||||||
|
)
|
||||||
|
w.triggers.add(trigger)
|
||||||
|
w.actions.add(email_workflow_action, trash_workflow_action)
|
||||||
|
w.save()
|
||||||
|
|
||||||
|
doc = Document.objects.create(
|
||||||
|
title="sample test",
|
||||||
|
correspondent=self.c,
|
||||||
|
original_filename="sample.pdf",
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(Document.objects.count(), 1)
|
||||||
|
self.assertEqual(Document.deleted_objects.count(), 0)
|
||||||
|
|
||||||
|
with self.assertLogs("paperless.workflows.actions", level="ERROR") as cm:
|
||||||
|
run_workflows(WorkflowTrigger.WorkflowTriggerType.DOCUMENT_UPDATED, doc)
|
||||||
|
|
||||||
|
expected_str = "Error occurred sending notification email"
|
||||||
|
self.assertIn(expected_str, cm.output[0])
|
||||||
|
|
||||||
|
self.assertEqual(Document.objects.count(), 0)
|
||||||
|
self.assertEqual(Document.deleted_objects.count(), 1)
|
||||||
|
|
||||||
|
def test_multiple_workflows_trash_then_assignment(self):
|
||||||
|
"""
|
||||||
|
GIVEN:
|
||||||
|
- Workflow 1 (order=0) with move to trash action
|
||||||
|
- Workflow 2 (order=1) with assignment action
|
||||||
|
- Both workflows match the same document
|
||||||
|
WHEN:
|
||||||
|
- Workflows run sequentially
|
||||||
|
THEN:
|
||||||
|
- First workflow runs and deletes document (soft delete)
|
||||||
|
- Second workflow does not trigger (document no longer exists)
|
||||||
|
- Logs confirm move to trash and skipping of remaining workflows
|
||||||
|
"""
|
||||||
|
trigger1 = WorkflowTrigger.objects.create(
|
||||||
|
type=WorkflowTrigger.WorkflowTriggerType.DOCUMENT_UPDATED,
|
||||||
|
)
|
||||||
|
trash_workflow_action = WorkflowAction.objects.create(
|
||||||
|
type=WorkflowAction.WorkflowActionType.MOVE_TO_TRASH,
|
||||||
|
)
|
||||||
|
w1 = Workflow.objects.create(
|
||||||
|
name="Workflow 1 - Move to Trash",
|
||||||
|
order=0,
|
||||||
|
)
|
||||||
|
w1.triggers.add(trigger1)
|
||||||
|
w1.actions.add(trash_workflow_action)
|
||||||
|
w1.save()
|
||||||
|
|
||||||
|
trigger2 = WorkflowTrigger.objects.create(
|
||||||
|
type=WorkflowTrigger.WorkflowTriggerType.DOCUMENT_UPDATED,
|
||||||
|
)
|
||||||
|
assignment_action = WorkflowAction.objects.create(
|
||||||
|
type=WorkflowAction.WorkflowActionType.ASSIGNMENT,
|
||||||
|
assign_correspondent=self.c2,
|
||||||
|
)
|
||||||
|
w2 = Workflow.objects.create(
|
||||||
|
name="Workflow 2 - Assignment",
|
||||||
|
order=1,
|
||||||
|
)
|
||||||
|
w2.triggers.add(trigger2)
|
||||||
|
w2.actions.add(assignment_action)
|
||||||
|
w2.save()
|
||||||
|
|
||||||
|
doc = Document.objects.create(
|
||||||
|
title="sample test",
|
||||||
|
correspondent=self.c,
|
||||||
|
original_filename="sample.pdf",
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(Document.objects.count(), 1)
|
||||||
|
self.assertEqual(Document.deleted_objects.count(), 0)
|
||||||
|
|
||||||
|
with self.assertLogs("paperless", level="DEBUG") as cm:
|
||||||
|
run_workflows(WorkflowTrigger.WorkflowTriggerType.DOCUMENT_UPDATED, doc)
|
||||||
|
|
||||||
|
self.assertEqual(Document.objects.count(), 0)
|
||||||
|
self.assertEqual(Document.deleted_objects.count(), 1)
|
||||||
|
|
||||||
|
# We check logs instead of WorkflowRun.objects.count() because when the document
|
||||||
|
# is soft-deleted, the WorkflowRun is cascade-deleted (hard delete) since it does
|
||||||
|
# not inherit from the SoftDeleteModel. The logs confirm that the first workflow
|
||||||
|
# executed the move to trash and remaining workflows were skipped.
|
||||||
|
log_output = "\n".join(cm.output)
|
||||||
|
self.assertIn("Moved document", log_output)
|
||||||
|
self.assertIn("to trash", log_output)
|
||||||
|
self.assertIn(
|
||||||
|
"Document was moved to trash, skipping remaining workflows",
|
||||||
|
log_output,
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_workflow_delete_action_during_consumption(self):
|
||||||
|
"""
|
||||||
|
GIVEN:
|
||||||
|
- Workflow with consumption trigger and delete action
|
||||||
|
WHEN:
|
||||||
|
- Document is being consumed and workflow runs
|
||||||
|
THEN:
|
||||||
|
- StopConsumeTaskError is raised to halt consumption
|
||||||
|
- Original file is deleted
|
||||||
|
- No document is created
|
||||||
|
"""
|
||||||
|
trigger = WorkflowTrigger.objects.create(
|
||||||
|
type=WorkflowTrigger.WorkflowTriggerType.CONSUMPTION,
|
||||||
|
sources=f"{DocumentSource.ConsumeFolder}",
|
||||||
|
filter_filename="*",
|
||||||
|
)
|
||||||
|
action = WorkflowAction.objects.create(
|
||||||
|
type=WorkflowAction.WorkflowActionType.MOVE_TO_TRASH,
|
||||||
|
)
|
||||||
|
w = Workflow.objects.create(
|
||||||
|
name="Workflow Delete During Consumption",
|
||||||
|
order=0,
|
||||||
|
)
|
||||||
|
w.triggers.add(trigger)
|
||||||
|
w.actions.add(action)
|
||||||
|
w.save()
|
||||||
|
|
||||||
|
# Create a test file to be consumed
|
||||||
|
test_file = shutil.copy(
|
||||||
|
self.SAMPLE_DIR / "simple.pdf",
|
||||||
|
self.dirs.scratch_dir / "simple.pdf",
|
||||||
|
)
|
||||||
|
test_file_path = Path(test_file)
|
||||||
|
self.assertTrue(test_file_path.exists())
|
||||||
|
|
||||||
|
# Create a ConsumableDocument
|
||||||
|
consumable_doc = ConsumableDocument(
|
||||||
|
source=DocumentSource.ConsumeFolder,
|
||||||
|
original_file=test_file_path,
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(Document.objects.count(), 0)
|
||||||
|
|
||||||
|
# Run workflows with overrides (consumption flow)
|
||||||
|
with self.assertRaises(StopConsumeTaskError) as context:
|
||||||
|
run_workflows(
|
||||||
|
WorkflowTrigger.WorkflowTriggerType.CONSUMPTION,
|
||||||
|
consumable_doc,
|
||||||
|
overrides=DocumentMetadataOverrides(),
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertIn("deleted by workflow action", str(context.exception))
|
||||||
|
|
||||||
|
# File should be deleted
|
||||||
|
self.assertFalse(test_file_path.exists())
|
||||||
|
|
||||||
|
# No document should be created
|
||||||
|
self.assertEqual(Document.objects.count(), 0)
|
||||||
|
|
||||||
|
def test_workflow_delete_action_during_consumption_with_assignment(self):
|
||||||
|
"""
|
||||||
|
GIVEN:
|
||||||
|
- Workflow with consumption trigger, assignment action, then delete action
|
||||||
|
WHEN:
|
||||||
|
- Document is being consumed and workflow runs
|
||||||
|
THEN:
|
||||||
|
- StopConsumeTaskError is raised to halt consumption
|
||||||
|
- Original file is deleted
|
||||||
|
- No document is created (even though assignment would have worked)
|
||||||
|
"""
|
||||||
|
trigger = WorkflowTrigger.objects.create(
|
||||||
|
type=WorkflowTrigger.WorkflowTriggerType.CONSUMPTION,
|
||||||
|
sources=f"{DocumentSource.ConsumeFolder}",
|
||||||
|
filter_filename="*",
|
||||||
|
)
|
||||||
|
assignment_action = WorkflowAction.objects.create(
|
||||||
|
type=WorkflowAction.WorkflowActionType.ASSIGNMENT,
|
||||||
|
assign_title="This should not be applied",
|
||||||
|
assign_correspondent=self.c,
|
||||||
|
)
|
||||||
|
trash_workflow_action = WorkflowAction.objects.create(
|
||||||
|
type=WorkflowAction.WorkflowActionType.MOVE_TO_TRASH,
|
||||||
|
)
|
||||||
|
w = Workflow.objects.create(
|
||||||
|
name="Workflow Assignment then Delete During Consumption",
|
||||||
|
order=0,
|
||||||
|
)
|
||||||
|
w.triggers.add(trigger)
|
||||||
|
w.actions.add(assignment_action, trash_workflow_action)
|
||||||
|
w.save()
|
||||||
|
|
||||||
|
# Create a test file to be consumed
|
||||||
|
test_file = shutil.copy(
|
||||||
|
self.SAMPLE_DIR / "simple.pdf",
|
||||||
|
self.dirs.scratch_dir / "simple2.pdf",
|
||||||
|
)
|
||||||
|
test_file_path = Path(test_file)
|
||||||
|
self.assertTrue(test_file_path.exists())
|
||||||
|
|
||||||
|
# Create a ConsumableDocument
|
||||||
|
consumable_doc = ConsumableDocument(
|
||||||
|
source=DocumentSource.ConsumeFolder,
|
||||||
|
original_file=test_file_path,
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(Document.objects.count(), 0)
|
||||||
|
|
||||||
|
# Run workflows with overrides (consumption flow)
|
||||||
|
with self.assertRaises(StopConsumeTaskError):
|
||||||
|
run_workflows(
|
||||||
|
WorkflowTrigger.WorkflowTriggerType.CONSUMPTION,
|
||||||
|
consumable_doc,
|
||||||
|
overrides=DocumentMetadataOverrides(),
|
||||||
|
)
|
||||||
|
|
||||||
|
# File should be deleted
|
||||||
|
self.assertFalse(test_file_path.exists())
|
||||||
|
|
||||||
|
# No document should be created
|
||||||
|
self.assertEqual(Document.objects.count(), 0)
|
||||||
|
|
||||||
|
|
||||||
class TestWebhookSend:
|
class TestWebhookSend:
|
||||||
def test_send_webhook_data_or_json(
|
def test_send_webhook_data_or_json(
|
||||||
@@ -3956,13 +4380,17 @@ class TestWebhookSend:
|
|||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
def resolve_to(monkeypatch):
|
def resolve_to(monkeypatch: pytest.MonkeyPatch) -> Callable[[str], None]:
|
||||||
"""
|
"""
|
||||||
Force DNS resolution to a specific IP for any hostname.
|
Force DNS resolution to a specific IP for any hostname.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def _set(ip: str):
|
def _set(ip: str) -> None:
|
||||||
def fake_getaddrinfo(host, *_args, **_kwargs):
|
def fake_getaddrinfo(
|
||||||
|
host: str,
|
||||||
|
*_args: object,
|
||||||
|
**_kwargs: object,
|
||||||
|
) -> list[tuple[Any, ...]]:
|
||||||
return [(socket.AF_INET, None, None, "", (ip, 0))]
|
return [(socket.AF_INET, None, None, "", (ip, 0))]
|
||||||
|
|
||||||
monkeypatch.setattr(socket, "getaddrinfo", fake_getaddrinfo)
|
monkeypatch.setattr(socket, "getaddrinfo", fake_getaddrinfo)
|
||||||
@@ -4103,7 +4531,7 @@ class TestWebhookSecurity:
|
|||||||
def test_strips_user_supplied_host_header(
|
def test_strips_user_supplied_host_header(
|
||||||
self,
|
self,
|
||||||
httpx_mock: HTTPXMock,
|
httpx_mock: HTTPXMock,
|
||||||
resolve_to,
|
resolve_to: Callable[[str], None],
|
||||||
) -> None:
|
) -> None:
|
||||||
"""
|
"""
|
||||||
GIVEN:
|
GIVEN:
|
||||||
@@ -4169,7 +4597,7 @@ class TestDateWorkflowLocalization(
|
|||||||
self,
|
self,
|
||||||
title_template: str,
|
title_template: str,
|
||||||
expected_title: str,
|
expected_title: str,
|
||||||
):
|
) -> None:
|
||||||
"""
|
"""
|
||||||
GIVEN:
|
GIVEN:
|
||||||
- Document added workflow with title template using localize_date filter
|
- Document added workflow with title template using localize_date filter
|
||||||
@@ -4234,7 +4662,7 @@ class TestDateWorkflowLocalization(
|
|||||||
self,
|
self,
|
||||||
title_template: str,
|
title_template: str,
|
||||||
expected_title: str,
|
expected_title: str,
|
||||||
):
|
) -> None:
|
||||||
"""
|
"""
|
||||||
GIVEN:
|
GIVEN:
|
||||||
- Document updated workflow with title template using localize_date filter
|
- Document updated workflow with title template using localize_date filter
|
||||||
@@ -4310,7 +4738,7 @@ class TestDateWorkflowLocalization(
|
|||||||
settings: SettingsWrapper,
|
settings: SettingsWrapper,
|
||||||
title_template: str,
|
title_template: str,
|
||||||
expected_title: str,
|
expected_title: str,
|
||||||
):
|
) -> None:
|
||||||
trigger = WorkflowTrigger.objects.create(
|
trigger = WorkflowTrigger.objects.create(
|
||||||
type=WorkflowTrigger.WorkflowTriggerType.CONSUMPTION,
|
type=WorkflowTrigger.WorkflowTriggerType.CONSUMPTION,
|
||||||
sources=f"{DocumentSource.ApiUpload}",
|
sources=f"{DocumentSource.ApiUpload}",
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import logging
|
import logging
|
||||||
import re
|
import re
|
||||||
|
import uuid
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
@@ -15,6 +16,7 @@ from documents.models import Document
|
|||||||
from documents.models import DocumentType
|
from documents.models import DocumentType
|
||||||
from documents.models import WorkflowAction
|
from documents.models import WorkflowAction
|
||||||
from documents.models import WorkflowTrigger
|
from documents.models import WorkflowTrigger
|
||||||
|
from documents.plugins.base import StopConsumeTaskError
|
||||||
from documents.signals import document_consumption_finished
|
from documents.signals import document_consumption_finished
|
||||||
from documents.templating.workflows import parse_w_workflow_placeholders
|
from documents.templating.workflows import parse_w_workflow_placeholders
|
||||||
from documents.workflows.webhooks import send_webhook
|
from documents.workflows.webhooks import send_webhook
|
||||||
@@ -338,3 +340,33 @@ def execute_password_removal_action(
|
|||||||
document.pk,
|
document.pk,
|
||||||
extra={"group": logging_group},
|
extra={"group": logging_group},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def execute_move_to_trash_action(
|
||||||
|
action: WorkflowAction,
|
||||||
|
document: Document | ConsumableDocument,
|
||||||
|
logging_group: uuid.UUID | None,
|
||||||
|
) -> None:
|
||||||
|
"""
|
||||||
|
Execute a move to trash action for a workflow on an existing document or a
|
||||||
|
document in consumption. In case of an existing document it soft-deletes
|
||||||
|
the document. In case of consumption it aborts consumption and deletes the
|
||||||
|
file.
|
||||||
|
"""
|
||||||
|
if isinstance(document, Document):
|
||||||
|
document.delete()
|
||||||
|
logger.debug(
|
||||||
|
f"Moved document {document} to trash",
|
||||||
|
extra={"group": logging_group},
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
if document.original_file.exists():
|
||||||
|
document.original_file.unlink()
|
||||||
|
logger.info(
|
||||||
|
f"Workflow move to trash action triggered during consumption, "
|
||||||
|
f"deleting file {document.original_file}",
|
||||||
|
extra={"group": logging_group},
|
||||||
|
)
|
||||||
|
raise StopConsumeTaskError(
|
||||||
|
"Document deleted by workflow action during consumption",
|
||||||
|
)
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ msgid ""
|
|||||||
msgstr ""
|
msgstr ""
|
||||||
"Project-Id-Version: paperless-ngx\n"
|
"Project-Id-Version: paperless-ngx\n"
|
||||||
"Report-Msgid-Bugs-To: \n"
|
"Report-Msgid-Bugs-To: \n"
|
||||||
"POT-Creation-Date: 2026-02-16 17:32+0000\n"
|
"POT-Creation-Date: 2026-02-24 00:43+0000\n"
|
||||||
"PO-Revision-Date: 2022-02-17 04:17\n"
|
"PO-Revision-Date: 2022-02-17 04:17\n"
|
||||||
"Last-Translator: \n"
|
"Last-Translator: \n"
|
||||||
"Language-Team: English\n"
|
"Language-Team: English\n"
|
||||||
@@ -89,7 +89,7 @@ msgstr ""
|
|||||||
msgid "Automatic"
|
msgid "Automatic"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: documents/models.py:66 documents/models.py:444 documents/models.py:1659
|
#: documents/models.py:66 documents/models.py:444 documents/models.py:1663
|
||||||
#: paperless_mail/models.py:23 paperless_mail/models.py:143
|
#: paperless_mail/models.py:23 paperless_mail/models.py:143
|
||||||
msgid "name"
|
msgid "name"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
@@ -252,7 +252,7 @@ msgid "The position of this document in your physical document archive."
|
|||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: documents/models.py:313 documents/models.py:688 documents/models.py:742
|
#: documents/models.py:313 documents/models.py:688 documents/models.py:742
|
||||||
#: documents/models.py:1702
|
#: documents/models.py:1706
|
||||||
msgid "document"
|
msgid "document"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
@@ -1093,193 +1093,197 @@ msgid "Password removal"
|
|||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: documents/models.py:1414
|
#: documents/models.py:1414
|
||||||
|
msgid "Move to trash"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: documents/models.py:1418
|
||||||
msgid "Workflow Action Type"
|
msgid "Workflow Action Type"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: documents/models.py:1419 documents/models.py:1661
|
#: documents/models.py:1423 documents/models.py:1665
|
||||||
#: paperless_mail/models.py:145
|
#: paperless_mail/models.py:145
|
||||||
msgid "order"
|
msgid "order"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: documents/models.py:1422
|
#: documents/models.py:1426
|
||||||
msgid "assign title"
|
msgid "assign title"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: documents/models.py:1426
|
#: documents/models.py:1430
|
||||||
msgid "Assign a document title, must be a Jinja2 template, see documentation."
|
msgid "Assign a document title, must be a Jinja2 template, see documentation."
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: documents/models.py:1434 paperless_mail/models.py:274
|
#: documents/models.py:1438 paperless_mail/models.py:274
|
||||||
msgid "assign this tag"
|
msgid "assign this tag"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: documents/models.py:1443 paperless_mail/models.py:282
|
#: documents/models.py:1447 paperless_mail/models.py:282
|
||||||
msgid "assign this document type"
|
msgid "assign this document type"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: documents/models.py:1452 paperless_mail/models.py:296
|
#: documents/models.py:1456 paperless_mail/models.py:296
|
||||||
msgid "assign this correspondent"
|
msgid "assign this correspondent"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: documents/models.py:1461
|
#: documents/models.py:1465
|
||||||
msgid "assign this storage path"
|
msgid "assign this storage path"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: documents/models.py:1470
|
#: documents/models.py:1474
|
||||||
msgid "assign this owner"
|
msgid "assign this owner"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: documents/models.py:1477
|
#: documents/models.py:1481
|
||||||
msgid "grant view permissions to these users"
|
msgid "grant view permissions to these users"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: documents/models.py:1484
|
#: documents/models.py:1488
|
||||||
msgid "grant view permissions to these groups"
|
msgid "grant view permissions to these groups"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: documents/models.py:1491
|
#: documents/models.py:1495
|
||||||
msgid "grant change permissions to these users"
|
msgid "grant change permissions to these users"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: documents/models.py:1498
|
#: documents/models.py:1502
|
||||||
msgid "grant change permissions to these groups"
|
msgid "grant change permissions to these groups"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: documents/models.py:1505
|
#: documents/models.py:1509
|
||||||
msgid "assign these custom fields"
|
msgid "assign these custom fields"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: documents/models.py:1509
|
#: documents/models.py:1513
|
||||||
msgid "custom field values"
|
msgid "custom field values"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: documents/models.py:1513
|
#: documents/models.py:1517
|
||||||
msgid "Optional values to assign to the custom fields."
|
msgid "Optional values to assign to the custom fields."
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: documents/models.py:1522
|
#: documents/models.py:1526
|
||||||
msgid "remove these tag(s)"
|
msgid "remove these tag(s)"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: documents/models.py:1527
|
#: documents/models.py:1531
|
||||||
msgid "remove all tags"
|
msgid "remove all tags"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: documents/models.py:1534
|
#: documents/models.py:1538
|
||||||
msgid "remove these document type(s)"
|
msgid "remove these document type(s)"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: documents/models.py:1539
|
#: documents/models.py:1543
|
||||||
msgid "remove all document types"
|
msgid "remove all document types"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: documents/models.py:1546
|
#: documents/models.py:1550
|
||||||
msgid "remove these correspondent(s)"
|
msgid "remove these correspondent(s)"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: documents/models.py:1551
|
#: documents/models.py:1555
|
||||||
msgid "remove all correspondents"
|
msgid "remove all correspondents"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: documents/models.py:1558
|
#: documents/models.py:1562
|
||||||
msgid "remove these storage path(s)"
|
msgid "remove these storage path(s)"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: documents/models.py:1563
|
#: documents/models.py:1567
|
||||||
msgid "remove all storage paths"
|
msgid "remove all storage paths"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: documents/models.py:1570
|
#: documents/models.py:1574
|
||||||
msgid "remove these owner(s)"
|
msgid "remove these owner(s)"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: documents/models.py:1575
|
#: documents/models.py:1579
|
||||||
msgid "remove all owners"
|
msgid "remove all owners"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: documents/models.py:1582
|
#: documents/models.py:1586
|
||||||
msgid "remove view permissions for these users"
|
msgid "remove view permissions for these users"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: documents/models.py:1589
|
#: documents/models.py:1593
|
||||||
msgid "remove view permissions for these groups"
|
msgid "remove view permissions for these groups"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: documents/models.py:1596
|
#: documents/models.py:1600
|
||||||
msgid "remove change permissions for these users"
|
msgid "remove change permissions for these users"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: documents/models.py:1603
|
#: documents/models.py:1607
|
||||||
msgid "remove change permissions for these groups"
|
msgid "remove change permissions for these groups"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: documents/models.py:1608
|
#: documents/models.py:1612
|
||||||
msgid "remove all permissions"
|
msgid "remove all permissions"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: documents/models.py:1615
|
#: documents/models.py:1619
|
||||||
msgid "remove these custom fields"
|
msgid "remove these custom fields"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: documents/models.py:1620
|
#: documents/models.py:1624
|
||||||
msgid "remove all custom fields"
|
msgid "remove all custom fields"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: documents/models.py:1629
|
#: documents/models.py:1633
|
||||||
msgid "email"
|
msgid "email"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: documents/models.py:1638
|
#: documents/models.py:1642
|
||||||
msgid "webhook"
|
msgid "webhook"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: documents/models.py:1642
|
#: documents/models.py:1646
|
||||||
msgid "passwords"
|
msgid "passwords"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: documents/models.py:1646
|
#: documents/models.py:1650
|
||||||
msgid ""
|
msgid ""
|
||||||
"Passwords to try when removing PDF protection. Separate with commas or new "
|
"Passwords to try when removing PDF protection. Separate with commas or new "
|
||||||
"lines."
|
"lines."
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: documents/models.py:1651
|
#: documents/models.py:1655
|
||||||
msgid "workflow action"
|
msgid "workflow action"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: documents/models.py:1652
|
#: documents/models.py:1656
|
||||||
msgid "workflow actions"
|
msgid "workflow actions"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: documents/models.py:1667
|
#: documents/models.py:1671
|
||||||
msgid "triggers"
|
msgid "triggers"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: documents/models.py:1674
|
#: documents/models.py:1678
|
||||||
msgid "actions"
|
msgid "actions"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: documents/models.py:1677 paperless_mail/models.py:154
|
#: documents/models.py:1681 paperless_mail/models.py:154
|
||||||
msgid "enabled"
|
msgid "enabled"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: documents/models.py:1688
|
#: documents/models.py:1692
|
||||||
msgid "workflow"
|
msgid "workflow"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: documents/models.py:1692
|
#: documents/models.py:1696
|
||||||
msgid "workflow trigger type"
|
msgid "workflow trigger type"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: documents/models.py:1706
|
#: documents/models.py:1710
|
||||||
msgid "date run"
|
msgid "date run"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: documents/models.py:1712
|
#: documents/models.py:1716
|
||||||
msgid "workflow run"
|
msgid "workflow run"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: documents/models.py:1713
|
#: documents/models.py:1717
|
||||||
msgid "workflow runs"
|
msgid "workflow runs"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user