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
This commit is contained in:
Jan Kleine 2025-10-25 12:46:28 +00:00
parent 63dab0ab09
commit a9ecb629a7
6 changed files with 1039 additions and 5 deletions

View file

@ -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",
),
),
]

View file

@ -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")

View file

@ -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:

View file

@ -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)

View file

@ -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)

View file

@ -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(