Migration from ConsumptionTemplate to Workflow

This commit is contained in:
shamoon 2023-12-24 00:22:25 -08:00
parent cf869b1356
commit 6984fcf821
9 changed files with 1018 additions and 281 deletions

View File

@ -26,8 +26,7 @@ from documents.data_models import DocumentMetadataOverrides
from documents.file_handling import create_source_path_directory from documents.file_handling import create_source_path_directory
from documents.file_handling import generate_unique_filename from documents.file_handling import generate_unique_filename
from documents.loggers import LoggingMixin from documents.loggers import LoggingMixin
from documents.matching import document_matches_template from documents.matching import document_matches_workflow
from documents.models import ConsumptionTemplate
from documents.models import Correspondent from documents.models import Correspondent
from documents.models import CustomField from documents.models import CustomField
from documents.models import CustomFieldInstance from documents.models import CustomFieldInstance
@ -36,6 +35,8 @@ from documents.models import DocumentType
from documents.models import FileInfo from documents.models import FileInfo
from documents.models import StoragePath from documents.models import StoragePath
from documents.models import Tag from documents.models import Tag
from documents.models import Workflow
from documents.models import WorkflowTrigger
from documents.parsers import DocumentParser from documents.parsers import DocumentParser
from documents.parsers import ParseError from documents.parsers import ParseError
from documents.parsers import get_parser_class_for_mime_type from documents.parsers import get_parser_class_for_mime_type
@ -611,50 +612,57 @@ class Consumer(LoggingMixin):
file name filters, path filters or mail rule filter if specified file name filters, path filters or mail rule filter if specified
""" """
overrides = DocumentMetadataOverrides() overrides = DocumentMetadataOverrides()
for template in ConsumptionTemplate.objects.all().order_by("order"): for workflow in Workflow.objects.all().order_by("order"):
template_overrides = DocumentMetadataOverrides() template_overrides = DocumentMetadataOverrides()
if document_matches_template(input_doc, template): if document_matches_workflow(
if template.assign_title is not None: input_doc,
template_overrides.title = template.assign_title workflow,
if template.assign_tags is not None: WorkflowTrigger.WorkflowTriggerType.CONSUMPTION,
template_overrides.tag_ids = [ ):
tag.pk for tag in template.assign_tags.all() for action in workflow.actions.all():
] if action.assign_title is not None:
if template.assign_correspondent is not None: template_overrides.title = action.assign_title
template_overrides.correspondent_id = ( if action.assign_tags is not None:
template.assign_correspondent.pk template_overrides.tag_ids = [
) tag.pk for tag in action.assign_tags.all()
if template.assign_document_type is not None: ]
template_overrides.document_type_id = ( if action.assign_correspondent is not None:
template.assign_document_type.pk template_overrides.correspondent_id = (
) action.assign_correspondent.pk
if template.assign_storage_path is not None: )
template_overrides.storage_path_id = template.assign_storage_path.pk if action.assign_document_type is not None:
if template.assign_owner is not None: template_overrides.document_type_id = (
template_overrides.owner_id = template.assign_owner.pk action.assign_document_type.pk
if template.assign_view_users is not None: )
template_overrides.view_users = [ if action.assign_storage_path is not None:
user.pk for user in template.assign_view_users.all() template_overrides.storage_path_id = (
] action.assign_storage_path.pk
if template.assign_view_groups is not None: )
template_overrides.view_groups = [ if action.assign_owner is not None:
group.pk for group in template.assign_view_groups.all() template_overrides.owner_id = action.assign_owner.pk
] if action.assign_view_users is not None:
if template.assign_change_users is not None: template_overrides.view_users = [
template_overrides.change_users = [ user.pk for user in action.assign_view_users.all()
user.pk for user in template.assign_change_users.all() ]
] if action.assign_view_groups is not None:
if template.assign_change_groups is not None: template_overrides.view_groups = [
template_overrides.change_groups = [ group.pk for group in action.assign_view_groups.all()
group.pk for group in template.assign_change_groups.all() ]
] if action.assign_change_users is not None:
if template.assign_custom_fields is not None: template_overrides.change_users = [
template_overrides.custom_field_ids = [ user.pk for user in action.assign_change_users.all()
field.pk for field in template.assign_custom_fields.all() ]
] if action.assign_change_groups is not None:
template_overrides.change_groups = [
group.pk for group in action.assign_change_groups.all()
]
if action.assign_custom_fields is not None:
template_overrides.custom_field_ids = [
field.pk for field in action.assign_custom_fields.all()
]
overrides.update(template_overrides) overrides.update(template_overrides)
return overrides return overrides
def _parse_title_placeholders(self, title: str) -> str: def _parse_title_placeholders(self, title: str) -> str:

View File

@ -5,13 +5,14 @@ from fnmatch import fnmatch
from documents.classifier import DocumentClassifier from documents.classifier import DocumentClassifier
from documents.data_models import ConsumableDocument from documents.data_models import ConsumableDocument
from documents.data_models import DocumentSource from documents.data_models import DocumentSource
from documents.models import ConsumptionTemplate
from documents.models import Correspondent from documents.models import Correspondent
from documents.models import Document from documents.models import Document
from documents.models import DocumentType from documents.models import DocumentType
from documents.models import MatchingModel from documents.models import MatchingModel
from documents.models import StoragePath from documents.models import StoragePath
from documents.models import Tag from documents.models import Tag
from documents.models import Workflow
from documents.models import WorkflowTrigger
from documents.permissions import get_objects_for_user_owner_aware from documents.permissions import get_objects_for_user_owner_aware
logger = logging.getLogger("paperless.matching") logger = logging.getLogger("paperless.matching")
@ -237,65 +238,75 @@ def _split_match(matching_model):
] ]
def document_matches_template( def document_matches_workflow(
document: ConsumableDocument, document: ConsumableDocument,
template: ConsumptionTemplate, workflow: Workflow,
trigger_type: WorkflowTrigger.WorkflowTriggerType,
) -> bool: ) -> bool:
""" """
Returns True if the incoming document matches all filters and Returns True if the incoming document matches all filters and
settings from the template, False otherwise settings from the workflow trigger, False otherwise
""" """
def log_match_failure(reason: str): def log_match_failure(reason: str):
logger.info(f"Document did not match template {template.name}") logger.info(f"Document did not match {workflow}")
logger.debug(reason) logger.debug(reason)
# Document source vs template source trigger_matched = True
if document.source not in [int(x) for x in list(template.sources)]: triggers = workflow.triggers.filter(type=trigger_type)
log_match_failure( if len(triggers) == 0:
f"Document source {document.source.name} not in" trigger_matched = False
f" {[DocumentSource(int(x)).name for x in template.sources]}", else:
) for trigger in triggers:
return False # Document source vs template source
if document.source not in [int(x) for x in list(trigger.sources)]:
log_match_failure(
f"Document source {document.source.name} not in"
f" {[DocumentSource(int(x)).name for x in trigger.sources]}",
)
trigger_matched = False
# Document mail rule vs template mail rule # Document mail rule vs template mail rule
if ( if (
document.mailrule_id is not None document.mailrule_id is not None
and template.filter_mailrule is not None and trigger.filter_mailrule is not None
and document.mailrule_id != template.filter_mailrule.pk and document.mailrule_id != trigger.filter_mailrule.pk
): ):
log_match_failure( log_match_failure(
f"Document mail rule {document.mailrule_id}" f"Document mail rule {document.mailrule_id}"
f" != {template.filter_mailrule.pk}", f" != {trigger.filter_mailrule.pk}",
) )
return False trigger_matched = False
# Document filename vs template filename # Document filename vs template filename
if ( if (
template.filter_filename is not None trigger.filter_filename is not None
and len(template.filter_filename) > 0 and len(trigger.filter_filename) > 0
and not fnmatch( and not fnmatch(
document.original_file.name.lower(), document.original_file.name.lower(),
template.filter_filename.lower(), trigger.filter_filename.lower(),
) )
): ):
log_match_failure( log_match_failure(
f"Document filename {document.original_file.name} does not match" f"Document filename {document.original_file.name} does not match"
f" {template.filter_filename.lower()}", f" {trigger.filter_filename.lower()}",
) )
return False trigger_matched = False
# Document path vs template path # Document path vs template path
if ( if (
template.filter_path is not None trigger.filter_path is not None
and len(template.filter_path) > 0 and len(trigger.filter_path) > 0
and not document.original_file.match(template.filter_path) and not document.original_file.match(trigger.filter_path)
): ):
log_match_failure( log_match_failure(
f"Document path {document.original_file}" f"Document path {document.original_file}"
f" does not match {template.filter_path}", f" does not match {trigger.filter_path}",
) )
return False trigger_matched = False
logger.info(f"Document matched template {template.name}") if trigger_matched:
return True logger.info(f"Document matched {trigger} from {workflow}")
return True
return trigger_matched

View File

@ -0,0 +1,431 @@
# Generated by Django 4.2.7 on 2023-12-23 22:51
import django.db.models.deletion
import multiselectfield.db.fields
from django.conf import settings
from django.contrib.auth.management import create_permissions
from django.contrib.auth.models import Group
from django.contrib.auth.models import Permission
from django.contrib.auth.models import User
from django.db import migrations
from django.db import models
from django.db import transaction
from django.db.models import Q
from documents.models import Correspondent
from documents.models import CustomField
from documents.models import DocumentType
from documents.models import StoragePath
from documents.models import Tag
from documents.models import Workflow
from documents.models import WorkflowAction
from documents.models import WorkflowTrigger
from paperless_mail.models import MailRule
def add_workflow_permissions(apps, schema_editor):
# create permissions without waiting for post_migrate signal
for app_config in apps.get_app_configs():
app_config.models_module = True
create_permissions(app_config, apps=apps, verbosity=0)
app_config.models_module = None
add_permission = Permission.objects.get(codename="add_document")
workflow_permissions = Permission.objects.filter(
codename__contains="workflow",
)
for user in User.objects.filter(Q(user_permissions=add_permission)).distinct():
user.user_permissions.add(*workflow_permissions)
for group in Group.objects.filter(Q(permissions=add_permission)).distinct():
group.permissions.add(*workflow_permissions)
def remove_workflow_permissions(apps, schema_editor):
workflow_permissions = Permission.objects.filter(
codename__contains="workflow_permissions",
)
for user in User.objects.all():
user.user_permissions.remove(*workflow_permissions)
for group in Group.objects.all():
group.permissions.remove(*workflow_permissions)
def migrate_consumption_templates(apps, schema_editor):
"""
Migrate consumption templates to workflows. At this point ConsumptionTemplate still exists
but objects are not returned as their true model so we have to manually do that
"""
model_name = "ConsumptionTemplate"
app_name = "documents"
ConsumptionTemplate = apps.get_model(app_label=app_name, model_name=model_name)
with transaction.atomic():
for template in ConsumptionTemplate.objects.all():
trigger = WorkflowTrigger(
type=WorkflowTrigger.WorkflowTriggerType.CONSUMPTION,
sources=template.sources,
filter_path=template.filter_path,
filter_filename=template.filter_filename,
)
if template.filter_mailrule is not None:
trigger.filter_mailrule = MailRule.objects.get(
id=template.filter_mailrule.id,
)
trigger.save()
action = WorkflowAction.objects.create(
assign_title=template.assign_title,
)
if template.assign_document_type is not None:
action.assign_document_type = DocumentType.objects.get(
id=template.assign_document_type.id,
)
if template.assign_correspondent is not None:
action.assign_correspondent = Correspondent.objects.get(
id=template.assign_correspondent.id,
)
if template.assign_storage_path is not None:
action.assign_storage_path = StoragePath.objects.get(
id=template.assign_storage_path.id,
)
if template.assign_owner is not None:
action.assign_owner = User.objects.get(id=template.assign_owner.id)
if template.assign_tags is not None:
action.assign_tags.set(
Tag.objects.filter(
id__in=[t.id for t in template.assign_tags.all()],
).all(),
)
if template.assign_view_users is not None:
action.assign_view_users.set(
User.objects.filter(
id__in=[u.id for u in template.assign_view_users.all()],
).all(),
)
if template.assign_view_groups is not None:
action.assign_view_groups.set(
Group.objects.filter(
id__in=[g.id for g in template.assign_view_groups.all()],
).all(),
)
if template.assign_change_users is not None:
action.assign_change_users.set(
User.objects.filter(
id__in=[u.id for u in template.assign_change_users.all()],
).all(),
)
if template.assign_change_groups is not None:
action.assign_change_groups.set(
Group.objects.filter(
id__in=[g.id for g in template.assign_change_groups.all()],
).all(),
)
if template.assign_custom_fields is not None:
action.assign_custom_fields.set(
CustomField.objects.filter(
id__in=[cf.id for cf in template.assign_custom_fields.all()],
).all(),
)
action.save()
workflow = Workflow.objects.create(
name=template.name,
order=template.order,
)
workflow.triggers.set([trigger])
workflow.actions.set([action])
workflow.save()
def unmigrate_consumption_templates(apps, schema_editor):
model_name = "ConsumptionTemplate"
app_name = "documents"
ConsumptionTemplate = apps.get_model(app_label=app_name, model_name=model_name)
for workflow in Workflow.objects.all():
template = ConsumptionTemplate.objects.create(
name=workflow.name,
order=workflow.order,
sources=workflow.sources,
filter_path=workflow.triggers.first().filter_path,
filter_filename=workflow.triggers.first().filter_filename,
filter_mailrule=workflow.triggers.first().filter_mailrule,
assign_title=workflow.actions.first().assign_title,
assign_document_type=workflow.actions.first().assign_document_type,
assign_correspondent=workflow.actions.first().assign_correspondent,
assign_storage_path=workflow.actions.first().assign_storage_path,
assign_owner=workflow.actions.first().assign_owner,
)
template.assign_tags.set(workflow.actions.first().assign_tags.all())
template.assign_view_users.set(workflow.actions.first().assign_view_users.all())
template.assign_view_groups.set(
workflow.actions.first().assign_view_groups.all(),
)
template.assign_change_users.set(
workflow.actions.first().assign_change_users.all(),
)
template.assign_change_groups.set(
workflow.actions.first().assign_change_groups.all(),
)
template.assign_custom_fields.set(
workflow.actions.first().assign_custom_fields.all(),
)
template.save()
class Migration(migrations.Migration):
dependencies = [
("paperless_mail", "0023_remove_mailrule_filter_attachment_filename_and_more"),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
("auth", "0012_alter_user_first_name_max_length"),
("documents", "1043_alter_savedviewfilterrule_rule_type"),
]
operations = [
migrations.CreateModel(
name="Workflow",
fields=[
(
"id",
models.AutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
(
"name",
models.CharField(max_length=256, unique=True, verbose_name="name"),
),
("order", models.IntegerField(default=0, verbose_name="order")),
],
),
migrations.CreateModel(
name="WorkflowAction",
fields=[
(
"id",
models.AutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
(
"assign_title",
models.CharField(
blank=True,
help_text="Assign a document title, can include some placeholders, see documentation.",
max_length=256,
null=True,
verbose_name="assign title",
),
),
(
"assign_change_groups",
models.ManyToManyField(
blank=True,
related_name="+",
to="auth.group",
verbose_name="grant change permissions to these groups",
),
),
(
"assign_change_users",
models.ManyToManyField(
blank=True,
related_name="+",
to=settings.AUTH_USER_MODEL,
verbose_name="grant change permissions to these users",
),
),
(
"assign_correspondent",
models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.SET_NULL,
to="documents.correspondent",
verbose_name="assign this correspondent",
),
),
(
"assign_custom_fields",
models.ManyToManyField(
blank=True,
related_name="+",
to="documents.customfield",
verbose_name="assign these custom fields",
),
),
(
"assign_document_type",
models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.SET_NULL,
to="documents.documenttype",
verbose_name="assign this document type",
),
),
(
"assign_owner",
models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name="+",
to=settings.AUTH_USER_MODEL,
verbose_name="assign this owner",
),
),
(
"assign_storage_path",
models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.SET_NULL,
to="documents.storagepath",
verbose_name="assign this storage path",
),
),
(
"assign_tags",
models.ManyToManyField(
blank=True,
to="documents.tag",
verbose_name="assign this tag",
),
),
(
"assign_view_groups",
models.ManyToManyField(
blank=True,
related_name="+",
to="auth.group",
verbose_name="grant view permissions to these groups",
),
),
(
"assign_view_users",
models.ManyToManyField(
blank=True,
related_name="+",
to=settings.AUTH_USER_MODEL,
verbose_name="grant view permissions to these users",
),
),
],
options={
"verbose_name": "workflow action",
"verbose_name_plural": "workflow actions",
},
),
migrations.CreateModel(
name="WorkflowTrigger",
fields=[
(
"id",
models.AutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
(
"type",
models.PositiveIntegerField(
choices=[
(1, "Consumption"),
(2, "Document Added"),
(3, "Document Updated"),
],
default=1,
verbose_name="Workflow Trigger Type",
),
),
(
"sources",
multiselectfield.db.fields.MultiSelectField(
choices=[
(1, "Consume Folder"),
(2, "Api Upload"),
(3, "Mail Fetch"),
],
default="1,2,3",
max_length=5,
),
),
(
"filter_path",
models.CharField(
blank=True,
help_text="Only consume documents with a path that matches this if specified. Wildcards specified as * are allowed. Case insensitive.",
max_length=256,
null=True,
verbose_name="filter path",
),
),
(
"filter_filename",
models.CharField(
blank=True,
help_text="Only consume documents which entirely match this filename if specified. Wildcards such as *.pdf or *invoice* are allowed. Case insensitive.",
max_length=256,
null=True,
verbose_name="filter filename",
),
),
(
"filter_mailrule",
models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.SET_NULL,
to="paperless_mail.mailrule",
verbose_name="filter documents from this mail rule",
),
),
],
options={
"verbose_name": "workflow trigger",
"verbose_name_plural": "workflow triggers",
},
),
migrations.RunPython(
add_workflow_permissions,
remove_workflow_permissions,
),
migrations.AddField(
model_name="workflow",
name="actions",
field=models.ManyToManyField(
related_name="workflows",
to="documents.workflowaction",
verbose_name="actions",
),
),
migrations.AddField(
model_name="workflow",
name="triggers",
field=models.ManyToManyField(
related_name="workflows",
to="documents.workflowtrigger",
verbose_name="triggers",
),
),
migrations.RunPython(
migrate_consumption_templates,
unmigrate_consumption_templates,
),
migrations.DeleteModel("ConsumptionTemplate"),
]

View File

@ -888,15 +888,22 @@ if settings.AUDIT_LOG_ENABLED:
auditlog.register(CustomFieldInstance) auditlog.register(CustomFieldInstance)
class ConsumptionTemplate(models.Model): class WorkflowTrigger(models.Model):
class WorkflowTriggerType(models.IntegerChoices):
CONSUMPTION = 1, _("Consumption")
DOCUMENT_ADDED = 2, _("Document Added")
DOCUMENT_UPDATED = 3, _("Document Updated")
class DocumentSourceChoices(models.IntegerChoices): class DocumentSourceChoices(models.IntegerChoices):
CONSUME_FOLDER = DocumentSource.ConsumeFolder.value, _("Consume Folder") CONSUME_FOLDER = DocumentSource.ConsumeFolder.value, _("Consume Folder")
API_UPLOAD = DocumentSource.ApiUpload.value, _("Api Upload") API_UPLOAD = DocumentSource.ApiUpload.value, _("Api Upload")
MAIL_FETCH = DocumentSource.MailFetch.value, _("Mail Fetch") MAIL_FETCH = DocumentSource.MailFetch.value, _("Mail Fetch")
name = models.CharField(_("name"), max_length=256, unique=True) type = models.PositiveIntegerField(
_("Workflow Trigger Type"),
order = models.IntegerField(_("order"), default=0) choices=WorkflowTriggerType.choices,
default=WorkflowTriggerType.CONSUMPTION,
)
sources = MultiSelectField( sources = MultiSelectField(
max_length=5, max_length=5,
@ -936,6 +943,15 @@ class ConsumptionTemplate(models.Model):
verbose_name=_("filter documents from this mail rule"), verbose_name=_("filter documents from this mail rule"),
) )
class Meta:
verbose_name = _("workflow trigger")
verbose_name_plural = _("workflow triggers")
def __str__(self):
return f"WorfklowTrigger: {self.pk}"
class WorkflowAction(models.Model):
assign_title = models.CharField( assign_title = models.CharField(
_("assign title"), _("assign title"),
max_length=256, max_length=256,
@ -1022,8 +1038,31 @@ class ConsumptionTemplate(models.Model):
) )
class Meta: class Meta:
verbose_name = _("consumption template") verbose_name = _("workflow action")
verbose_name_plural = _("consumption templates") verbose_name_plural = _("workflow actions")
def __str__(self): def __str__(self):
return f"{self.name}" return f"WorkflowAction {self.pk}"
class Workflow(models.Model):
name = models.CharField(_("name"), max_length=256, unique=True)
order = models.IntegerField(_("order"), default=0)
triggers = models.ManyToManyField(
WorkflowTrigger,
related_name="workflows",
blank=False,
verbose_name=_("triggers"),
)
actions = models.ManyToManyField(
WorkflowAction,
related_name="workflows",
blank=False,
verbose_name=_("actions"),
)
def __str__(self):
return f"Workflow: {self.name}"

View File

@ -2,6 +2,7 @@ import datetime
import math import math
import re import re
import zoneinfo import zoneinfo
from typing import Any
import magic import magic
from celery import states from celery import states
@ -24,7 +25,6 @@ from rest_framework.fields import SerializerMethodField
from documents import bulk_edit from documents import bulk_edit
from documents.data_models import DocumentSource from documents.data_models import DocumentSource
from documents.models import ConsumptionTemplate
from documents.models import Correspondent from documents.models import Correspondent
from documents.models import CustomField from documents.models import CustomField
from documents.models import CustomFieldInstance from documents.models import CustomFieldInstance
@ -38,6 +38,9 @@ from documents.models import ShareLink
from documents.models import StoragePath from documents.models import StoragePath
from documents.models import Tag from documents.models import Tag
from documents.models import UiSettings from documents.models import UiSettings
from documents.models import Workflow
from documents.models import WorkflowAction
from documents.models import WorkflowTrigger
from documents.parsers import is_mime_type_supported from documents.parsers import is_mime_type_supported
from documents.permissions import get_groups_with_only_permission from documents.permissions import get_groups_with_only_permission
from documents.permissions import set_permissions_for_object from documents.permissions import set_permissions_for_object
@ -1258,10 +1261,9 @@ class BulkEditObjectPermissionsSerializer(serializers.Serializer, SetPermissions
return attrs return attrs
class ConsumptionTemplateSerializer(serializers.ModelSerializer): class WorkflowTriggerSerializer(serializers.ModelSerializer):
order = serializers.IntegerField(required=False)
sources = fields.MultipleChoiceField( sources = fields.MultipleChoiceField(
choices=ConsumptionTemplate.DocumentSourceChoices.choices, choices=WorkflowTrigger.DocumentSourceChoices.choices,
allow_empty=False, allow_empty=False,
default={ default={
DocumentSource.ConsumeFolder, DocumentSource.ConsumeFolder,
@ -1269,32 +1271,21 @@ class ConsumptionTemplateSerializer(serializers.ModelSerializer):
DocumentSource.MailFetch, DocumentSource.MailFetch,
}, },
) )
assign_correspondent = CorrespondentField(allow_null=True, required=False)
assign_tags = TagsField(many=True, allow_null=True, required=False) type = serializers.ChoiceField(
assign_document_type = DocumentTypeField(allow_null=True, required=False) choices=WorkflowTrigger.WorkflowTriggerType.choices,
assign_storage_path = StoragePathField(allow_null=True, required=False) label="Trigger Type",
)
class Meta: class Meta:
model = ConsumptionTemplate model = WorkflowTrigger
fields = [ fields = [
"id", "id",
"name",
"order",
"sources", "sources",
"type",
"filter_path", "filter_path",
"filter_filename", "filter_filename",
"filter_mailrule", "filter_mailrule",
"assign_title",
"assign_tags",
"assign_correspondent",
"assign_document_type",
"assign_storage_path",
"assign_owner",
"assign_view_users",
"assign_view_groups",
"assign_change_users",
"assign_change_groups",
"assign_custom_fields",
] ]
def validate(self, attrs): def validate(self, attrs):
@ -1302,12 +1293,6 @@ class ConsumptionTemplateSerializer(serializers.ModelSerializer):
attrs["sources"] = {DocumentSource.MailFetch.value} attrs["sources"] = {DocumentSource.MailFetch.value}
# Empty strings treated as None to avoid unexpected behavior # Empty strings treated as None to avoid unexpected behavior
if (
"assign_title" in attrs
and attrs["assign_title"] is not None
and len(attrs["assign_title"]) == 0
):
attrs["assign_title"] = None
if ( if (
"filter_filename" in attrs "filter_filename" in attrs
and attrs["filter_filename"] is not None and attrs["filter_filename"] is not None
@ -1322,7 +1307,8 @@ class ConsumptionTemplateSerializer(serializers.ModelSerializer):
attrs["filter_path"] = None attrs["filter_path"] = None
if ( if (
"filter_mailrule" not in attrs attrs["type"] == WorkflowTrigger.WorkflowTriggerType.CONSUMPTION
and "filter_mailrule" not in attrs
and ("filter_filename" not in attrs or attrs["filter_filename"] is None) and ("filter_filename" not in attrs or attrs["filter_filename"] is None)
and ("filter_path" not in attrs or attrs["filter_path"] is None) and ("filter_path" not in attrs or attrs["filter_path"] is None)
): ):
@ -1331,3 +1317,89 @@ class ConsumptionTemplateSerializer(serializers.ModelSerializer):
) )
return attrs return attrs
class WorkflowActionSerializer(serializers.ModelSerializer):
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)
class Meta:
model = WorkflowAction
fields = [
"id",
"assign_title",
"assign_tags",
"assign_correspondent",
"assign_document_type",
"assign_storage_path",
"assign_owner",
"assign_view_users",
"assign_view_groups",
"assign_change_users",
"assign_change_groups",
"assign_custom_fields",
]
def validate(self, attrs):
# Empty strings treated as None to avoid unexpected behavior
if (
"assign_title" in attrs
and attrs["assign_title"] is not None
and len(attrs["assign_title"]) == 0
):
attrs["assign_title"] = None
return attrs
class WorkflowSerializer(serializers.ModelSerializer):
order = serializers.IntegerField(required=False)
triggers = WorkflowTriggerSerializer(many=True)
actions = WorkflowActionSerializer(many=True)
class Meta:
model = Workflow
fields = [
"id",
"name",
"order",
"triggers",
"actions",
]
def create(self, validated_data: Any) -> Any:
if "triggers" in validated_data:
# WorkflowTrigger.objects.update_or_create(triggers)
triggers = validated_data.pop("triggers")
if "actions" in validated_data:
# WorkflowAction.objects.update_or_create(actions)
actions = validated_data.pop("actions")
instance = super().create(validated_data)
set_triggers = []
set_actions = []
if triggers is not None:
for trigger in triggers:
print(trigger)
trigger_instance = WorkflowTrigger.objects.filter(**trigger).first()
if trigger_instance is not None:
set_triggers.append(trigger_instance)
if actions is not None:
for action in actions:
print(action)
action_instance = WorkflowAction.objects.filter(**action).first()
if action_instance is not None:
set_actions.append(action_instance)
instance.triggers.set(set_triggers)
instance.actions.set(set_actions)
instance.save()
return instance

View File

@ -6,19 +6,23 @@ from rest_framework import status
from rest_framework.test import APITestCase from rest_framework.test import APITestCase
from documents.data_models import DocumentSource from documents.data_models import DocumentSource
from documents.models import ConsumptionTemplate
from documents.models import Correspondent from documents.models import Correspondent
from documents.models import CustomField from documents.models import CustomField
from documents.models import DocumentType from documents.models import DocumentType
from documents.models import StoragePath from documents.models import StoragePath
from documents.models import Tag from documents.models import Tag
from documents.models import Workflow
from documents.models import WorkflowAction
from documents.models import WorkflowTrigger
from documents.tests.utils import DirectoriesMixin from documents.tests.utils import DirectoriesMixin
from paperless_mail.models import MailAccount from paperless_mail.models import MailAccount
from paperless_mail.models import MailRule from paperless_mail.models import MailRule
class TestApiConsumptionTemplates(DirectoriesMixin, APITestCase): class TestApiWorkflows(DirectoriesMixin, APITestCase):
ENDPOINT = "/api/consumption_templates/" ENDPOINT = "/api/workflows/"
ENDPOINT_TRIGGERS = "/api/workflow_triggers/"
ENDPOINT_ACTIONS = "/api/workflow_actions/"
def setUp(self) -> None: def setUp(self) -> None:
super().setUp() super().setUp()
@ -42,104 +46,158 @@ class TestApiConsumptionTemplates(DirectoriesMixin, APITestCase):
data_type="integer", data_type="integer",
) )
self.ct = ConsumptionTemplate.objects.create( self.trigger = WorkflowTrigger.objects.create(
name="Template 1", type=WorkflowTrigger.WorkflowTriggerType.CONSUMPTION,
order=0,
sources=f"{int(DocumentSource.ApiUpload)},{int(DocumentSource.ConsumeFolder)},{int(DocumentSource.MailFetch)}", sources=f"{int(DocumentSource.ApiUpload)},{int(DocumentSource.ConsumeFolder)},{int(DocumentSource.MailFetch)}",
filter_filename="*simple*", filter_filename="*simple*",
filter_path="*/samples/*", filter_path="*/samples/*",
)
self.action = WorkflowAction.objects.create(
assign_title="Doc from {correspondent}", assign_title="Doc from {correspondent}",
assign_correspondent=self.c, assign_correspondent=self.c,
assign_document_type=self.dt, assign_document_type=self.dt,
assign_storage_path=self.sp, assign_storage_path=self.sp,
assign_owner=self.user2, assign_owner=self.user2,
) )
self.ct.assign_tags.add(self.t1) self.action.assign_tags.add(self.t1)
self.ct.assign_tags.add(self.t2) self.action.assign_tags.add(self.t2)
self.ct.assign_tags.add(self.t3) self.action.assign_tags.add(self.t3)
self.ct.assign_view_users.add(self.user3.pk) self.action.assign_view_users.add(self.user3.pk)
self.ct.assign_view_groups.add(self.group1.pk) self.action.assign_view_groups.add(self.group1.pk)
self.ct.assign_change_users.add(self.user3.pk) self.action.assign_change_users.add(self.user3.pk)
self.ct.assign_change_groups.add(self.group1.pk) self.action.assign_change_groups.add(self.group1.pk)
self.ct.assign_custom_fields.add(self.cf1.pk) self.action.assign_custom_fields.add(self.cf1.pk)
self.ct.assign_custom_fields.add(self.cf2.pk) self.action.assign_custom_fields.add(self.cf2.pk)
self.ct.save() self.action.save()
def test_api_get_consumption_template(self): self.workflow = Workflow.objects.create(
name="Workflow 1",
order=0,
)
self.workflow.triggers.add(self.trigger)
self.workflow.actions.add(self.action)
self.workflow.save()
def test_api_get_workflow(self):
""" """
GIVEN: GIVEN:
- API request to get all consumption template - API request to get all workflows
WHEN: WHEN:
- API is called - API is called
THEN: THEN:
- Existing consumption templates are returned - Existing workflows are returned
""" """
response = self.client.get(self.ENDPOINT, format="json") response = self.client.get(self.ENDPOINT, format="json")
self.assertEqual(response.status_code, status.HTTP_200_OK) self.assertEqual(response.status_code, status.HTTP_200_OK)
self.assertEqual(response.data["count"], 1) self.assertEqual(response.data["count"], 1)
resp_consumption_template = response.data["results"][0] resp_workflow = response.data["results"][0]
self.assertEqual(resp_consumption_template["id"], self.ct.id) self.assertEqual(resp_workflow["id"], self.workflow.id)
self.assertEqual( self.assertEqual(
resp_consumption_template["assign_correspondent"], resp_workflow["actions"][0]["assign_correspondent"],
self.ct.assign_correspondent.pk, self.action.assign_correspondent.pk,
) )
def test_api_create_consumption_template(self): def test_api_create_workflow(self):
""" """
GIVEN: GIVEN:
- API request to create a consumption template - API request to create a workflow, trigger and action
WHEN: WHEN:
- API is called - API is called
THEN: THEN:
- Correct HTTP response - Correct HTTP response
- New template is created - New workflow, trigger and action are created
""" """
trigger_response = self.client.post(
self.ENDPOINT_TRIGGERS,
json.dumps(
{
"type": WorkflowTrigger.WorkflowTriggerType.CONSUMPTION,
"sources": [DocumentSource.ApiUpload],
"filter_filename": "*",
},
),
content_type="application/json",
)
self.assertEqual(trigger_response.status_code, status.HTTP_201_CREATED)
action_response = self.client.post(
self.ENDPOINT_ACTIONS,
json.dumps(
{
"assign_title": "Action Title",
},
),
content_type="application/json",
)
self.assertEqual(action_response.status_code, status.HTTP_201_CREATED)
response = self.client.post( response = self.client.post(
self.ENDPOINT, self.ENDPOINT,
json.dumps( json.dumps(
{ {
"name": "Template 2", "name": "Workflow 2",
"order": 1, "order": 1,
"sources": [DocumentSource.ApiUpload], "triggers": [
"filter_filename": "*test*", {
"sources": [DocumentSource.ApiUpload],
"type": trigger_response.data["type"],
"filter_filename": trigger_response.data["filter_filename"],
},
],
"actions": [
{
"assign_title": action_response.data["assign_title"],
},
],
}, },
), ),
content_type="application/json", content_type="application/json",
) )
self.assertEqual(response.status_code, status.HTTP_201_CREATED) self.assertEqual(response.status_code, status.HTTP_201_CREATED)
self.assertEqual(ConsumptionTemplate.objects.count(), 2) self.assertEqual(Workflow.objects.count(), 2)
def test_api_create_invalid_consumption_template(self): def test_api_create_invalid_workflow_trigger(self):
""" """
GIVEN: GIVEN:
- API request to create a consumption template - API request to create a workflow trigger
- Neither file name nor path filter are specified - Neither type or file name nor path filter are specified
WHEN: WHEN:
- API is called - API is called
THEN: THEN:
- Correct HTTP 400 response - Correct HTTP 400 response
- No template is created - No objects are created
""" """
response = self.client.post( response = self.client.post(
self.ENDPOINT, self.ENDPOINT_TRIGGERS,
json.dumps( json.dumps(
{ {
"name": "Template 2",
"order": 1,
"sources": [DocumentSource.ApiUpload], "sources": [DocumentSource.ApiUpload],
}, },
), ),
content_type="application/json", content_type="application/json",
) )
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
self.assertEqual(ConsumptionTemplate.objects.count(), 1)
def test_api_create_consumption_template_empty_fields(self): response = self.client.post(
self.ENDPOINT_TRIGGERS,
json.dumps(
{
"type": WorkflowTrigger.WorkflowTriggerType.CONSUMPTION,
"sources": [DocumentSource.ApiUpload],
},
),
content_type="application/json",
)
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
self.assertEqual(WorkflowTrigger.objects.count(), 1)
def test_api_create_workflow_trigger_action_empty_fields(self):
""" """
GIVEN: GIVEN:
- API request to create a consumption template - API request to create a workflow trigger and action
- Path or filename filter or assign title are empty string - Path or filename filter or assign title are empty string
WHEN: WHEN:
- API is called - API is called
@ -147,31 +205,40 @@ class TestApiConsumptionTemplates(DirectoriesMixin, APITestCase):
- Template is created but filter or title assignment is not set if "" - Template is created but filter or title assignment is not set if ""
""" """
response = self.client.post( response = self.client.post(
self.ENDPOINT, self.ENDPOINT_TRIGGERS,
json.dumps( json.dumps(
{ {
"name": "Template 2", "type": WorkflowTrigger.WorkflowTriggerType.CONSUMPTION,
"order": 1,
"sources": [DocumentSource.ApiUpload], "sources": [DocumentSource.ApiUpload],
"filter_filename": "*test*", "filter_filename": "*test*",
"filter_path": "", "filter_path": "",
},
),
content_type="application/json",
)
self.assertEqual(response.status_code, status.HTTP_201_CREATED)
trigger = WorkflowTrigger.objects.get(id=response.data["id"])
self.assertEqual(trigger.filter_filename, "*test*")
self.assertIsNone(trigger.filter_path)
response = self.client.post(
self.ENDPOINT_ACTIONS,
json.dumps(
{
"assign_title": "", "assign_title": "",
}, },
), ),
content_type="application/json", content_type="application/json",
) )
self.assertEqual(response.status_code, status.HTTP_201_CREATED) self.assertEqual(response.status_code, status.HTTP_201_CREATED)
ct = ConsumptionTemplate.objects.get(name="Template 2") action = WorkflowAction.objects.get(id=response.data["id"])
self.assertEqual(ct.filter_filename, "*test*") self.assertIsNone(action.assign_title)
self.assertIsNone(ct.filter_path)
self.assertIsNone(ct.assign_title)
response = self.client.post( response = self.client.post(
self.ENDPOINT, self.ENDPOINT_TRIGGERS,
json.dumps( json.dumps(
{ {
"name": "Template 3", "type": WorkflowTrigger.WorkflowTriggerType.CONSUMPTION,
"order": 1,
"sources": [DocumentSource.ApiUpload], "sources": [DocumentSource.ApiUpload],
"filter_filename": "", "filter_filename": "",
"filter_path": "*/test/*", "filter_path": "*/test/*",
@ -180,18 +247,18 @@ class TestApiConsumptionTemplates(DirectoriesMixin, APITestCase):
content_type="application/json", content_type="application/json",
) )
self.assertEqual(response.status_code, status.HTTP_201_CREATED) self.assertEqual(response.status_code, status.HTTP_201_CREATED)
ct2 = ConsumptionTemplate.objects.get(name="Template 3") trigger2 = WorkflowTrigger.objects.get(id=response.data["id"])
self.assertEqual(ct2.filter_path, "*/test/*") self.assertEqual(trigger2.filter_path, "*/test/*")
self.assertIsNone(ct2.filter_filename) self.assertIsNone(trigger2.filter_filename)
def test_api_create_consumption_template_with_mailrule(self): def test_api_create_workflow_trigger_with_mailrule(self):
""" """
GIVEN: GIVEN:
- API request to create a consumption template with a mail rule but no MailFetch source - API request to create a workflow trigger with a mail rule but no MailFetch source
WHEN: WHEN:
- API is called - API is called
THEN: THEN:
- New template is created with MailFetch as source - New trigger is created with MailFetch as source
""" """
account1 = MailAccount.objects.create( account1 = MailAccount.objects.create(
name="Email1", name="Email1",
@ -219,11 +286,10 @@ class TestApiConsumptionTemplates(DirectoriesMixin, APITestCase):
attachment_type=MailRule.AttachmentProcessing.ATTACHMENTS_ONLY, attachment_type=MailRule.AttachmentProcessing.ATTACHMENTS_ONLY,
) )
response = self.client.post( response = self.client.post(
self.ENDPOINT, self.ENDPOINT_TRIGGERS,
json.dumps( json.dumps(
{ {
"name": "Template 2", "type": WorkflowTrigger.WorkflowTriggerType.CONSUMPTION,
"order": 1,
"sources": [DocumentSource.ApiUpload], "sources": [DocumentSource.ApiUpload],
"filter_mailrule": rule1.pk, "filter_mailrule": rule1.pk,
}, },
@ -231,6 +297,6 @@ class TestApiConsumptionTemplates(DirectoriesMixin, APITestCase):
content_type="application/json", content_type="application/json",
) )
self.assertEqual(response.status_code, status.HTTP_201_CREATED) self.assertEqual(response.status_code, status.HTTP_201_CREATED)
self.assertEqual(ConsumptionTemplate.objects.count(), 2) self.assertEqual(WorkflowTrigger.objects.count(), 2)
ct = ConsumptionTemplate.objects.get(name="Template 2") trigger = WorkflowTrigger.objects.get(id=response.data["id"])
self.assertEqual(ct.sources, [int(DocumentSource.MailFetch).__str__()]) self.assertEqual(trigger.sources, [int(DocumentSource.MailFetch).__str__()])

View File

@ -9,12 +9,14 @@ from django.contrib.auth.models import User
from documents import tasks from documents import tasks
from documents.data_models import ConsumableDocument from documents.data_models import ConsumableDocument
from documents.data_models import DocumentSource from documents.data_models import DocumentSource
from documents.models import ConsumptionTemplate
from documents.models import Correspondent from documents.models import Correspondent
from documents.models import CustomField from documents.models import CustomField
from documents.models import DocumentType from documents.models import DocumentType
from documents.models import StoragePath from documents.models import StoragePath
from documents.models import Tag from documents.models import Tag
from documents.models import Workflow
from documents.models import WorkflowAction
from documents.models import WorkflowTrigger
from documents.tests.utils import DirectoriesMixin from documents.tests.utils import DirectoriesMixin
from documents.tests.utils import FileSystemAssertsMixin from documents.tests.utils import FileSystemAssertsMixin
from paperless_mail.models import MailAccount from paperless_mail.models import MailAccount
@ -22,7 +24,7 @@ from paperless_mail.models import MailRule
@pytest.mark.django_db @pytest.mark.django_db
class TestConsumptionTemplates(DirectoriesMixin, FileSystemAssertsMixin, TestCase): class TestWorkflows(DirectoriesMixin, FileSystemAssertsMixin, TestCase):
SAMPLE_DIR = Path(__file__).parent / "samples" SAMPLE_DIR = Path(__file__).parent / "samples"
def setUp(self) -> None: def setUp(self) -> None:
@ -73,39 +75,47 @@ class TestConsumptionTemplates(DirectoriesMixin, FileSystemAssertsMixin, TestCas
return super().setUp() return super().setUp()
@mock.patch("documents.consumer.Consumer.try_consume_file") @mock.patch("documents.consumer.Consumer.try_consume_file")
def test_consumption_template_match(self, m): def test_workflow_match(self, m):
""" """
GIVEN: GIVEN:
- Existing consumption template - Existing workflow
WHEN: WHEN:
- File that matches is consumed - File that matches is consumed
THEN: THEN:
- Template overrides are applied - Template overrides are applied
""" """
ct = ConsumptionTemplate.objects.create( trigger = WorkflowTrigger.objects.create(
name="Template 1", type=WorkflowTrigger.WorkflowTriggerType.CONSUMPTION,
order=0,
sources=f"{DocumentSource.ApiUpload},{DocumentSource.ConsumeFolder},{DocumentSource.MailFetch}", sources=f"{DocumentSource.ApiUpload},{DocumentSource.ConsumeFolder},{DocumentSource.MailFetch}",
filter_filename="*simple*", filter_filename="*simple*",
filter_path="*/samples/*", filter_path="*/samples/*",
)
action = WorkflowAction.objects.create(
assign_title="Doc from {correspondent}", assign_title="Doc from {correspondent}",
assign_correspondent=self.c, assign_correspondent=self.c,
assign_document_type=self.dt, assign_document_type=self.dt,
assign_storage_path=self.sp, assign_storage_path=self.sp,
assign_owner=self.user2, assign_owner=self.user2,
) )
ct.assign_tags.add(self.t1) action.assign_tags.add(self.t1)
ct.assign_tags.add(self.t2) action.assign_tags.add(self.t2)
ct.assign_tags.add(self.t3) action.assign_tags.add(self.t3)
ct.assign_view_users.add(self.user3.pk) action.assign_view_users.add(self.user3.pk)
ct.assign_view_groups.add(self.group1.pk) action.assign_view_groups.add(self.group1.pk)
ct.assign_change_users.add(self.user3.pk) action.assign_change_users.add(self.user3.pk)
ct.assign_change_groups.add(self.group1.pk) action.assign_change_groups.add(self.group1.pk)
ct.assign_custom_fields.add(self.cf1.pk) action.assign_custom_fields.add(self.cf1.pk)
ct.assign_custom_fields.add(self.cf2.pk) action.assign_custom_fields.add(self.cf2.pk)
ct.save() action.save()
w = Workflow.objects.create(
name="Workflow 1",
order=0,
)
w.triggers.add(trigger)
w.actions.add(action)
w.save()
self.assertEqual(ct.__str__(), "Template 1") self.assertEqual(w.__str__(), "Workflow: Workflow 1")
test_file = self.SAMPLE_DIR / "simple.pdf" test_file = self.SAMPLE_DIR / "simple.pdf"
@ -142,40 +152,48 @@ class TestConsumptionTemplates(DirectoriesMixin, FileSystemAssertsMixin, TestCas
) )
info = cm.output[0] info = cm.output[0]
expected_str = f"Document matched template {ct}" expected_str = f"Document matched {trigger} from {w}"
self.assertIn(expected_str, info) self.assertIn(expected_str, info)
@mock.patch("documents.consumer.Consumer.try_consume_file") @mock.patch("documents.consumer.Consumer.try_consume_file")
def test_consumption_template_match_mailrule(self, m): def test_workflow_match_mailrule(self, m):
""" """
GIVEN: GIVEN:
- Existing consumption template - Existing workflow
WHEN: WHEN:
- File that matches is consumed via mail rule - File that matches is consumed via mail rule
THEN: THEN:
- Template overrides are applied - Template overrides are applied
""" """
ct = ConsumptionTemplate.objects.create( trigger = WorkflowTrigger.objects.create(
name="Template 1", type=WorkflowTrigger.WorkflowTriggerType.CONSUMPTION,
order=0,
sources=f"{DocumentSource.ApiUpload},{DocumentSource.ConsumeFolder},{DocumentSource.MailFetch}", sources=f"{DocumentSource.ApiUpload},{DocumentSource.ConsumeFolder},{DocumentSource.MailFetch}",
filter_mailrule=self.rule1, filter_mailrule=self.rule1,
)
action = WorkflowAction.objects.create(
assign_title="Doc from {correspondent}", assign_title="Doc from {correspondent}",
assign_correspondent=self.c, assign_correspondent=self.c,
assign_document_type=self.dt, assign_document_type=self.dt,
assign_storage_path=self.sp, assign_storage_path=self.sp,
assign_owner=self.user2, assign_owner=self.user2,
) )
ct.assign_tags.add(self.t1) action.assign_tags.add(self.t1)
ct.assign_tags.add(self.t2) action.assign_tags.add(self.t2)
ct.assign_tags.add(self.t3) action.assign_tags.add(self.t3)
ct.assign_view_users.add(self.user3.pk) action.assign_view_users.add(self.user3.pk)
ct.assign_view_groups.add(self.group1.pk) action.assign_view_groups.add(self.group1.pk)
ct.assign_change_users.add(self.user3.pk) action.assign_change_users.add(self.user3.pk)
ct.assign_change_groups.add(self.group1.pk) action.assign_change_groups.add(self.group1.pk)
ct.save() action.save()
self.assertEqual(ct.__str__(), "Template 1") w = Workflow.objects.create(
name="Workflow 1",
order=0,
)
w.triggers.add(trigger)
w.actions.add(action)
w.save()
test_file = self.SAMPLE_DIR / "simple.pdf" test_file = self.SAMPLE_DIR / "simple.pdf"
with mock.patch("documents.tasks.async_to_sync"): with mock.patch("documents.tasks.async_to_sync"):
@ -208,45 +226,64 @@ class TestConsumptionTemplates(DirectoriesMixin, FileSystemAssertsMixin, TestCas
) )
info = cm.output[0] info = cm.output[0]
expected_str = f"Document matched template {ct}" expected_str = f"Document matched {trigger} from {w}"
self.assertIn(expected_str, info) self.assertIn(expected_str, info)
@mock.patch("documents.consumer.Consumer.try_consume_file") @mock.patch("documents.consumer.Consumer.try_consume_file")
def test_consumption_template_match_multiple(self, m): def test_workflow_match_multiple(self, m):
""" """
GIVEN: GIVEN:
- Multiple existing consumption template - Multiple existing workflow
WHEN: WHEN:
- File that matches is consumed - File that matches is consumed
THEN: THEN:
- Template overrides are applied with subsequent templates only overwriting empty values - Template overrides are applied with subsequent templates only overwriting empty values
or merging if multiple or merging if multiple
""" """
ct1 = ConsumptionTemplate.objects.create( trigger1 = WorkflowTrigger.objects.create(
name="Template 1", type=WorkflowTrigger.WorkflowTriggerType.CONSUMPTION,
order=0,
sources=f"{DocumentSource.ApiUpload},{DocumentSource.ConsumeFolder},{DocumentSource.MailFetch}", sources=f"{DocumentSource.ApiUpload},{DocumentSource.ConsumeFolder},{DocumentSource.MailFetch}",
filter_path="*/samples/*", filter_path="*/samples/*",
)
action1 = WorkflowAction.objects.create(
assign_title="Doc from {correspondent}", assign_title="Doc from {correspondent}",
assign_correspondent=self.c, assign_correspondent=self.c,
assign_document_type=self.dt, assign_document_type=self.dt,
) )
ct1.assign_tags.add(self.t1) action1.assign_tags.add(self.t1)
ct1.assign_tags.add(self.t2) action1.assign_tags.add(self.t2)
ct1.assign_view_users.add(self.user2) action1.assign_view_users.add(self.user2)
ct1.save() action1.save()
ct2 = ConsumptionTemplate.objects.create(
name="Template 2", w1 = Workflow.objects.create(
name="Workflow 1",
order=0, order=0,
)
w1.triggers.add(trigger1)
w1.actions.add(action1)
w1.save()
trigger2 = WorkflowTrigger.objects.create(
type=WorkflowTrigger.WorkflowTriggerType.CONSUMPTION,
sources=f"{DocumentSource.ApiUpload},{DocumentSource.ConsumeFolder},{DocumentSource.MailFetch}", sources=f"{DocumentSource.ApiUpload},{DocumentSource.ConsumeFolder},{DocumentSource.MailFetch}",
filter_filename="*simple*", filter_filename="*simple*",
)
action2 = WorkflowAction.objects.create(
assign_title="Doc from {correspondent}", assign_title="Doc from {correspondent}",
assign_correspondent=self.c2, assign_correspondent=self.c2,
assign_storage_path=self.sp, assign_storage_path=self.sp,
) )
ct2.assign_tags.add(self.t3) action2.assign_tags.add(self.t3)
ct1.assign_view_users.add(self.user3) action2.assign_view_users.add(self.user3)
ct2.save() action2.save()
w2 = Workflow.objects.create(
name="Workflow 2",
order=0,
)
w2.triggers.add(trigger2)
w2.actions.add(action2)
w2.save()
test_file = self.SAMPLE_DIR / "simple.pdf" test_file = self.SAMPLE_DIR / "simple.pdf"
@ -276,33 +313,43 @@ class TestConsumptionTemplates(DirectoriesMixin, FileSystemAssertsMixin, TestCas
[self.user2.pk, self.user3.pk], [self.user2.pk, self.user3.pk],
) )
expected_str = f"Document matched template {ct1}" expected_str = f"Document matched {trigger1} from {w1}"
self.assertIn(expected_str, cm.output[0]) self.assertIn(expected_str, cm.output[0])
expected_str = f"Document matched template {ct2}" expected_str = f"Document matched {trigger2} from {w2}"
self.assertIn(expected_str, cm.output[1]) self.assertIn(expected_str, cm.output[1])
@mock.patch("documents.consumer.Consumer.try_consume_file") @mock.patch("documents.consumer.Consumer.try_consume_file")
def test_consumption_template_no_match_filename(self, m): def test_workflow_no_match_filename(self, m):
""" """
GIVEN: GIVEN:
- Existing consumption template - Existing workflow
WHEN: WHEN:
- File that does not match on filename is consumed - File that does not match on filename is consumed
THEN: THEN:
- Template overrides are not applied - Template overrides are not applied
""" """
ct = ConsumptionTemplate.objects.create( trigger = WorkflowTrigger.objects.create(
name="Template 1", type=WorkflowTrigger.WorkflowTriggerType.CONSUMPTION,
order=0,
sources=f"{DocumentSource.ApiUpload},{DocumentSource.ConsumeFolder},{DocumentSource.MailFetch}", sources=f"{DocumentSource.ApiUpload},{DocumentSource.ConsumeFolder},{DocumentSource.MailFetch}",
filter_filename="*foobar*", filter_filename="*foobar*",
filter_path=None, filter_path=None,
)
action = WorkflowAction.objects.create(
assign_title="Doc from {correspondent}", assign_title="Doc from {correspondent}",
assign_correspondent=self.c, assign_correspondent=self.c,
assign_document_type=self.dt, assign_document_type=self.dt,
assign_storage_path=self.sp, assign_storage_path=self.sp,
assign_owner=self.user2, assign_owner=self.user2,
) )
action.save()
w = Workflow.objects.create(
name="Workflow 1",
order=0,
)
w.triggers.add(trigger)
w.actions.add(action)
w.save()
test_file = self.SAMPLE_DIR / "simple.pdf" test_file = self.SAMPLE_DIR / "simple.pdf"
@ -328,32 +375,42 @@ class TestConsumptionTemplates(DirectoriesMixin, FileSystemAssertsMixin, TestCas
self.assertIsNone(overrides["override_change_groups"]) self.assertIsNone(overrides["override_change_groups"])
self.assertIsNone(overrides["override_title"]) self.assertIsNone(overrides["override_title"])
expected_str = f"Document did not match template {ct}" expected_str = f"Document did not match {w}"
self.assertIn(expected_str, cm.output[0]) self.assertIn(expected_str, cm.output[0])
expected_str = f"Document filename {test_file.name} does not match" expected_str = f"Document filename {test_file.name} does not match"
self.assertIn(expected_str, cm.output[1]) self.assertIn(expected_str, cm.output[1])
@mock.patch("documents.consumer.Consumer.try_consume_file") @mock.patch("documents.consumer.Consumer.try_consume_file")
def test_consumption_template_no_match_path(self, m): def test_workflow_no_match_path(self, m):
""" """
GIVEN: GIVEN:
- Existing consumption template - Existing workflow
WHEN: WHEN:
- File that does not match on path is consumed - File that does not match on path is consumed
THEN: THEN:
- Template overrides are not applied - Template overrides are not applied
""" """
ct = ConsumptionTemplate.objects.create( trigger = WorkflowTrigger.objects.create(
name="Template 1", type=WorkflowTrigger.WorkflowTriggerType.CONSUMPTION,
order=0,
sources=f"{DocumentSource.ApiUpload},{DocumentSource.ConsumeFolder},{DocumentSource.MailFetch}", sources=f"{DocumentSource.ApiUpload},{DocumentSource.ConsumeFolder},{DocumentSource.MailFetch}",
filter_path="*foo/bar*", filter_path="*foo/bar*",
)
action = WorkflowAction.objects.create(
assign_title="Doc from {correspondent}", assign_title="Doc from {correspondent}",
assign_correspondent=self.c, assign_correspondent=self.c,
assign_document_type=self.dt, assign_document_type=self.dt,
assign_storage_path=self.sp, assign_storage_path=self.sp,
assign_owner=self.user2, assign_owner=self.user2,
) )
action.save()
w = Workflow.objects.create(
name="Workflow 1",
order=0,
)
w.triggers.add(trigger)
w.actions.add(action)
w.save()
test_file = self.SAMPLE_DIR / "simple.pdf" test_file = self.SAMPLE_DIR / "simple.pdf"
@ -379,32 +436,42 @@ class TestConsumptionTemplates(DirectoriesMixin, FileSystemAssertsMixin, TestCas
self.assertIsNone(overrides["override_change_groups"]) self.assertIsNone(overrides["override_change_groups"])
self.assertIsNone(overrides["override_title"]) self.assertIsNone(overrides["override_title"])
expected_str = f"Document did not match template {ct}" expected_str = f"Document did not match {w}"
self.assertIn(expected_str, cm.output[0]) self.assertIn(expected_str, cm.output[0])
expected_str = f"Document path {test_file} does not match" expected_str = f"Document path {test_file} does not match"
self.assertIn(expected_str, cm.output[1]) self.assertIn(expected_str, cm.output[1])
@mock.patch("documents.consumer.Consumer.try_consume_file") @mock.patch("documents.consumer.Consumer.try_consume_file")
def test_consumption_template_no_match_mail_rule(self, m): def test_workflow_no_match_mail_rule(self, m):
""" """
GIVEN: GIVEN:
- Existing consumption template - Existing workflow
WHEN: WHEN:
- File that does not match on source is consumed - File that does not match on source is consumed
THEN: THEN:
- Template overrides are not applied - Template overrides are not applied
""" """
ct = ConsumptionTemplate.objects.create( trigger = WorkflowTrigger.objects.create(
name="Template 1", type=WorkflowTrigger.WorkflowTriggerType.CONSUMPTION,
order=0,
sources=f"{DocumentSource.ApiUpload},{DocumentSource.ConsumeFolder},{DocumentSource.MailFetch}", sources=f"{DocumentSource.ApiUpload},{DocumentSource.ConsumeFolder},{DocumentSource.MailFetch}",
filter_mailrule=self.rule1, filter_mailrule=self.rule1,
)
action = WorkflowAction.objects.create(
assign_title="Doc from {correspondent}", assign_title="Doc from {correspondent}",
assign_correspondent=self.c, assign_correspondent=self.c,
assign_document_type=self.dt, assign_document_type=self.dt,
assign_storage_path=self.sp, assign_storage_path=self.sp,
assign_owner=self.user2, assign_owner=self.user2,
) )
action.save()
w = Workflow.objects.create(
name="Workflow 1",
order=0,
)
w.triggers.add(trigger)
w.actions.add(action)
w.save()
test_file = self.SAMPLE_DIR / "simple.pdf" test_file = self.SAMPLE_DIR / "simple.pdf"
@ -431,32 +498,42 @@ class TestConsumptionTemplates(DirectoriesMixin, FileSystemAssertsMixin, TestCas
self.assertIsNone(overrides["override_change_groups"]) self.assertIsNone(overrides["override_change_groups"])
self.assertIsNone(overrides["override_title"]) self.assertIsNone(overrides["override_title"])
expected_str = f"Document did not match template {ct}" expected_str = f"Document did not match {w}"
self.assertIn(expected_str, cm.output[0]) self.assertIn(expected_str, cm.output[0])
expected_str = "Document mail rule 99 !=" expected_str = "Document mail rule 99 !="
self.assertIn(expected_str, cm.output[1]) self.assertIn(expected_str, cm.output[1])
@mock.patch("documents.consumer.Consumer.try_consume_file") @mock.patch("documents.consumer.Consumer.try_consume_file")
def test_consumption_template_no_match_source(self, m): def test_workflow_no_match_source(self, m):
""" """
GIVEN: GIVEN:
- Existing consumption template - Existing workflow
WHEN: WHEN:
- File that does not match on source is consumed - File that does not match on source is consumed
THEN: THEN:
- Template overrides are not applied - Template overrides are not applied
""" """
ct = ConsumptionTemplate.objects.create( trigger = WorkflowTrigger.objects.create(
name="Template 1", type=WorkflowTrigger.WorkflowTriggerType.CONSUMPTION,
order=0,
sources=f"{DocumentSource.ConsumeFolder},{DocumentSource.MailFetch}", sources=f"{DocumentSource.ConsumeFolder},{DocumentSource.MailFetch}",
filter_path="*", filter_path="*",
)
action = WorkflowAction.objects.create(
assign_title="Doc from {correspondent}", assign_title="Doc from {correspondent}",
assign_correspondent=self.c, assign_correspondent=self.c,
assign_document_type=self.dt, assign_document_type=self.dt,
assign_storage_path=self.sp, assign_storage_path=self.sp,
assign_owner=self.user2, assign_owner=self.user2,
) )
action.save()
w = Workflow.objects.create(
name="Workflow 1",
order=0,
)
w.triggers.add(trigger)
w.actions.add(action)
w.save()
test_file = self.SAMPLE_DIR / "simple.pdf" test_file = self.SAMPLE_DIR / "simple.pdf"
@ -482,7 +559,7 @@ class TestConsumptionTemplates(DirectoriesMixin, FileSystemAssertsMixin, TestCas
self.assertIsNone(overrides["override_change_groups"]) self.assertIsNone(overrides["override_change_groups"])
self.assertIsNone(overrides["override_title"]) self.assertIsNone(overrides["override_title"])
expected_str = f"Document did not match template {ct}" expected_str = f"Document did not match {w}"
self.assertIn(expected_str, cm.output[0]) self.assertIn(expected_str, cm.output[0])
expected_str = f"Document source {DocumentSource.ApiUpload.name} not in ['{DocumentSource.ConsumeFolder.name}', '{DocumentSource.MailFetch.name}']" expected_str = f"Document source {DocumentSource.ApiUpload.name} not in ['{DocumentSource.ConsumeFolder.name}', '{DocumentSource.MailFetch.name}']"
self.assertIn(expected_str, cm.output[1]) self.assertIn(expected_str, cm.output[1])

View File

@ -76,7 +76,6 @@ from documents.matching import match_correspondents
from documents.matching import match_document_types from documents.matching import match_document_types
from documents.matching import match_storage_paths from documents.matching import match_storage_paths
from documents.matching import match_tags from documents.matching import match_tags
from documents.models import ConsumptionTemplate
from documents.models import Correspondent from documents.models import Correspondent
from documents.models import CustomField from documents.models import CustomField
from documents.models import Document from documents.models import Document
@ -87,6 +86,9 @@ from documents.models import SavedView
from documents.models import ShareLink from documents.models import ShareLink
from documents.models import StoragePath from documents.models import StoragePath
from documents.models import Tag from documents.models import Tag
from documents.models import Workflow
from documents.models import WorkflowAction
from documents.models import WorkflowTrigger
from documents.parsers import get_parser_class_for_mime_type from documents.parsers import get_parser_class_for_mime_type
from documents.parsers import parse_date_generator from documents.parsers import parse_date_generator
from documents.permissions import PaperlessAdminPermissions from documents.permissions import PaperlessAdminPermissions
@ -98,7 +100,6 @@ from documents.serialisers import AcknowledgeTasksViewSerializer
from documents.serialisers import BulkDownloadSerializer from documents.serialisers import BulkDownloadSerializer
from documents.serialisers import BulkEditObjectPermissionsSerializer from documents.serialisers import BulkEditObjectPermissionsSerializer
from documents.serialisers import BulkEditSerializer from documents.serialisers import BulkEditSerializer
from documents.serialisers import ConsumptionTemplateSerializer
from documents.serialisers import CorrespondentSerializer from documents.serialisers import CorrespondentSerializer
from documents.serialisers import CustomFieldSerializer from documents.serialisers import CustomFieldSerializer
from documents.serialisers import DocumentListSerializer from documents.serialisers import DocumentListSerializer
@ -112,6 +113,9 @@ from documents.serialisers import TagSerializer
from documents.serialisers import TagSerializerVersion1 from documents.serialisers import TagSerializerVersion1
from documents.serialisers import TasksViewSerializer from documents.serialisers import TasksViewSerializer
from documents.serialisers import UiSettingsViewSerializer from documents.serialisers import UiSettingsViewSerializer
from documents.serialisers import WorkflowActionSerializer
from documents.serialisers import WorkflowSerializer
from documents.serialisers import WorkflowTriggerSerializer
from documents.tasks import consume_file from documents.tasks import consume_file
from paperless import version from paperless import version
from paperless.db import GnuPG from paperless.db import GnuPG
@ -1373,25 +1377,50 @@ class BulkEditObjectPermissionsView(GenericAPIView, PassUserMixin):
) )
class ConsumptionTemplateViewSet(ModelViewSet): class WorkflowTriggerViewSet(ModelViewSet):
permission_classes = (IsAuthenticated, PaperlessObjectPermissions) permission_classes = (IsAuthenticated, PaperlessObjectPermissions)
serializer_class = ConsumptionTemplateSerializer serializer_class = WorkflowTriggerSerializer
pagination_class = StandardPagination pagination_class = StandardPagination
model = ConsumptionTemplate model = WorkflowTrigger
queryset = WorkflowTrigger.objects.all()
class WorkflowActionViewSet(ModelViewSet):
permission_classes = (IsAuthenticated, PaperlessObjectPermissions)
serializer_class = WorkflowActionSerializer
pagination_class = StandardPagination
model = WorkflowAction
queryset = WorkflowAction.objects.all().prefetch_related(
"assign_tags",
"assign_view_users",
"assign_view_groups",
"assign_change_users",
"assign_change_groups",
"assign_custom_fields",
)
class WorkflowViewSet(ModelViewSet):
permission_classes = (IsAuthenticated, PaperlessObjectPermissions)
serializer_class = WorkflowSerializer
pagination_class = StandardPagination
model = Workflow
queryset = ( queryset = (
ConsumptionTemplate.objects.prefetch_related( Workflow.objects.all()
"assign_tags",
"assign_view_users",
"assign_view_groups",
"assign_change_users",
"assign_change_groups",
"assign_custom_fields",
)
.all()
.order_by("order") .order_by("order")
.prefetch_related(
"triggers",
"actions",
)
) )

View File

@ -15,7 +15,6 @@ from documents.views import AcknowledgeTasksView
from documents.views import BulkDownloadView from documents.views import BulkDownloadView
from documents.views import BulkEditObjectPermissionsView from documents.views import BulkEditObjectPermissionsView
from documents.views import BulkEditView from documents.views import BulkEditView
from documents.views import ConsumptionTemplateViewSet
from documents.views import CorrespondentViewSet from documents.views import CorrespondentViewSet
from documents.views import CustomFieldViewSet from documents.views import CustomFieldViewSet
from documents.views import DocumentTypeViewSet from documents.views import DocumentTypeViewSet
@ -34,6 +33,9 @@ from documents.views import TagViewSet
from documents.views import TasksViewSet from documents.views import TasksViewSet
from documents.views import UiSettingsView from documents.views import UiSettingsView
from documents.views import UnifiedSearchViewSet from documents.views import UnifiedSearchViewSet
from documents.views import WorkflowActionViewSet
from documents.views import WorkflowTriggerViewSet
from documents.views import WorkflowViewSet
from paperless.consumers import StatusConsumer from paperless.consumers import StatusConsumer
from paperless.views import FaviconView from paperless.views import FaviconView
from paperless.views import GenerateAuthTokenView from paperless.views import GenerateAuthTokenView
@ -58,7 +60,9 @@ api_router.register(r"groups", GroupViewSet, basename="groups")
api_router.register(r"mail_accounts", MailAccountViewSet) api_router.register(r"mail_accounts", MailAccountViewSet)
api_router.register(r"mail_rules", MailRuleViewSet) api_router.register(r"mail_rules", MailRuleViewSet)
api_router.register(r"share_links", ShareLinkViewSet) api_router.register(r"share_links", ShareLinkViewSet)
api_router.register(r"consumption_templates", ConsumptionTemplateViewSet) api_router.register(r"workflow_triggers", WorkflowTriggerViewSet)
api_router.register(r"workflow_actions", WorkflowActionViewSet)
api_router.register(r"workflows", WorkflowViewSet)
api_router.register(r"custom_fields", CustomFieldViewSet) api_router.register(r"custom_fields", CustomFieldViewSet)