Feature: email, webhook workflow actions (#8108)

This commit is contained in:
shamoon
2024-12-02 16:12:40 -08:00
committed by GitHub
parent 81a5baa451
commit 1d65628132
24 changed files with 2147 additions and 462 deletions

View File

@@ -43,7 +43,7 @@ from documents.plugins.helpers import ProgressStatusOptions
from documents.signals import document_consumption_finished
from documents.signals import document_consumption_started
from documents.signals.handlers import run_workflows
from documents.templating.title import parse_doc_title_w_placeholders
from documents.templating.workflows import parse_w_workflow_placeholders
from documents.utils import copy_basic_file_stats
from documents.utils import copy_file_with_basic_stats
from documents.utils import run_subprocess
@@ -666,7 +666,7 @@ class ConsumerPlugin(
else None
)
return parse_doc_title_w_placeholders(
return parse_w_workflow_placeholders(
title,
correspondent_name,
doc_type_name,

View File

@@ -18,8 +18,7 @@ def settings(request):
)
return {
"EMAIL_ENABLED": django_settings.EMAIL_HOST != "localhost"
or django_settings.EMAIL_HOST_USER != "",
"EMAIL_ENABLED": django_settings.EMAIL_ENABLED,
"DISABLE_REGULAR_LOGIN": django_settings.DISABLE_REGULAR_LOGIN,
"REDIRECT_LOGIN_TO_SSO": django_settings.REDIRECT_LOGIN_TO_SSO,
"ACCOUNT_ALLOW_SIGNUPS": django_settings.ACCOUNT_ALLOW_SIGNUPS,

View File

@@ -0,0 +1,154 @@
# Generated by Django 5.1.3 on 2024-11-26 04:07
import django.db.models.deletion
from django.db import migrations
from django.db import models
class Migration(migrations.Migration):
dependencies = [
("documents", "1058_workflowtrigger_schedule_date_custom_field_and_more"),
]
operations = [
migrations.CreateModel(
name="WorkflowActionEmail",
fields=[
(
"id",
models.AutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
(
"subject",
models.CharField(
help_text="The subject of the email, can include some placeholders, see documentation.",
max_length=256,
verbose_name="email subject",
),
),
(
"body",
models.TextField(
help_text="The body (message) of the email, can include some placeholders, see documentation.",
verbose_name="email body",
),
),
(
"to",
models.TextField(
help_text="The destination email addresses, comma separated.",
verbose_name="emails to",
),
),
(
"include_document",
models.BooleanField(
default=False,
verbose_name="include document in email",
),
),
],
),
migrations.CreateModel(
name="WorkflowActionWebhook",
fields=[
(
"id",
models.AutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
(
"url",
models.URLField(
help_text="The destination URL for the notification.",
verbose_name="webhook url",
),
),
(
"use_params",
models.BooleanField(default=True, verbose_name="use parameters"),
),
(
"params",
models.JSONField(
blank=True,
help_text="The parameters to send with the webhook URL if body not used.",
null=True,
verbose_name="webhook parameters",
),
),
(
"body",
models.TextField(
blank=True,
help_text="The body to send with the webhook URL if parameters not used.",
null=True,
verbose_name="webhook body",
),
),
(
"headers",
models.JSONField(
blank=True,
help_text="The headers to send with the webhook URL.",
null=True,
verbose_name="webhook headers",
),
),
(
"include_document",
models.BooleanField(
default=False,
verbose_name="include document in webhook",
),
),
],
),
migrations.AlterField(
model_name="workflowaction",
name="type",
field=models.PositiveIntegerField(
choices=[
(1, "Assignment"),
(2, "Removal"),
(3, "Email"),
(4, "Webhook"),
],
default=1,
verbose_name="Workflow Action Type",
),
),
migrations.AddField(
model_name="workflowaction",
name="email",
field=models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name="action",
to="documents.workflowactionemail",
verbose_name="email",
),
),
migrations.AddField(
model_name="workflowaction",
name="webhook",
field=models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name="action",
to="documents.workflowactionwebhook",
verbose_name="webhook",
),
),
]

View File

@@ -63,7 +63,7 @@ def reverse_migrate_customfield_selects(apps, schema_editor):
class Migration(migrations.Migration):
dependencies = [
("documents", "1058_workflowtrigger_schedule_date_custom_field_and_more"),
("documents", "1059_workflowactionemail_workflowactionwebhook_and_more"),
]
operations = [

View File

@@ -1160,6 +1160,85 @@ class WorkflowTrigger(models.Model):
return f"WorkflowTrigger {self.pk}"
class WorkflowActionEmail(models.Model):
subject = models.CharField(
_("email subject"),
max_length=256,
null=False,
help_text=_(
"The subject of the email, can include some placeholders, "
"see documentation.",
),
)
body = models.TextField(
_("email body"),
null=False,
help_text=_(
"The body (message) of the email, can include some placeholders, "
"see documentation.",
),
)
to = models.TextField(
_("emails to"),
null=False,
help_text=_(
"The destination email addresses, comma separated.",
),
)
include_document = models.BooleanField(
default=False,
verbose_name=_("include document in email"),
)
def __str__(self):
return f"Workflow Email Action {self.pk}"
class WorkflowActionWebhook(models.Model):
url = models.URLField(
_("webhook url"),
null=False,
help_text=_("The destination URL for the notification."),
)
use_params = models.BooleanField(
default=True,
verbose_name=_("use parameters"),
)
params = models.JSONField(
_("webhook parameters"),
null=True,
blank=True,
help_text=_("The parameters to send with the webhook URL if body not used."),
)
body = models.TextField(
_("webhook body"),
null=True,
blank=True,
help_text=_("The body to send with the webhook URL if parameters not used."),
)
headers = models.JSONField(
_("webhook headers"),
null=True,
blank=True,
help_text=_("The headers to send with the webhook URL."),
)
include_document = models.BooleanField(
default=False,
verbose_name=_("include document in webhook"),
)
def __str__(self):
return f"Workflow Webhook Action {self.pk}"
class WorkflowAction(models.Model):
class WorkflowActionType(models.IntegerChoices):
ASSIGNMENT = (
@@ -1170,6 +1249,14 @@ class WorkflowAction(models.Model):
2,
_("Removal"),
)
EMAIL = (
3,
_("Email"),
)
WEBHOOK = (
4,
_("Webhook"),
)
type = models.PositiveIntegerField(
_("Workflow Action Type"),
@@ -1371,6 +1458,24 @@ class WorkflowAction(models.Model):
verbose_name=_("remove all custom fields"),
)
email = models.ForeignKey(
WorkflowActionEmail,
null=True,
blank=True,
on_delete=models.SET_NULL,
related_name="action",
verbose_name=_("email"),
)
webhook = models.ForeignKey(
WorkflowActionWebhook,
null=True,
blank=True,
on_delete=models.SET_NULL,
related_name="action",
verbose_name=_("webhook"),
)
class Meta:
verbose_name = _("workflow action")
verbose_name_plural = _("workflow actions")

View File

@@ -49,6 +49,8 @@ from documents.models import Tag
from documents.models import UiSettings
from documents.models import Workflow
from documents.models import WorkflowAction
from documents.models import WorkflowActionEmail
from documents.models import WorkflowActionWebhook
from documents.models import WorkflowTrigger
from documents.parsers import is_mime_type_supported
from documents.permissions import get_groups_with_only_permission
@@ -1818,12 +1820,44 @@ class WorkflowTriggerSerializer(serializers.ModelSerializer):
return attrs
class WorkflowActionEmailSerializer(serializers.ModelSerializer):
id = serializers.IntegerField(allow_null=True, required=False)
class Meta:
model = WorkflowActionEmail
fields = [
"id",
"subject",
"body",
"to",
"include_document",
]
class WorkflowActionWebhookSerializer(serializers.ModelSerializer):
id = serializers.IntegerField(allow_null=True, required=False)
class Meta:
model = WorkflowActionWebhook
fields = [
"id",
"url",
"use_params",
"params",
"body",
"headers",
"include_document",
]
class WorkflowActionSerializer(serializers.ModelSerializer):
id = serializers.IntegerField(required=False, allow_null=True)
assign_correspondent = CorrespondentField(allow_null=True, required=False)
assign_tags = TagsField(many=True, allow_null=True, required=False)
assign_document_type = DocumentTypeField(allow_null=True, required=False)
assign_storage_path = StoragePathField(allow_null=True, required=False)
email = WorkflowActionEmailSerializer(allow_null=True, required=False)
webhook = WorkflowActionWebhookSerializer(allow_null=True, required=False)
class Meta:
model = WorkflowAction
@@ -1858,6 +1892,8 @@ class WorkflowActionSerializer(serializers.ModelSerializer):
"remove_view_groups",
"remove_change_users",
"remove_change_groups",
"email",
"webhook",
]
def validate(self, attrs):
@@ -1895,6 +1931,24 @@ class WorkflowActionSerializer(serializers.ModelSerializer):
{"assign_title": f'Invalid f-string detected: "{e.args[0]}"'},
)
if (
"type" in attrs
and attrs["type"] == WorkflowAction.WorkflowActionType.EMAIL
and "email" not in attrs
):
raise serializers.ValidationError(
"Email data is required for email actions",
)
if (
"type" in attrs
and attrs["type"] == WorkflowAction.WorkflowActionType.WEBHOOK
and "webhook" not in attrs
):
raise serializers.ValidationError(
"Webhook data is required for webhook actions",
)
return attrs
@@ -1949,11 +2003,34 @@ class WorkflowSerializer(serializers.ModelSerializer):
remove_change_users = action.pop("remove_change_users", None)
remove_change_groups = action.pop("remove_change_groups", None)
email_data = action.pop("email", None)
webhook_data = action.pop("webhook", None)
action_instance, _ = WorkflowAction.objects.update_or_create(
id=action.get("id"),
defaults=action,
)
if email_data is not None:
serializer = WorkflowActionEmailSerializer(data=email_data)
serializer.is_valid(raise_exception=True)
email, _ = WorkflowActionEmail.objects.update_or_create(
id=email_data.get("id"),
defaults=serializer.validated_data,
)
action_instance.email = email
action_instance.save()
if webhook_data is not None:
serializer = WorkflowActionWebhookSerializer(data=webhook_data)
serializer.is_valid(raise_exception=True)
webhook, _ = WorkflowActionWebhook.objects.update_or_create(
id=webhook_data.get("id"),
defaults=serializer.validated_data,
)
action_instance.webhook = webhook
action_instance.save()
if assign_tags is not None:
action_instance.assign_tags.set(assign_tags)
if assign_view_users is not None:
@@ -2006,6 +2083,9 @@ class WorkflowSerializer(serializers.ModelSerializer):
if action.workflows.all().count() == 0:
action.delete()
WorkflowActionEmail.objects.filter(action=None).delete()
WorkflowActionWebhook.objects.filter(action=None).delete()
def create(self, validated_data) -> Workflow:
if "triggers" in validated_data:
triggers = validated_data.pop("triggers")

View File

@@ -2,6 +2,8 @@ import logging
import os
import shutil
import httpx
from celery import shared_task
from celery import states
from celery.signals import before_task_publish
from celery.signals import task_failure
@@ -12,6 +14,7 @@ from django.contrib.admin.models import ADDITION
from django.contrib.admin.models import LogEntry
from django.contrib.auth.models import User
from django.contrib.contenttypes.models import ContentType
from django.core.mail import EmailMessage
from django.db import DatabaseError
from django.db import close_old_connections
from django.db import models
@@ -41,7 +44,7 @@ from documents.models import WorkflowRun
from documents.models import WorkflowTrigger
from documents.permissions import get_objects_for_user_owner_aware
from documents.permissions import set_permissions_for_object
from documents.templating.title import parse_doc_title_w_placeholders
from documents.templating.workflows import parse_w_workflow_placeholders
logger = logging.getLogger("paperless.handlers")
@@ -570,6 +573,30 @@ def run_workflows_updated(sender, document: Document, logging_group=None, **kwar
)
@shared_task(
retry_backoff=True,
autoretry_for=(httpx.HTTPStatusError,),
max_retries=3,
throws=(httpx.HTTPError,),
)
def send_webhook(url, data, headers, files):
try:
httpx.post(
url,
data=data,
files=files,
headers=headers,
).raise_for_status()
logger.info(
f"Webhook sent to {url}",
)
except Exception as e:
logger.error(
f"Failed attempt sending webhook to {url}: {e}",
)
raise e
def run_workflows(
trigger_type: WorkflowTrigger.WorkflowTriggerType,
document: Document | ConsumableDocument,
@@ -622,7 +649,7 @@ def run_workflows(
if action.assign_title:
if not use_overrides:
try:
document.title = parse_doc_title_w_placeholders(
document.title = parse_w_workflow_placeholders(
action.assign_title,
document.correspondent.name if document.correspondent else "",
document.document_type.name if document.document_type else "",
@@ -879,6 +906,151 @@ def run_workflows(
):
overrides.custom_field_ids.remove(field.pk)
def email_action():
if not settings.EMAIL_ENABLED:
logger.error(
"Email backend has not been configured, cannot send email notifications",
extra={"group": logging_group},
)
return
title = (
document.title
if isinstance(document, Document)
else str(document.original_file)
)
doc_url = None
if isinstance(document, Document):
doc_url = f"{settings.PAPERLESS_URL}/documents/{document.pk}/"
correspondent = document.correspondent.name if document.correspondent else ""
document_type = document.document_type.name if document.document_type else ""
owner_username = document.owner.username if document.owner else ""
filename = document.original_filename or ""
added = timezone.localtime(document.added)
created = timezone.localtime(document.created)
subject = parse_w_workflow_placeholders(
action.email.subject,
correspondent,
document_type,
owner_username,
added,
filename,
created,
title,
doc_url,
)
body = parse_w_workflow_placeholders(
action.email.body,
correspondent,
document_type,
owner_username,
added,
filename,
created,
title,
doc_url,
)
try:
email = EmailMessage(
subject=subject,
body=body,
to=action.email.to.split(","),
)
if action.email.include_document:
email.attach_file(document.source_path)
n_messages = email.send()
logger.debug(
f"Sent {n_messages} notification email(s) to {action.email.to}",
extra={"group": logging_group},
)
except Exception as e:
logger.exception(
f"Error occurred sending notification email: {e}",
extra={"group": logging_group},
)
def webhook_action():
title = (
document.title
if isinstance(document, Document)
else str(document.original_file)
)
doc_url = None
if isinstance(document, Document):
doc_url = f"{settings.PAPERLESS_URL}/documents/{document.pk}/"
correspondent = document.correspondent.name if document.correspondent else ""
document_type = document.document_type.name if document.document_type else ""
owner_username = document.owner.username if document.owner else ""
filename = document.original_filename or ""
added = timezone.localtime(document.added)
created = timezone.localtime(document.created)
try:
data = {}
if action.webhook.use_params:
try:
for key, value in action.webhook.params.items():
data[key] = parse_w_workflow_placeholders(
value,
correspondent,
document_type,
owner_username,
added,
filename,
created,
title,
doc_url,
)
except Exception as e:
logger.error(
f"Error occurred parsing webhook params: {e}",
extra={"group": logging_group},
)
else:
data = parse_w_workflow_placeholders(
action.webhook.body,
correspondent,
document_type,
owner_username,
added,
filename,
created,
title,
doc_url,
)
headers = {}
if action.webhook.headers:
try:
headers = {
str(k): str(v) for k, v in action.webhook.headers.items()
}
except Exception as e:
logger.error(
f"Error occurred parsing webhook headers: {e}",
extra={"group": logging_group},
)
files = None
if action.webhook.include_document:
with open(document.source_path, "rb") as f:
files = {
"file": (document.original_filename, f, document.mime_type),
}
send_webhook.delay(
url=action.webhook.url,
data=data,
headers=headers,
files=files,
)
logger.debug(
f"Webhook to {action.webhook.url} queued",
extra={"group": logging_group},
)
except Exception as e:
logger.exception(
f"Error occurred sending webhook: {e}",
extra={"group": logging_group},
)
use_overrides = overrides is not None
messages = []
@@ -924,6 +1096,10 @@ def run_workflows(
assignment_action()
elif action.type == WorkflowAction.WorkflowActionType.REMOVAL:
removal_action()
elif action.type == WorkflowAction.WorkflowActionType.EMAIL:
email_action()
elif action.type == WorkflowAction.WorkflowActionType.WEBHOOK:
webhook_action()
if not use_overrides:
# save first before setting tags

View File

@@ -2,14 +2,16 @@ from datetime import datetime
from pathlib import Path
def parse_doc_title_w_placeholders(
title: str,
def parse_w_workflow_placeholders(
text: str,
correspondent_name: str,
doc_type_name: str,
owner_username: str,
local_added: datetime,
original_filename: str,
created: datetime | None = None,
doc_title: str | None = None,
doc_url: str | None = None,
) -> str:
"""
Available title placeholders for Workflows depend on what has already been assigned,
@@ -43,4 +45,8 @@ def parse_doc_title_w_placeholders(
"created_time": created.strftime("%H:%M"),
},
)
return title.format(**formatting).strip()
if doc_title is not None:
formatting.update({"doc_title": doc_title})
if doc_url is not None:
formatting.update({"doc_url": doc_url})
return text.format(**formatting).strip()

View File

@@ -433,3 +433,158 @@ class TestApiWorkflows(DirectoriesMixin, APITestCase):
self.assertNotEqual(workflow.triggers.first().id, self.trigger.id)
self.assertEqual(WorkflowAction.objects.all().count(), 1)
self.assertNotEqual(workflow.actions.first().id, self.action.id)
def test_email_action_validation(self):
"""
GIVEN:
- API request to create a workflow with an email 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.EMAIL,
},
],
},
),
content_type="application/json",
)
# Notification action requires to, subject and body
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
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.EMAIL,
"email": {
"subject": "Subject",
"body": "Body",
},
},
],
},
),
content_type="application/json",
)
# Notification action requires destination emails or url
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
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.EMAIL,
"email": {
"subject": "Subject",
"body": "Body",
"to": "me@example.com",
"include_document": False,
},
},
],
},
),
content_type="application/json",
)
self.assertEqual(response.status_code, status.HTTP_201_CREATED)
def test_webhook_action_validation(self):
"""
GIVEN:
- API request to create a workflow with a notification 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.WEBHOOK,
},
],
},
),
content_type="application/json",
)
# Notification action requires url
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
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.WEBHOOK,
"webhook": {
"url": "https://example.com",
"include_document": False,
},
},
],
},
),
content_type="application/json",
)
self.assertEqual(response.status_code, status.HTTP_201_CREATED)

View File

@@ -4,8 +4,8 @@ from documents.tests.utils import TestMigrations
class TestMigrateCustomFieldSelects(TestMigrations):
migrate_from = "1058_workflowtrigger_schedule_date_custom_field_and_more"
migrate_to = "1059_alter_customfieldinstance_value_select"
migrate_from = "1059_workflowactionemail_workflowactionwebhook_and_more"
migrate_to = "1060_alter_customfieldinstance_value_select"
def setUpBeforeMigration(self, apps):
CustomField = apps.get_model("documents.CustomField")
@@ -43,8 +43,8 @@ class TestMigrateCustomFieldSelects(TestMigrations):
class TestMigrationCustomFieldSelectsReverse(TestMigrations):
migrate_from = "1059_alter_customfieldinstance_value_select"
migrate_to = "1058_workflowtrigger_schedule_date_custom_field_and_more"
migrate_from = "1060_alter_customfieldinstance_value_select"
migrate_to = "1059_workflowactionemail_workflowactionwebhook_and_more"
def setUpBeforeMigration(self, apps):
CustomField = apps.get_model("documents.CustomField")

View File

@@ -1,20 +1,25 @@
import shutil
from datetime import timedelta
from pathlib import Path
from typing import TYPE_CHECKING
from unittest import mock
from django.contrib.auth.models import Group
from django.contrib.auth.models import User
from django.test import override_settings
from django.utils import timezone
from guardian.shortcuts import assign_perm
from guardian.shortcuts import get_groups_with_perms
from guardian.shortcuts import get_users_with_perms
from httpx import HTTPStatusError
from rest_framework.test import APITestCase
from documents.signals.handlers import run_workflows
from documents.signals.handlers import send_webhook
if TYPE_CHECKING:
from django.db.models import QuerySet
from documents import tasks
from documents.data_models import ConsumableDocument
from documents.data_models import DocumentSource
@@ -29,19 +34,25 @@ from documents.models import StoragePath
from documents.models import Tag
from documents.models import Workflow
from documents.models import WorkflowAction
from documents.models import WorkflowActionEmail
from documents.models import WorkflowActionWebhook
from documents.models import WorkflowRun
from documents.models import WorkflowTrigger
from documents.signals import document_consumption_finished
from documents.tests.utils import DirectoriesMixin
from documents.tests.utils import DummyProgressManager
from documents.tests.utils import FileSystemAssertsMixin
from documents.tests.utils import SampleDirMixin
from paperless_mail.models import MailAccount
from paperless_mail.models import MailRule
class TestWorkflows(DirectoriesMixin, FileSystemAssertsMixin, APITestCase):
SAMPLE_DIR = Path(__file__).parent / "samples"
class TestWorkflows(
DirectoriesMixin,
FileSystemAssertsMixin,
SampleDirMixin,
APITestCase,
):
def setUp(self) -> None:
self.c = Correspondent.objects.create(name="Correspondent Name")
self.c2 = Correspondent.objects.create(name="Correspondent Name 2")
@@ -2077,3 +2088,477 @@ class TestWorkflows(DirectoriesMixin, FileSystemAssertsMixin, APITestCase):
self.assertEqual(doc.owner, self.user2)
self.assertEqual(doc.tags.all().count(), 1)
self.assertIn(self.t2, doc.tags.all())
@override_settings(
PAPERLESS_EMAIL_HOST="localhost",
EMAIL_ENABLED=True,
PAPERLESS_URL="http://localhost:8000",
)
@mock.patch("httpx.post")
@mock.patch("django.core.mail.message.EmailMessage.send")
def test_workflow_email_action(self, mock_email_send, mock_post):
"""
GIVEN:
- Document updated workflow with email action
WHEN:
- Document that matches is updated
THEN:
- email is sent
"""
mock_post.return_value = mock.Mock(
status_code=200,
json=mock.Mock(return_value={"status": "ok"}),
)
mock_email_send.return_value = 1
trigger = WorkflowTrigger.objects.create(
type=WorkflowTrigger.WorkflowTriggerType.DOCUMENT_UPDATED,
)
email_action = WorkflowActionEmail.objects.create(
subject="Test Notification: {doc_title}",
body="Test message: {doc_url}",
to="user@example.com",
include_document=False,
)
self.assertEqual(str(email_action), f"Workflow Email Action {email_action.id}")
action = WorkflowAction.objects.create(
type=WorkflowAction.WorkflowActionType.EMAIL,
email=email_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",
)
run_workflows(WorkflowTrigger.WorkflowTriggerType.DOCUMENT_UPDATED, doc)
mock_email_send.assert_called_once()
@override_settings(
PAPERLESS_EMAIL_HOST="localhost",
EMAIL_ENABLED=True,
PAPERLESS_URL="http://localhost:8000",
)
@mock.patch("httpx.post")
@mock.patch("django.core.mail.message.EmailMessage.send")
def test_workflow_email_include_file(self, mock_email_send, mock_post):
"""
GIVEN:
- Document updated workflow with email action
- Include document is set to True
WHEN:
- Document that matches is updated
THEN:
- Notification includes document file
"""
# move the file
test_file = shutil.copy(
self.SAMPLE_DIR / "simple.pdf",
self.dirs.scratch_dir / "simple.pdf",
)
trigger = WorkflowTrigger.objects.create(
type=WorkflowTrigger.WorkflowTriggerType.DOCUMENT_UPDATED,
)
email_action = WorkflowActionEmail.objects.create(
subject="Test Notification: {doc_title}",
body="Test message: {doc_url}",
to="me@example.com",
include_document=True,
)
action = WorkflowAction.objects.create(
type=WorkflowAction.WorkflowActionType.EMAIL,
email=email_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,
filename=test_file,
)
run_workflows(WorkflowTrigger.WorkflowTriggerType.DOCUMENT_UPDATED, doc)
mock_email_send.assert_called_once()
@override_settings(
EMAIL_ENABLED=False,
)
def test_workflow_email_action_no_email_setup(self):
"""
GIVEN:
- Document updated workflow with email action
- Email is not enabled
WHEN:
- Document that matches is updated
THEN:
- Error is logged
"""
trigger = WorkflowTrigger.objects.create(
type=WorkflowTrigger.WorkflowTriggerType.DOCUMENT_UPDATED,
)
email_action = WorkflowActionEmail.objects.create(
subject="Test Notification: {doc_title}",
body="Test message: {doc_url}",
to="me@example.com",
)
action = WorkflowAction.objects.create(
type=WorkflowAction.WorkflowActionType.EMAIL,
email=email_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",
)
with self.assertLogs("paperless.handlers", level="ERROR") as cm:
run_workflows(WorkflowTrigger.WorkflowTriggerType.DOCUMENT_UPDATED, doc)
expected_str = "Email backend has not been configured"
self.assertIn(expected_str, cm.output[0])
@override_settings(
EMAIL_ENABLED=True,
PAPERLESS_URL="http://localhost:8000",
)
@mock.patch("django.core.mail.message.EmailMessage.send")
def test_workflow_email_action_fail(self, mock_email_send):
"""
GIVEN:
- Document updated workflow with email action
WHEN:
- Document that matches is updated
- An error occurs during email send
THEN:
- Error is logged
"""
mock_email_send.side_effect = Exception("Error occurred sending email")
trigger = WorkflowTrigger.objects.create(
type=WorkflowTrigger.WorkflowTriggerType.DOCUMENT_UPDATED,
)
email_action = WorkflowActionEmail.objects.create(
subject="Test Notification: {doc_title}",
body="Test message: {doc_url}",
to="me@example.com",
)
action = WorkflowAction.objects.create(
type=WorkflowAction.WorkflowActionType.EMAIL,
email=email_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",
)
with self.assertLogs("paperless.handlers", level="ERROR") as cm:
run_workflows(WorkflowTrigger.WorkflowTriggerType.DOCUMENT_UPDATED, doc)
expected_str = "Error occurred sending email"
self.assertIn(expected_str, cm.output[0])
@override_settings(
PAPERLESS_EMAIL_HOST="localhost",
EMAIL_ENABLED=True,
PAPERLESS_URL="http://localhost:8000",
)
@mock.patch("documents.signals.handlers.send_webhook.delay")
def test_workflow_webhook_action_body(self, mock_post):
"""
GIVEN:
- Document updated workflow with webhook action which uses body
WHEN:
- Document that matches is updated
THEN:
- Webhook is sent with body
"""
mock_post.return_value = mock.Mock(
status_code=200,
json=mock.Mock(return_value={"status": "ok"}),
)
trigger = WorkflowTrigger.objects.create(
type=WorkflowTrigger.WorkflowTriggerType.DOCUMENT_UPDATED,
)
webhook_action = WorkflowActionWebhook.objects.create(
use_params=False,
body="Test message: {doc_url}",
url="http://paperless-ngx.com",
include_document=False,
)
self.assertEqual(
str(webhook_action),
f"Workflow Webhook Action {webhook_action.id}",
)
action = WorkflowAction.objects.create(
type=WorkflowAction.WorkflowActionType.WEBHOOK,
webhook=webhook_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",
)
run_workflows(WorkflowTrigger.WorkflowTriggerType.DOCUMENT_UPDATED, doc)
mock_post.assert_called_once_with(
url="http://paperless-ngx.com",
data=f"Test message: http://localhost:8000/documents/{doc.id}/",
headers={},
files=None,
)
@override_settings(
PAPERLESS_EMAIL_HOST="localhost",
EMAIL_ENABLED=True,
PAPERLESS_URL="http://localhost:8000",
)
@mock.patch("documents.signals.handlers.send_webhook.delay")
def test_workflow_webhook_action_w_files(self, mock_post):
"""
GIVEN:
- Document updated workflow with webhook action which includes document
WHEN:
- Document that matches is updated
THEN:
- Webhook is sent with file
"""
mock_post.return_value = mock.Mock(
status_code=200,
json=mock.Mock(return_value={"status": "ok"}),
)
trigger = WorkflowTrigger.objects.create(
type=WorkflowTrigger.WorkflowTriggerType.DOCUMENT_UPDATED,
)
webhook_action = WorkflowActionWebhook.objects.create(
use_params=False,
body="Test message: {doc_url}",
url="http://paperless-ngx.com",
include_document=True,
)
action = WorkflowAction.objects.create(
type=WorkflowAction.WorkflowActionType.WEBHOOK,
webhook=webhook_action,
)
w = Workflow.objects.create(
name="Workflow 1",
order=0,
)
w.triggers.add(trigger)
w.actions.add(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",
)
run_workflows(WorkflowTrigger.WorkflowTriggerType.DOCUMENT_UPDATED, doc)
mock_post.assert_called_once_with(
url="http://paperless-ngx.com",
data=f"Test message: http://localhost:8000/documents/{doc.id}/",
headers={},
files={"file": ("simple.pdf", mock.ANY, "application/pdf")},
)
@override_settings(
PAPERLESS_EMAIL_HOST="localhost",
EMAIL_ENABLED=True,
PAPERLESS_URL="http://localhost:8000",
)
def test_workflow_webhook_action_fail(self):
"""
GIVEN:
- Document updated workflow with webhook action
WHEN:
- Document that matches is updated
- An error occurs during webhook
THEN:
- Error is logged
"""
trigger = WorkflowTrigger.objects.create(
type=WorkflowTrigger.WorkflowTriggerType.DOCUMENT_UPDATED,
)
webhook_action = WorkflowActionWebhook.objects.create(
use_params=True,
params={
"title": "Test webhook: {doc_title}",
"body": "Test message: {doc_url}",
},
url="http://paperless-ngx.com",
include_document=True,
)
action = WorkflowAction.objects.create(
type=WorkflowAction.WorkflowActionType.WEBHOOK,
webhook=webhook_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",
)
# fails because no file
with self.assertLogs("paperless.handlers", level="ERROR") as cm:
run_workflows(WorkflowTrigger.WorkflowTriggerType.DOCUMENT_UPDATED, doc)
expected_str = "Error occurred sending webhook"
self.assertIn(expected_str, cm.output[0])
def test_workflow_webhook_action_url_invalid_params_headers(self):
"""
GIVEN:
- Document updated workflow with webhook action
- Invalid params and headers JSON
WHEN:
- Document that matches is updated
THEN:
- Error is logged
"""
trigger = WorkflowTrigger.objects.create(
type=WorkflowTrigger.WorkflowTriggerType.DOCUMENT_UPDATED,
)
webhook_action = WorkflowActionWebhook.objects.create(
url="http://paperless-ngx.com",
use_params=True,
params="invalid",
headers="invalid",
)
action = WorkflowAction.objects.create(
type=WorkflowAction.WorkflowActionType.WEBHOOK,
webhook=webhook_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",
)
with self.assertLogs("paperless.handlers", level="ERROR") as cm:
run_workflows(WorkflowTrigger.WorkflowTriggerType.DOCUMENT_UPDATED, doc)
expected_str = "Error occurred parsing webhook params"
self.assertIn(expected_str, cm.output[0])
expected_str = "Error occurred parsing webhook headers"
self.assertIn(expected_str, cm.output[1])
@mock.patch("httpx.post")
def test_workflow_webhook_send_webhook_task(self, mock_post):
mock_post.return_value = mock.Mock(
status_code=200,
json=mock.Mock(return_value={"status": "ok"}),
raise_for_status=mock.Mock(),
)
with self.assertLogs("paperless.handlers") as cm:
send_webhook(
url="http://paperless-ngx.com",
data="Test message",
headers={},
files=None,
)
mock_post.assert_called_once_with(
"http://paperless-ngx.com",
data="Test message",
headers={},
files=None,
)
expected_str = "Webhook sent to http://paperless-ngx.com"
self.assertIn(expected_str, cm.output[0])
@mock.patch("httpx.post")
def test_workflow_webhook_send_webhook_retry(self, mock_http):
mock_http.return_value.raise_for_status = mock.Mock(
side_effect=HTTPStatusError(
"Error",
request=mock.Mock(),
response=mock.Mock(),
),
)
with self.assertLogs("paperless.handlers") as cm:
with self.assertRaises(HTTPStatusError):
send_webhook(
url="http://paperless-ngx.com",
data="Test message",
headers={},
files=None,
)
self.assertEqual(mock_http.call_count, 1)
expected_str = (
"Failed attempt sending webhook to http://paperless-ngx.com"
)
self.assertIn(expected_str, cm.output[0])