diff --git a/.mypy-baseline.txt b/.mypy-baseline.txt
index debe3e12b..68d1cdd5f 100644
--- a/.mypy-baseline.txt
+++ b/.mypy-baseline.txt
@@ -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: 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 "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 "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 "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]
diff --git a/docs/usage.md b/docs/usage.md
index ca57e9018..23a7f1e68 100644
--- a/docs/usage.md
+++ b/docs/usage.md
@@ -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,
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
Titles and webhook payloads can be generated by workflows using [Jinja templates](https://jinja.palletsprojects.com/en/3.1.x/templates/).
diff --git a/src-ui/src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.html b/src-ui/src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.html
index b83a5b344..51b8a2a5d 100644
--- a/src-ui/src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.html
+++ b/src-ui/src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.html
@@ -448,6 +448,13 @@
}
+ @case (WorkflowActionType.MoveToTrash) {
+
+
+
The document will be moved to the trash at the end of the workflow run.
+
+
+ }
}
diff --git a/src-ui/src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.ts b/src-ui/src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.ts
index 37d8bef0d..83e7a40f9 100644
--- a/src-ui/src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.ts
+++ b/src-ui/src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.ts
@@ -143,6 +143,10 @@ export const WORKFLOW_ACTION_OPTIONS = [
id: WorkflowActionType.PasswordRemoval,
name: $localize`Password removal`,
},
+ {
+ id: WorkflowActionType.MoveToTrash,
+ name: $localize`Move to trash`,
+ },
]
export enum TriggerFilterType {
diff --git a/src-ui/src/app/data/workflow-action.ts b/src-ui/src/app/data/workflow-action.ts
index ff1509693..5ddaeba7e 100644
--- a/src-ui/src/app/data/workflow-action.ts
+++ b/src-ui/src/app/data/workflow-action.ts
@@ -6,6 +6,7 @@ export enum WorkflowActionType {
Email = 3,
Webhook = 4,
PasswordRemoval = 5,
+ MoveToTrash = 6,
}
export interface WorkflowActionEmail extends ObjectWithId {
diff --git a/src/documents/migrations/0012_alter_workflowaction_type.py b/src/documents/migrations/0012_alter_workflowaction_type.py
new file mode 100644
index 000000000..4d707937c
--- /dev/null
+++ b/src/documents/migrations/0012_alter_workflowaction_type.py
@@ -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",
+ ),
+ ),
+ ]
diff --git a/src/documents/models.py b/src/documents/models.py
index 7700eba19..b1303f613 100644
--- a/src/documents/models.py
+++ b/src/documents/models.py
@@ -1409,6 +1409,10 @@ class WorkflowAction(models.Model):
5,
_("Password removal"),
)
+ MOVE_TO_TRASH = (
+ 6,
+ _("Move to trash"),
+ )
type = models.PositiveSmallIntegerField(
_("Workflow Action Type"),
diff --git a/src/documents/signals/handlers.py b/src/documents/signals/handlers.py
index 41fb03d9e..a837171a0 100644
--- a/src/documents/signals/handlers.py
+++ b/src/documents/signals/handlers.py
@@ -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.workflows.actions import build_workflow_action_context
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_webhook_action
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
if TYPE_CHECKING:
+ import uuid
+
from documents.classifier import DocumentClassifier
from documents.data_models import ConsumableDocument
from documents.data_models import DocumentMetadataOverrides
@@ -727,7 +730,7 @@ def add_to_index(sender, document, **kwargs) -> None:
def run_workflows_added(
sender,
document: Document,
- logging_group=None,
+ logging_group: uuid.UUID | None = None,
original_file=None,
**kwargs,
) -> None:
@@ -743,7 +746,7 @@ def run_workflows_added(
def run_workflows_updated(
sender,
document: Document,
- logging_group=None,
+ logging_group: uuid.UUID | None = None,
**kwargs,
) -> None:
run_workflows(
@@ -757,7 +760,7 @@ def run_workflows(
trigger_type: WorkflowTrigger.WorkflowTriggerType,
document: Document | ConsumableDocument,
workflow_to_run: Workflow | None = None,
- logging_group=None,
+ logging_group: uuid.UUID | None = None,
overrides: DocumentMetadataOverrides | None = None,
original_file: Path | None = None,
) -> tuple[DocumentMetadataOverrides, str] | None:
@@ -783,14 +786,33 @@ def run_workflows(
for workflow in workflows:
if not use_overrides:
- # This can be called from bulk_update_documents, which may be running multiple times
- # 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))
+ if TYPE_CHECKING:
+ assert isinstance(document, Document)
+ try:
+ # This can be called from bulk_update_documents, which may be running multiple times
+ # 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):
action: WorkflowAction
+ has_move_to_trash_action = False
for action in workflow.actions.order_by("order", "pk"):
message = f"Applying {action} from {workflow}"
if not use_overrides:
@@ -834,6 +856,8 @@ def run_workflows(
)
elif action.type == WorkflowAction.WorkflowActionType.PASSWORD_REMOVAL:
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:
# limit title to 128 characters
@@ -848,7 +872,12 @@ def run_workflows(
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 TYPE_CHECKING:
+ assert overrides is not None
return overrides, "\n".join(messages)
diff --git a/src/documents/tests/test_api_workflows.py b/src/documents/tests/test_api_workflows.py
index f07b2b60c..d23a2dc47 100644
--- a/src/documents/tests/test_api_workflows.py
+++ b/src/documents/tests/test_api_workflows.py
@@ -896,3 +896,210 @@ class TestApiWorkflows(DirectoriesMixin, APITestCase):
"Passwords are required",
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)
diff --git a/src/documents/tests/test_workflows.py b/src/documents/tests/test_workflows.py
index 1cd0a9826..55bad6b2c 100644
--- a/src/documents/tests/test_workflows.py
+++ b/src/documents/tests/test_workflows.py
@@ -3,9 +3,11 @@ import json
import shutil
import socket
import tempfile
+from collections.abc import Callable
from datetime import timedelta
from pathlib import Path
from typing import TYPE_CHECKING
+from typing import Any
from unittest import mock
import pytest
@@ -55,6 +57,7 @@ from documents.models import WorkflowActionEmail
from documents.models import WorkflowActionWebhook
from documents.models import WorkflowRun
from documents.models import WorkflowTrigger
+from documents.plugins.base import StopConsumeTaskError
from documents.serialisers import WorkflowTriggerSerializer
from documents.signals import document_consumption_finished
from documents.tests.utils import DirectoriesMixin
@@ -3914,6 +3917,427 @@ class TestWorkflows(
)
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:
def test_send_webhook_data_or_json(
@@ -3956,13 +4380,17 @@ class TestWebhookSend:
@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.
"""
- def _set(ip: str):
- def fake_getaddrinfo(host, *_args, **_kwargs):
+ def _set(ip: str) -> None:
+ def fake_getaddrinfo(
+ host: str,
+ *_args: object,
+ **_kwargs: object,
+ ) -> list[tuple[Any, ...]]:
return [(socket.AF_INET, None, None, "", (ip, 0))]
monkeypatch.setattr(socket, "getaddrinfo", fake_getaddrinfo)
@@ -4103,7 +4531,7 @@ class TestWebhookSecurity:
def test_strips_user_supplied_host_header(
self,
httpx_mock: HTTPXMock,
- resolve_to,
+ resolve_to: Callable[[str], None],
) -> None:
"""
GIVEN:
@@ -4169,7 +4597,7 @@ class TestDateWorkflowLocalization(
self,
title_template: str,
expected_title: str,
- ):
+ ) -> None:
"""
GIVEN:
- Document added workflow with title template using localize_date filter
@@ -4234,7 +4662,7 @@ class TestDateWorkflowLocalization(
self,
title_template: str,
expected_title: str,
- ):
+ ) -> None:
"""
GIVEN:
- Document updated workflow with title template using localize_date filter
@@ -4310,7 +4738,7 @@ class TestDateWorkflowLocalization(
settings: SettingsWrapper,
title_template: str,
expected_title: str,
- ):
+ ) -> None:
trigger = WorkflowTrigger.objects.create(
type=WorkflowTrigger.WorkflowTriggerType.CONSUMPTION,
sources=f"{DocumentSource.ApiUpload}",
diff --git a/src/documents/workflows/actions.py b/src/documents/workflows/actions.py
index 442bc0abe..46d9f5c4a 100644
--- a/src/documents/workflows/actions.py
+++ b/src/documents/workflows/actions.py
@@ -1,5 +1,6 @@
import logging
import re
+import uuid
from pathlib import Path
from django.conf import settings
@@ -15,6 +16,7 @@ from documents.models import Document
from documents.models import DocumentType
from documents.models import WorkflowAction
from documents.models import WorkflowTrigger
+from documents.plugins.base import StopConsumeTaskError
from documents.signals import document_consumption_finished
from documents.templating.workflows import parse_w_workflow_placeholders
from documents.workflows.webhooks import send_webhook
@@ -338,3 +340,33 @@ def execute_password_removal_action(
document.pk,
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",
+ )