From a9ecb629a77a64ef34d454384eaaaf51ac1f3734 Mon Sep 17 00:00:00 2001 From: Jan Kleine Date: Sat, 25 Oct 2025 12:46:28 +0000 Subject: [PATCH] Feature: add backend support for deletion workflow action Implement deletion workflow action with soft/hard delete options and validation: - Add WorkflowActionDeletion model with skip_trash flag - Add DELETION action type to WorkflowAction enum - Handle document deletion in workflow signal handlers - Add validation to ensure deletion action is always last in workflow - Skip remaining workflows if document was deleted - Clean up orphaned WorkflowActionDeletion records - Add comprehensive test coverage for deletion action validation and execution --- ...tion_alter_workflowaction_type_and_more.py | 62 +++ src/documents/models.py | 23 + src/documents/serialisers.py | 44 ++ src/documents/signals/handlers.py | 46 +- src/documents/tests/test_api_workflows.py | 431 +++++++++++++++++ src/documents/tests/test_workflows.py | 438 ++++++++++++++++++ 6 files changed, 1039 insertions(+), 5 deletions(-) create mode 100644 src/documents/migrations/1073_workflowactiondeletion_alter_workflowaction_type_and_more.py diff --git a/src/documents/migrations/1073_workflowactiondeletion_alter_workflowaction_type_and_more.py b/src/documents/migrations/1073_workflowactiondeletion_alter_workflowaction_type_and_more.py new file mode 100644 index 000000000..41203e03e --- /dev/null +++ b/src/documents/migrations/1073_workflowactiondeletion_alter_workflowaction_type_and_more.py @@ -0,0 +1,62 @@ +# Generated by Django 5.2.6 on 2025-10-24 20:35 + +import django.db.models.deletion +from django.db import migrations +from django.db import models + + +class Migration(migrations.Migration): + dependencies = [ + ("documents", "1072_workflowtrigger_filter_custom_field_query_and_more"), + ] + + operations = [ + migrations.CreateModel( + name="WorkflowActionDeletion", + fields=[ + ( + "id", + models.AutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ( + "skip_trash", + models.BooleanField( + default=False, + verbose_name="Skip trash and delete directly", + ), + ), + ], + ), + migrations.AlterField( + model_name="workflowaction", + name="type", + field=models.PositiveIntegerField( + choices=[ + (1, "Assignment"), + (2, "Removal"), + (3, "Email"), + (4, "Webhook"), + (5, "Deletion"), + ], + default=1, + verbose_name="Workflow Action Type", + ), + ), + migrations.AddField( + model_name="workflowaction", + name="deletion", + field=models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="action", + to="documents.workflowactiondeletion", + verbose_name="delete", + ), + ), + ] diff --git a/src/documents/models.py b/src/documents/models.py index 4794bc82f..31e3c4e2d 100644 --- a/src/documents/models.py +++ b/src/documents/models.py @@ -1269,6 +1269,16 @@ class WorkflowActionWebhook(models.Model): return f"Workflow Webhook Action {self.pk}" +class WorkflowActionDeletion(models.Model): + skip_trash = models.BooleanField( + default=False, + verbose_name=_("Skip trash and delete directly"), + ) + + def __str__(self): + return f"Workflow Delete Action {self.pk}" + + class WorkflowAction(models.Model): class WorkflowActionType(models.IntegerChoices): ASSIGNMENT = ( @@ -1287,6 +1297,10 @@ class WorkflowAction(models.Model): 4, _("Webhook"), ) + DELETION = ( + 5, + _("Deletion"), + ) type = models.PositiveIntegerField( _("Workflow Action Type"), @@ -1514,6 +1528,15 @@ class WorkflowAction(models.Model): verbose_name=_("webhook"), ) + deletion = models.ForeignKey( + WorkflowActionDeletion, + null=True, + blank=True, + on_delete=models.SET_NULL, + related_name="action", + verbose_name=_("delete"), + ) + class Meta: verbose_name = _("workflow action") verbose_name_plural = _("workflow actions") diff --git a/src/documents/serialisers.py b/src/documents/serialisers.py index 25207bdfa..44a6afcd5 100644 --- a/src/documents/serialisers.py +++ b/src/documents/serialisers.py @@ -62,6 +62,7 @@ from documents.models import Tag from documents.models import UiSettings from documents.models import Workflow from documents.models import WorkflowAction +from documents.models import WorkflowActionDeletion from documents.models import WorkflowActionEmail from documents.models import WorkflowActionWebhook from documents.models import WorkflowTrigger @@ -2363,6 +2364,17 @@ class WorkflowActionWebhookSerializer(serializers.ModelSerializer): ] +class WorkflowActionDeletionSerializer(serializers.ModelSerializer): + id = serializers.IntegerField(allow_null=True, required=False) + + class Meta: + model = WorkflowActionDeletion + fields = [ + "id", + "skip_trash", + ] + + class WorkflowActionSerializer(serializers.ModelSerializer): id = serializers.IntegerField(required=False, allow_null=True) assign_correspondent = CorrespondentField(allow_null=True, required=False) @@ -2371,6 +2383,7 @@ class WorkflowActionSerializer(serializers.ModelSerializer): assign_storage_path = StoragePathField(allow_null=True, required=False) email = WorkflowActionEmailSerializer(allow_null=True, required=False) webhook = WorkflowActionWebhookSerializer(allow_null=True, required=False) + deletion = WorkflowActionDeletionSerializer(allow_null=True, required=False) class Meta: model = WorkflowAction @@ -2408,6 +2421,7 @@ class WorkflowActionSerializer(serializers.ModelSerializer): "remove_change_groups", "email", "webhook", + "deletion", ] def validate(self, attrs): @@ -2552,6 +2566,7 @@ class WorkflowSerializer(serializers.ModelSerializer): email_data = action.pop("email", None) webhook_data = action.pop("webhook", None) + deletion_data = action.pop("deletion", None) action_instance, _ = WorkflowAction.objects.update_or_create( id=action.get("id"), @@ -2578,6 +2593,16 @@ class WorkflowSerializer(serializers.ModelSerializer): action_instance.webhook = webhook action_instance.save() + if deletion_data is not None: + serializer = WorkflowActionDeletionSerializer(data=deletion_data) + serializer.is_valid(raise_exception=True) + deletion, _ = WorkflowActionDeletion.objects.update_or_create( + id=deletion_data.get("id"), + defaults=serializer.validated_data, + ) + action_instance.deletion = deletion + action_instance.save() + if assign_tags is not None: action_instance.assign_tags.set(assign_tags) if assign_view_users is not None: @@ -2613,6 +2638,24 @@ class WorkflowSerializer(serializers.ModelSerializer): set_actions.append(action_instance) + if set_actions: + for i, action in enumerate(set_actions): + if ( + action.type == WorkflowAction.WorkflowActionType.DELETION + and i != len(set_actions) - 1 + ): + actions_errors = [None] * len(set_actions) + actions_errors[i] = { + "type": [ + "Delete action must be the last action in the workflow", + ], + } + raise serializers.ValidationError( + { + "actions": actions_errors, + }, + ) + if triggers is not serializers.empty: instance.triggers.set(set_triggers) if actions is not serializers.empty: @@ -2634,6 +2677,7 @@ class WorkflowSerializer(serializers.ModelSerializer): WorkflowActionEmail.objects.filter(action=None).delete() WorkflowActionWebhook.objects.filter(action=None).delete() + WorkflowActionDeletion.objects.filter(action=None).delete() def create(self, validated_data) -> Workflow: if "triggers" in validated_data: diff --git a/src/documents/signals/handlers.py b/src/documents/signals/handlers.py index 8862b064b..b98d1e620 100644 --- a/src/documents/signals/handlers.py +++ b/src/documents/signals/handlers.py @@ -1294,6 +1294,20 @@ def run_workflows( extra={"group": logging_group}, ) + def deletion_action(): + if action.deletion.skip_trash: + document.hard_delete() + logger.debug( + f"Hard deleted document {document}", + extra={"group": logging_group}, + ) + else: + document.delete() + logger.debug( + f"Moved document {document} to trash", + extra={"group": logging_group}, + ) + use_overrides = overrides is not None if original_file is None: original_file = ( @@ -1328,14 +1342,31 @@ 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)) + 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_deletion_action = False for action in workflow.actions.all(): message = f"Applying {action} from {workflow}" if not use_overrides: @@ -1351,6 +1382,8 @@ def run_workflows( email_action() elif action.type == WorkflowAction.WorkflowActionType.WEBHOOK: webhook_action() + elif action.type == WorkflowAction.WorkflowActionType.DELETION: + has_deletion_action = True if not use_overrides: # limit title to 128 characters @@ -1365,6 +1398,9 @@ def run_workflows( document=document if not use_overrides else None, ) + if has_deletion_action: + deletion_action() + if use_overrides: return overrides, "\n".join(messages) diff --git a/src/documents/tests/test_api_workflows.py b/src/documents/tests/test_api_workflows.py index 9efdb8451..5619417bd 100644 --- a/src/documents/tests/test_api_workflows.py +++ b/src/documents/tests/test_api_workflows.py @@ -808,3 +808,434 @@ class TestApiWorkflows(DirectoriesMixin, APITestCase): self.assertEqual(response.status_code, status.HTTP_200_OK) self.action.refresh_from_db() self.assertEqual(self.action.assign_title, "Patched Title") + + def test_deletion_action_validation(self): + """ + GIVEN: + - API request to create a workflow with a deletion 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.DELETION, + "deletion": { + "skip_trash": False, + }, + }, + ], + }, + ), + 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.DELETION, + "deletion": { + "skip_trash": True, + }, + }, + ], + }, + ), + content_type="application/json", + ) + self.assertEqual(response.status_code, status.HTTP_201_CREATED) + + def test_deletion_action_as_last_action_valid(self): + """ + GIVEN: + - API request to create a workflow with multiple actions + - Deletion 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 Deletion 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.DELETION, + "deletion": { + "skip_trash": False, + }, + }, + ], + }, + ), + content_type="application/json", + ) + self.assertEqual(response.status_code, status.HTTP_201_CREATED) + + def test_deletion_action_in_middle_invalid(self): + """ + GIVEN: + - API request to create a workflow with deletion action not at the end + WHEN: + - API is called + THEN: + - HTTP 400 error with validation message + """ + response = self.client.post( + self.ENDPOINT, + json.dumps( + { + "name": "Workflow with Deletion in Middle", + "order": 1, + "triggers": [ + { + "type": WorkflowTrigger.WorkflowTriggerType.CONSUMPTION, + "sources": [DocumentSource.ApiUpload], + "filter_filename": "*", + }, + ], + "actions": [ + { + "type": WorkflowAction.WorkflowActionType.DELETION, + "deletion": { + "skip_trash": False, + }, + }, + { + "type": WorkflowAction.WorkflowActionType.ASSIGNMENT, + "assign_title": "After Deletion", + }, + ], + }, + ), + content_type="application/json", + ) + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + self.assertIn("Deletion action must be the last action", str(response.data)) + + def test_multiple_deletion_actions_invalid(self): + """ + GIVEN: + - API request to create a workflow with multiple deletion actions + WHEN: + - API is called + THEN: + - HTTP 400 error with validation message + - Multiple deletions are caught because the first one is not last + """ + response = self.client.post( + self.ENDPOINT, + json.dumps( + { + "name": "Workflow with Multiple Deletions", + "order": 1, + "triggers": [ + { + "type": WorkflowTrigger.WorkflowTriggerType.CONSUMPTION, + "sources": [DocumentSource.ApiUpload], + "filter_filename": "*", + }, + ], + "actions": [ + { + "type": WorkflowAction.WorkflowActionType.DELETION, + "deletion": { + "skip_trash": False, + }, + }, + { + "type": WorkflowAction.WorkflowActionType.DELETION, + "deletion": { + "skip_trash": True, + }, + }, + ], + }, + ), + content_type="application/json", + ) + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + self.assertIn("Deletion action must be the last action", str(response.data)) + + def test_update_workflow_add_action_after_deletion_invalid(self): + """ + GIVEN: + - Existing workflow with deletion action at end + WHEN: + - PATCH to add action after deletion + THEN: + - HTTP 400 error with validation message + """ + response = self.client.post( + self.ENDPOINT, + json.dumps( + { + "name": "Workflow to Update", + "order": 1, + "triggers": [ + { + "type": WorkflowTrigger.WorkflowTriggerType.CONSUMPTION, + "sources": [DocumentSource.ApiUpload], + "filter_filename": "*", + }, + ], + "actions": [ + { + "type": WorkflowAction.WorkflowActionType.DELETION, + "deletion": { + "skip_trash": False, + }, + }, + ], + }, + ), + 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.DELETION, + "deletion": { + "skip_trash": False, + }, + }, + { + "type": WorkflowAction.WorkflowActionType.ASSIGNMENT, + "assign_title": "After Deletion", + }, + ], + }, + ), + content_type="application/json", + ) + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + self.assertIn("Deletion action must be the last action", str(response.data)) + + def test_update_workflow_reorder_deletion_to_middle_invalid(self): + """ + GIVEN: + - Existing workflow with assignment then deletion + WHEN: + - PATCH to reorder to deletion then assignment + THEN: + - HTTP 400 error with validation message + """ + response = self.client.post( + self.ENDPOINT, + json.dumps( + { + "name": "Workflow to Reorder", + "order": 1, + "triggers": [ + { + "type": WorkflowTrigger.WorkflowTriggerType.CONSUMPTION, + "sources": [DocumentSource.ApiUpload], + "filter_filename": "*", + }, + ], + "actions": [ + { + "type": WorkflowAction.WorkflowActionType.ASSIGNMENT, + "assign_title": "First", + }, + { + "type": WorkflowAction.WorkflowActionType.DELETION, + "deletion": { + "skip_trash": False, + }, + }, + ], + }, + ), + 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.DELETION, + "deletion": { + "skip_trash": False, + }, + }, + { + "type": WorkflowAction.WorkflowActionType.ASSIGNMENT, + "assign_title": "Second", + }, + ], + }, + ), + content_type="application/json", + ) + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + self.assertIn("Deletion action must be the last action", str(response.data)) + + def test_update_workflow_add_deletion_at_end_valid(self): + """ + GIVEN: + - Existing workflow without deletion action + WHEN: + - PATCH to add deletion action at end + THEN: + - HTTP 200 success + """ + response = self.client.post( + self.ENDPOINT, + json.dumps( + { + "name": "Workflow to Add Deletion", + "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.DELETION, + "deletion": { + "skip_trash": False, + }, + }, + ], + }, + ), + content_type="application/json", + ) + self.assertEqual(response.status_code, status.HTTP_200_OK) + + def test_update_workflow_remove_deletion_action_valid(self): + """ + GIVEN: + - Existing workflow with deletion action + WHEN: + - PATCH to remove deletion action + THEN: + - HTTP 200 success + """ + response = self.client.post( + self.ENDPOINT, + json.dumps( + { + "name": "Workflow to Remove Deletion", + "order": 1, + "triggers": [ + { + "type": WorkflowTrigger.WorkflowTriggerType.CONSUMPTION, + "sources": [DocumentSource.ApiUpload], + "filter_filename": "*", + }, + ], + "actions": [ + { + "type": WorkflowAction.WorkflowActionType.ASSIGNMENT, + "assign_title": "First Action", + }, + { + "type": WorkflowAction.WorkflowActionType.DELETION, + "deletion": { + "skip_trash": False, + }, + }, + ], + }, + ), + 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 a6da01578..fed636eee 100644 --- a/src/documents/tests/test_workflows.py +++ b/src/documents/tests/test_workflows.py @@ -44,6 +44,7 @@ from documents.models import StoragePath from documents.models import Tag from documents.models import Workflow from documents.models import WorkflowAction +from documents.models import WorkflowActionDeletion from documents.models import WorkflowActionEmail from documents.models import WorkflowActionWebhook from documents.models import WorkflowRun @@ -3406,6 +3407,443 @@ class TestWorkflows( mock_post.assert_called_once() + def test_workflow_delete_action_soft_delete(self): + """ + GIVEN: + - Document updated workflow with delete action + - skip_trash is False + WHEN: + - Document that matches is updated + THEN: + - Document is moved to trash (soft deleted) + """ + trigger = WorkflowTrigger.objects.create( + type=WorkflowTrigger.WorkflowTriggerType.DOCUMENT_UPDATED, + ) + delete_action = WorkflowActionDeletion.objects.create( + skip_trash=False, + ) + self.assertEqual( + str(delete_action), + f"Workflow Delete Action {delete_action.id}", + ) + action = WorkflowAction.objects.create( + type=WorkflowAction.WorkflowActionType.DELETION, + deletion=delete_action, + ) + 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) + + def test_workflow_delete_action_hard_delete(self): + """ + GIVEN: + - Document updated workflow with delete action + - skip_trash is True + WHEN: + - Document that matches is updated + THEN: + - Document is hard deleted + """ + trigger = WorkflowTrigger.objects.create( + type=WorkflowTrigger.WorkflowTriggerType.DOCUMENT_UPDATED, + ) + delete_action = WorkflowActionDeletion.objects.create( + skip_trash=True, + ) + action = WorkflowAction.objects.create( + type=WorkflowAction.WorkflowActionType.DELETION, + deletion=delete_action, + ) + 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(), 0) + + @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_deletion_with_email_action(self, mock_email_send): + """ + GIVEN: + - Workflow with email action, then deletion action + - skip_trash is True + WHEN: + - Document matches and workflow runs + THEN: + - Email is sent first + - Document is hard 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, + ) + deletion_action = WorkflowActionDeletion.objects.create( + skip_trash=True, + ) + deletion_workflow_action = WorkflowAction.objects.create( + type=WorkflowAction.WorkflowActionType.DELETION, + deletion=deletion_action, + ) + w = Workflow.objects.create( + name="Workflow with email then deletion", + order=0, + ) + w.triggers.add(trigger) + w.actions.add(email_workflow_action, deletion_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(), 0) + + @override_settings( + PAPERLESS_URL="http://localhost:8000", + ) + @mock.patch("documents.signals.handlers.send_webhook.delay") + def test_workflow_deletion_with_webhook_action(self, mock_webhook_delay): + """ + GIVEN: + - Workflow with webhook action (include_document=True), then deletion action + - skip_trash is True + WHEN: + - Document matches and workflow runs + THEN: + - Webhook .delay() is called with complete data including file bytes + - Document is hard 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="http://paperless-ngx.com/webhook", + include_document=True, + ) + webhook_workflow_action = WorkflowAction.objects.create( + type=WorkflowAction.WorkflowActionType.WEBHOOK, + webhook=webhook_action, + ) + deletion_action = WorkflowActionDeletion.objects.create( + skip_trash=True, + ) + deletion_workflow_action = WorkflowAction.objects.create( + type=WorkflowAction.WorkflowActionType.DELETION, + deletion=deletion_action, + ) + w = Workflow.objects.create( + name="Workflow with webhook then deletion", + order=0, + ) + w.triggers.add(trigger) + w.actions.add(webhook_workflow_action, deletion_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"], "http://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(), 0) + + @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_deletion_after_email_failure(self, mock_email_send): + """ + GIVEN: + - Workflow with email action (that fails), then deletion action + WHEN: + - Document matches and workflow runs + - Email action raises exception + THEN: + - Email failure is logged + - Deletion still executes successfully + """ + 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, + ) + deletion_action = WorkflowActionDeletion.objects.create( + skip_trash=True, + ) + deletion_workflow_action = WorkflowAction.objects.create( + type=WorkflowAction.WorkflowActionType.DELETION, + deletion=deletion_action, + ) + w = Workflow.objects.create( + name="Workflow with failing email then deletion", + order=0, + ) + w.triggers.add(trigger) + w.actions.add(email_workflow_action, deletion_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.handlers", 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(), 0) + + def test_multiple_workflows_deletion_then_assignment(self): + """ + GIVEN: + - Workflow 1 (order=0) with deletion 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 deletion and skipping of remaining workflows + """ + trigger1 = WorkflowTrigger.objects.create( + type=WorkflowTrigger.WorkflowTriggerType.DOCUMENT_UPDATED, + ) + deletion_action = WorkflowActionDeletion.objects.create( + skip_trash=False, + ) + deletion_workflow_action = WorkflowAction.objects.create( + type=WorkflowAction.WorkflowActionType.DELETION, + deletion=deletion_action, + ) + w1 = Workflow.objects.create( + name="Workflow 1 - Deletion", + order=0, + ) + w1.triggers.add(trigger1) + w1.actions.add(deletion_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.handlers", 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 deletion 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_multiple_workflows_hard_deletion_then_assignment(self): + """ + GIVEN: + - Workflow 1 (order=0) with deletion action (skip_trash=True) + - Workflow 2 (order=1) with assignment action + - Both workflows match the same document + WHEN: + - Workflows run sequentially + THEN: + - First workflow runs and hard deletes document + - Second workflow does not trigger (document no longer exists) + - Logs confirm hard deletion and skipping of remaining workflows + """ + trigger1 = WorkflowTrigger.objects.create( + type=WorkflowTrigger.WorkflowTriggerType.DOCUMENT_UPDATED, + ) + deletion_action = WorkflowActionDeletion.objects.create( + skip_trash=True, # Hard delete + ) + deletion_workflow_action = WorkflowAction.objects.create( + type=WorkflowAction.WorkflowActionType.DELETION, + deletion=deletion_action, + ) + w1 = Workflow.objects.create( + name="Workflow 1 - Hard Deletion", + order=0, + ) + w1.triggers.add(trigger1) + w1.actions.add(deletion_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.handlers", level="DEBUG") as cm: + run_workflows(WorkflowTrigger.WorkflowTriggerType.DOCUMENT_UPDATED, doc) + + self.assertEqual(Document.objects.count(), 0) + self.assertEqual(Document.deleted_objects.count(), 0) + + log_output = "\n".join(cm.output) + self.assertIn("Hard deleted document", log_output) + self.assertIn( + "Document no longer exists, skipping remaining workflows", + log_output, + ) + class TestWebhookSend: def test_send_webhook_data_or_json(