From c4ea332c610144d51258077ac427b95857bcd56c Mon Sep 17 00:00:00 2001 From: Jan Kleine Date: Tue, 24 Feb 2026 01:42:50 +0100 Subject: [PATCH] Feature: move to trash action for workflows (#11176) Co-authored-by: shamoon <4887959+shamoon@users.noreply.github.com> --- .mypy-baseline.txt | 4 - docs/usage.md | 12 + .../workflow-edit-dialog.component.html | 7 + .../workflow-edit-dialog.component.ts | 4 + src-ui/src/app/data/workflow-action.ts | 1 + .../0012_alter_workflowaction_type.py | 29 ++ src/documents/models.py | 4 + src/documents/signals/handlers.py | 45 +- src/documents/tests/test_api_workflows.py | 207 ++++++++ src/documents/tests/test_workflows.py | 442 +++++++++++++++++- src/documents/workflows/actions.py | 32 ++ 11 files changed, 768 insertions(+), 19 deletions(-) create mode 100644 src/documents/migrations/0012_alter_workflowaction_type.py 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", + )