mirror of
https://github.com/paperless-ngx/paperless-ngx.git
synced 2025-12-15 19:17:03 +01:00
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:
parent
63dab0ab09
commit
a9ecb629a7
6 changed files with 1039 additions and 5 deletions
|
|
@ -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",
|
||||
),
|
||||
),
|
||||
]
|
||||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue