diff --git a/src/documents/consumer.py b/src/documents/consumer.py index 6a0d1ec02..b0da455ec 100644 --- a/src/documents/consumer.py +++ b/src/documents/consumer.py @@ -26,8 +26,7 @@ from documents.data_models import DocumentMetadataOverrides from documents.file_handling import create_source_path_directory from documents.file_handling import generate_unique_filename from documents.loggers import LoggingMixin -from documents.matching import document_matches_template -from documents.models import ConsumptionTemplate +from documents.matching import document_matches_workflow from documents.models import Correspondent from documents.models import CustomField from documents.models import CustomFieldInstance @@ -36,6 +35,8 @@ from documents.models import DocumentType from documents.models import FileInfo from documents.models import StoragePath from documents.models import Tag +from documents.models import Workflow +from documents.models import WorkflowTrigger from documents.parsers import DocumentParser from documents.parsers import ParseError 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 """ overrides = DocumentMetadataOverrides() - for template in ConsumptionTemplate.objects.all().order_by("order"): + for workflow in Workflow.objects.all().order_by("order"): template_overrides = DocumentMetadataOverrides() - if document_matches_template(input_doc, template): - if template.assign_title is not None: - template_overrides.title = template.assign_title - if template.assign_tags is not None: - template_overrides.tag_ids = [ - tag.pk for tag in template.assign_tags.all() - ] - if template.assign_correspondent is not None: - template_overrides.correspondent_id = ( - template.assign_correspondent.pk - ) - if template.assign_document_type is not None: - template_overrides.document_type_id = ( - template.assign_document_type.pk - ) - if template.assign_storage_path is not None: - template_overrides.storage_path_id = template.assign_storage_path.pk - if template.assign_owner is not None: - template_overrides.owner_id = template.assign_owner.pk - if template.assign_view_users is not None: - template_overrides.view_users = [ - user.pk for user in template.assign_view_users.all() - ] - if template.assign_view_groups is not None: - template_overrides.view_groups = [ - group.pk for group in template.assign_view_groups.all() - ] - if template.assign_change_users is not None: - template_overrides.change_users = [ - user.pk for user in template.assign_change_users.all() - ] - if template.assign_change_groups is not None: - template_overrides.change_groups = [ - group.pk for group in template.assign_change_groups.all() - ] - if template.assign_custom_fields is not None: - template_overrides.custom_field_ids = [ - field.pk for field in template.assign_custom_fields.all() - ] + if document_matches_workflow( + input_doc, + workflow, + WorkflowTrigger.WorkflowTriggerType.CONSUMPTION, + ): + for action in workflow.actions.all(): + if action.assign_title is not None: + template_overrides.title = action.assign_title + if action.assign_tags is not None: + template_overrides.tag_ids = [ + tag.pk for tag in action.assign_tags.all() + ] + if action.assign_correspondent is not None: + template_overrides.correspondent_id = ( + action.assign_correspondent.pk + ) + if action.assign_document_type is not None: + template_overrides.document_type_id = ( + action.assign_document_type.pk + ) + if action.assign_storage_path is not None: + template_overrides.storage_path_id = ( + action.assign_storage_path.pk + ) + if action.assign_owner is not None: + template_overrides.owner_id = action.assign_owner.pk + if action.assign_view_users is not None: + template_overrides.view_users = [ + user.pk for user in action.assign_view_users.all() + ] + if action.assign_view_groups is not None: + template_overrides.view_groups = [ + group.pk for group in action.assign_view_groups.all() + ] + if action.assign_change_users is not None: + template_overrides.change_users = [ + user.pk for user in action.assign_change_users.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 def _parse_title_placeholders(self, title: str) -> str: diff --git a/src/documents/matching.py b/src/documents/matching.py index 9c6e11ca7..2033b98a8 100644 --- a/src/documents/matching.py +++ b/src/documents/matching.py @@ -5,13 +5,14 @@ from fnmatch import fnmatch from documents.classifier import DocumentClassifier from documents.data_models import ConsumableDocument from documents.data_models import DocumentSource -from documents.models import ConsumptionTemplate from documents.models import Correspondent from documents.models import Document from documents.models import DocumentType from documents.models import MatchingModel from documents.models import StoragePath 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 logger = logging.getLogger("paperless.matching") @@ -237,65 +238,75 @@ def _split_match(matching_model): ] -def document_matches_template( +def document_matches_workflow( document: ConsumableDocument, - template: ConsumptionTemplate, + workflow: Workflow, + trigger_type: WorkflowTrigger.WorkflowTriggerType, ) -> bool: """ 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): - logger.info(f"Document did not match template {template.name}") + logger.info(f"Document did not match {workflow}") logger.debug(reason) - # Document source vs template source - if document.source not in [int(x) for x in list(template.sources)]: - log_match_failure( - f"Document source {document.source.name} not in" - f" {[DocumentSource(int(x)).name for x in template.sources]}", - ) - return False + trigger_matched = True + triggers = workflow.triggers.filter(type=trigger_type) + if len(triggers) == 0: + trigger_matched = False + else: + for trigger in triggers: + # 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 - if ( - document.mailrule_id is not None - and template.filter_mailrule is not None - and document.mailrule_id != template.filter_mailrule.pk - ): - log_match_failure( - f"Document mail rule {document.mailrule_id}" - f" != {template.filter_mailrule.pk}", - ) - return False + # Document mail rule vs template mail rule + if ( + document.mailrule_id is not None + and trigger.filter_mailrule is not None + and document.mailrule_id != trigger.filter_mailrule.pk + ): + log_match_failure( + f"Document mail rule {document.mailrule_id}" + f" != {trigger.filter_mailrule.pk}", + ) + trigger_matched = False - # Document filename vs template filename - if ( - template.filter_filename is not None - and len(template.filter_filename) > 0 - and not fnmatch( - document.original_file.name.lower(), - template.filter_filename.lower(), - ) - ): - log_match_failure( - f"Document filename {document.original_file.name} does not match" - f" {template.filter_filename.lower()}", - ) - return False + # Document filename vs template filename + if ( + trigger.filter_filename is not None + and len(trigger.filter_filename) > 0 + and not fnmatch( + document.original_file.name.lower(), + trigger.filter_filename.lower(), + ) + ): + log_match_failure( + f"Document filename {document.original_file.name} does not match" + f" {trigger.filter_filename.lower()}", + ) + trigger_matched = False - # Document path vs template path - if ( - template.filter_path is not None - and len(template.filter_path) > 0 - and not document.original_file.match(template.filter_path) - ): - log_match_failure( - f"Document path {document.original_file}" - f" does not match {template.filter_path}", - ) - return False + # Document path vs template path + if ( + trigger.filter_path is not None + and len(trigger.filter_path) > 0 + and not document.original_file.match(trigger.filter_path) + ): + log_match_failure( + f"Document path {document.original_file}" + f" does not match {trigger.filter_path}", + ) + trigger_matched = False - logger.info(f"Document matched template {template.name}") - return True + if trigger_matched: + logger.info(f"Document matched {trigger} from {workflow}") + return True + + return trigger_matched diff --git a/src/documents/migrations/1044_workflow_workflowaction_workflowtrigger_and_more.py b/src/documents/migrations/1044_workflow_workflowaction_workflowtrigger_and_more.py new file mode 100644 index 000000000..4eee99efc --- /dev/null +++ b/src/documents/migrations/1044_workflow_workflowaction_workflowtrigger_and_more.py @@ -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"), + ] diff --git a/src/documents/models.py b/src/documents/models.py index d95bf46e1..82eff152d 100644 --- a/src/documents/models.py +++ b/src/documents/models.py @@ -888,15 +888,22 @@ if settings.AUDIT_LOG_ENABLED: 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): CONSUME_FOLDER = DocumentSource.ConsumeFolder.value, _("Consume Folder") API_UPLOAD = DocumentSource.ApiUpload.value, _("Api Upload") MAIL_FETCH = DocumentSource.MailFetch.value, _("Mail Fetch") - name = models.CharField(_("name"), max_length=256, unique=True) - - order = models.IntegerField(_("order"), default=0) + type = models.PositiveIntegerField( + _("Workflow Trigger Type"), + choices=WorkflowTriggerType.choices, + default=WorkflowTriggerType.CONSUMPTION, + ) sources = MultiSelectField( max_length=5, @@ -936,6 +943,15 @@ class ConsumptionTemplate(models.Model): 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"), max_length=256, @@ -1022,8 +1038,31 @@ class ConsumptionTemplate(models.Model): ) class Meta: - verbose_name = _("consumption template") - verbose_name_plural = _("consumption templates") + verbose_name = _("workflow action") + verbose_name_plural = _("workflow actions") 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}" diff --git a/src/documents/serialisers.py b/src/documents/serialisers.py index b6be62d9b..04108b10a 100644 --- a/src/documents/serialisers.py +++ b/src/documents/serialisers.py @@ -2,6 +2,7 @@ import datetime import math import re import zoneinfo +from typing import Any import magic from celery import states @@ -24,7 +25,6 @@ from rest_framework.fields import SerializerMethodField from documents import bulk_edit from documents.data_models import DocumentSource -from documents.models import ConsumptionTemplate from documents.models import Correspondent from documents.models import CustomField from documents.models import CustomFieldInstance @@ -38,6 +38,9 @@ from documents.models import ShareLink from documents.models import StoragePath from documents.models import Tag 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.permissions import get_groups_with_only_permission from documents.permissions import set_permissions_for_object @@ -1258,10 +1261,9 @@ class BulkEditObjectPermissionsSerializer(serializers.Serializer, SetPermissions return attrs -class ConsumptionTemplateSerializer(serializers.ModelSerializer): - order = serializers.IntegerField(required=False) +class WorkflowTriggerSerializer(serializers.ModelSerializer): sources = fields.MultipleChoiceField( - choices=ConsumptionTemplate.DocumentSourceChoices.choices, + choices=WorkflowTrigger.DocumentSourceChoices.choices, allow_empty=False, default={ DocumentSource.ConsumeFolder, @@ -1269,32 +1271,21 @@ class ConsumptionTemplateSerializer(serializers.ModelSerializer): DocumentSource.MailFetch, }, ) - 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) + + type = serializers.ChoiceField( + choices=WorkflowTrigger.WorkflowTriggerType.choices, + label="Trigger Type", + ) class Meta: - model = ConsumptionTemplate + model = WorkflowTrigger fields = [ "id", - "name", - "order", "sources", + "type", "filter_path", "filter_filename", "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): @@ -1302,12 +1293,6 @@ class ConsumptionTemplateSerializer(serializers.ModelSerializer): attrs["sources"] = {DocumentSource.MailFetch.value} # 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 ( "filter_filename" in attrs and attrs["filter_filename"] is not None @@ -1322,7 +1307,8 @@ class ConsumptionTemplateSerializer(serializers.ModelSerializer): attrs["filter_path"] = None 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_path" not in attrs or attrs["filter_path"] is None) ): @@ -1331,3 +1317,89 @@ class ConsumptionTemplateSerializer(serializers.ModelSerializer): ) 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 diff --git a/src/documents/tests/test_api_consumption_templates.py b/src/documents/tests/test_api_workflows.py similarity index 51% rename from src/documents/tests/test_api_consumption_templates.py rename to src/documents/tests/test_api_workflows.py index e32294050..45769661c 100644 --- a/src/documents/tests/test_api_consumption_templates.py +++ b/src/documents/tests/test_api_workflows.py @@ -6,19 +6,23 @@ from rest_framework import status from rest_framework.test import APITestCase from documents.data_models import DocumentSource -from documents.models import ConsumptionTemplate 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 documents.tests.utils import DirectoriesMixin from paperless_mail.models import MailAccount from paperless_mail.models import MailRule -class TestApiConsumptionTemplates(DirectoriesMixin, APITestCase): - ENDPOINT = "/api/consumption_templates/" +class TestApiWorkflows(DirectoriesMixin, APITestCase): + ENDPOINT = "/api/workflows/" + ENDPOINT_TRIGGERS = "/api/workflow_triggers/" + ENDPOINT_ACTIONS = "/api/workflow_actions/" def setUp(self) -> None: super().setUp() @@ -42,104 +46,158 @@ class TestApiConsumptionTemplates(DirectoriesMixin, APITestCase): data_type="integer", ) - self.ct = ConsumptionTemplate.objects.create( - name="Template 1", - order=0, + self.trigger = WorkflowTrigger.objects.create( + type=WorkflowTrigger.WorkflowTriggerType.CONSUMPTION, sources=f"{int(DocumentSource.ApiUpload)},{int(DocumentSource.ConsumeFolder)},{int(DocumentSource.MailFetch)}", filter_filename="*simple*", filter_path="*/samples/*", + ) + self.action = WorkflowAction.objects.create( assign_title="Doc from {correspondent}", assign_correspondent=self.c, assign_document_type=self.dt, assign_storage_path=self.sp, assign_owner=self.user2, ) - self.ct.assign_tags.add(self.t1) - self.ct.assign_tags.add(self.t2) - self.ct.assign_tags.add(self.t3) - self.ct.assign_view_users.add(self.user3.pk) - self.ct.assign_view_groups.add(self.group1.pk) - self.ct.assign_change_users.add(self.user3.pk) - self.ct.assign_change_groups.add(self.group1.pk) - self.ct.assign_custom_fields.add(self.cf1.pk) - self.ct.assign_custom_fields.add(self.cf2.pk) - self.ct.save() + self.action.assign_tags.add(self.t1) + self.action.assign_tags.add(self.t2) + self.action.assign_tags.add(self.t3) + self.action.assign_view_users.add(self.user3.pk) + self.action.assign_view_groups.add(self.group1.pk) + self.action.assign_change_users.add(self.user3.pk) + self.action.assign_change_groups.add(self.group1.pk) + self.action.assign_custom_fields.add(self.cf1.pk) + self.action.assign_custom_fields.add(self.cf2.pk) + 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: - - API request to get all consumption template + - API request to get all workflows WHEN: - API is called THEN: - - Existing consumption templates are returned + - Existing workflows are returned """ response = self.client.get(self.ENDPOINT, format="json") self.assertEqual(response.status_code, status.HTTP_200_OK) self.assertEqual(response.data["count"], 1) - resp_consumption_template = response.data["results"][0] - self.assertEqual(resp_consumption_template["id"], self.ct.id) + resp_workflow = response.data["results"][0] + self.assertEqual(resp_workflow["id"], self.workflow.id) self.assertEqual( - resp_consumption_template["assign_correspondent"], - self.ct.assign_correspondent.pk, + resp_workflow["actions"][0]["assign_correspondent"], + self.action.assign_correspondent.pk, ) - def test_api_create_consumption_template(self): + def test_api_create_workflow(self): """ GIVEN: - - API request to create a consumption template + - API request to create a workflow, trigger and action WHEN: - API is called THEN: - 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( self.ENDPOINT, json.dumps( { - "name": "Template 2", + "name": "Workflow 2", "order": 1, - "sources": [DocumentSource.ApiUpload], - "filter_filename": "*test*", + "triggers": [ + { + "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", ) 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: - - API request to create a consumption template - - Neither file name nor path filter are specified + - API request to create a workflow trigger + - Neither type or file name nor path filter are specified WHEN: - API is called THEN: - Correct HTTP 400 response - - No template is created + - No objects are created """ response = self.client.post( - self.ENDPOINT, + self.ENDPOINT_TRIGGERS, json.dumps( { - "name": "Template 2", - "order": 1, "sources": [DocumentSource.ApiUpload], }, ), content_type="application/json", ) 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: - - 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 WHEN: - API is called @@ -147,31 +205,40 @@ class TestApiConsumptionTemplates(DirectoriesMixin, APITestCase): - Template is created but filter or title assignment is not set if "" """ response = self.client.post( - self.ENDPOINT, + self.ENDPOINT_TRIGGERS, json.dumps( { - "name": "Template 2", - "order": 1, + "type": WorkflowTrigger.WorkflowTriggerType.CONSUMPTION, "sources": [DocumentSource.ApiUpload], "filter_filename": "*test*", "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": "", }, ), content_type="application/json", ) self.assertEqual(response.status_code, status.HTTP_201_CREATED) - ct = ConsumptionTemplate.objects.get(name="Template 2") - self.assertEqual(ct.filter_filename, "*test*") - self.assertIsNone(ct.filter_path) - self.assertIsNone(ct.assign_title) + action = WorkflowAction.objects.get(id=response.data["id"]) + self.assertIsNone(action.assign_title) response = self.client.post( - self.ENDPOINT, + self.ENDPOINT_TRIGGERS, json.dumps( { - "name": "Template 3", - "order": 1, + "type": WorkflowTrigger.WorkflowTriggerType.CONSUMPTION, "sources": [DocumentSource.ApiUpload], "filter_filename": "", "filter_path": "*/test/*", @@ -180,18 +247,18 @@ class TestApiConsumptionTemplates(DirectoriesMixin, APITestCase): content_type="application/json", ) self.assertEqual(response.status_code, status.HTTP_201_CREATED) - ct2 = ConsumptionTemplate.objects.get(name="Template 3") - self.assertEqual(ct2.filter_path, "*/test/*") - self.assertIsNone(ct2.filter_filename) + trigger2 = WorkflowTrigger.objects.get(id=response.data["id"]) + self.assertEqual(trigger2.filter_path, "*/test/*") + self.assertIsNone(trigger2.filter_filename) - def test_api_create_consumption_template_with_mailrule(self): + def test_api_create_workflow_trigger_with_mailrule(self): """ 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: - API is called THEN: - - New template is created with MailFetch as source + - New trigger is created with MailFetch as source """ account1 = MailAccount.objects.create( name="Email1", @@ -219,11 +286,10 @@ class TestApiConsumptionTemplates(DirectoriesMixin, APITestCase): attachment_type=MailRule.AttachmentProcessing.ATTACHMENTS_ONLY, ) response = self.client.post( - self.ENDPOINT, + self.ENDPOINT_TRIGGERS, json.dumps( { - "name": "Template 2", - "order": 1, + "type": WorkflowTrigger.WorkflowTriggerType.CONSUMPTION, "sources": [DocumentSource.ApiUpload], "filter_mailrule": rule1.pk, }, @@ -231,6 +297,6 @@ class TestApiConsumptionTemplates(DirectoriesMixin, APITestCase): content_type="application/json", ) self.assertEqual(response.status_code, status.HTTP_201_CREATED) - self.assertEqual(ConsumptionTemplate.objects.count(), 2) - ct = ConsumptionTemplate.objects.get(name="Template 2") - self.assertEqual(ct.sources, [int(DocumentSource.MailFetch).__str__()]) + self.assertEqual(WorkflowTrigger.objects.count(), 2) + trigger = WorkflowTrigger.objects.get(id=response.data["id"]) + self.assertEqual(trigger.sources, [int(DocumentSource.MailFetch).__str__()]) diff --git a/src/documents/tests/test_consumption_templates.py b/src/documents/tests/test_workflows.py similarity index 79% rename from src/documents/tests/test_consumption_templates.py rename to src/documents/tests/test_workflows.py index 6f671bfc4..48c8a9c96 100644 --- a/src/documents/tests/test_consumption_templates.py +++ b/src/documents/tests/test_workflows.py @@ -9,12 +9,14 @@ from django.contrib.auth.models import User from documents import tasks from documents.data_models import ConsumableDocument from documents.data_models import DocumentSource -from documents.models import ConsumptionTemplate 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 documents.tests.utils import DirectoriesMixin from documents.tests.utils import FileSystemAssertsMixin from paperless_mail.models import MailAccount @@ -22,7 +24,7 @@ from paperless_mail.models import MailRule @pytest.mark.django_db -class TestConsumptionTemplates(DirectoriesMixin, FileSystemAssertsMixin, TestCase): +class TestWorkflows(DirectoriesMixin, FileSystemAssertsMixin, TestCase): SAMPLE_DIR = Path(__file__).parent / "samples" def setUp(self) -> None: @@ -73,39 +75,47 @@ class TestConsumptionTemplates(DirectoriesMixin, FileSystemAssertsMixin, TestCas return super().setUp() @mock.patch("documents.consumer.Consumer.try_consume_file") - def test_consumption_template_match(self, m): + def test_workflow_match(self, m): """ GIVEN: - - Existing consumption template + - Existing workflow WHEN: - File that matches is consumed THEN: - Template overrides are applied """ - ct = ConsumptionTemplate.objects.create( - name="Template 1", - order=0, + trigger = WorkflowTrigger.objects.create( + type=WorkflowTrigger.WorkflowTriggerType.CONSUMPTION, sources=f"{DocumentSource.ApiUpload},{DocumentSource.ConsumeFolder},{DocumentSource.MailFetch}", filter_filename="*simple*", filter_path="*/samples/*", + ) + action = WorkflowAction.objects.create( assign_title="Doc from {correspondent}", assign_correspondent=self.c, assign_document_type=self.dt, assign_storage_path=self.sp, assign_owner=self.user2, ) - ct.assign_tags.add(self.t1) - ct.assign_tags.add(self.t2) - ct.assign_tags.add(self.t3) - ct.assign_view_users.add(self.user3.pk) - ct.assign_view_groups.add(self.group1.pk) - ct.assign_change_users.add(self.user3.pk) - ct.assign_change_groups.add(self.group1.pk) - ct.assign_custom_fields.add(self.cf1.pk) - ct.assign_custom_fields.add(self.cf2.pk) - ct.save() + action.assign_tags.add(self.t1) + action.assign_tags.add(self.t2) + action.assign_tags.add(self.t3) + action.assign_view_users.add(self.user3.pk) + action.assign_view_groups.add(self.group1.pk) + action.assign_change_users.add(self.user3.pk) + action.assign_change_groups.add(self.group1.pk) + action.assign_custom_fields.add(self.cf1.pk) + action.assign_custom_fields.add(self.cf2.pk) + 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" @@ -142,40 +152,48 @@ class TestConsumptionTemplates(DirectoriesMixin, FileSystemAssertsMixin, TestCas ) info = cm.output[0] - expected_str = f"Document matched template {ct}" + expected_str = f"Document matched {trigger} from {w}" self.assertIn(expected_str, info) @mock.patch("documents.consumer.Consumer.try_consume_file") - def test_consumption_template_match_mailrule(self, m): + def test_workflow_match_mailrule(self, m): """ GIVEN: - - Existing consumption template + - Existing workflow WHEN: - File that matches is consumed via mail rule THEN: - Template overrides are applied """ - ct = ConsumptionTemplate.objects.create( - name="Template 1", - order=0, + trigger = WorkflowTrigger.objects.create( + type=WorkflowTrigger.WorkflowTriggerType.CONSUMPTION, sources=f"{DocumentSource.ApiUpload},{DocumentSource.ConsumeFolder},{DocumentSource.MailFetch}", filter_mailrule=self.rule1, + ) + + action = WorkflowAction.objects.create( assign_title="Doc from {correspondent}", assign_correspondent=self.c, assign_document_type=self.dt, assign_storage_path=self.sp, assign_owner=self.user2, ) - ct.assign_tags.add(self.t1) - ct.assign_tags.add(self.t2) - ct.assign_tags.add(self.t3) - ct.assign_view_users.add(self.user3.pk) - ct.assign_view_groups.add(self.group1.pk) - ct.assign_change_users.add(self.user3.pk) - ct.assign_change_groups.add(self.group1.pk) - ct.save() + action.assign_tags.add(self.t1) + action.assign_tags.add(self.t2) + action.assign_tags.add(self.t3) + action.assign_view_users.add(self.user3.pk) + action.assign_view_groups.add(self.group1.pk) + action.assign_change_users.add(self.user3.pk) + action.assign_change_groups.add(self.group1.pk) + 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" with mock.patch("documents.tasks.async_to_sync"): @@ -208,45 +226,64 @@ class TestConsumptionTemplates(DirectoriesMixin, FileSystemAssertsMixin, TestCas ) info = cm.output[0] - expected_str = f"Document matched template {ct}" + expected_str = f"Document matched {trigger} from {w}" self.assertIn(expected_str, info) @mock.patch("documents.consumer.Consumer.try_consume_file") - def test_consumption_template_match_multiple(self, m): + def test_workflow_match_multiple(self, m): """ GIVEN: - - Multiple existing consumption template + - Multiple existing workflow WHEN: - File that matches is consumed THEN: - Template overrides are applied with subsequent templates only overwriting empty values or merging if multiple """ - ct1 = ConsumptionTemplate.objects.create( - name="Template 1", - order=0, + trigger1 = WorkflowTrigger.objects.create( + type=WorkflowTrigger.WorkflowTriggerType.CONSUMPTION, sources=f"{DocumentSource.ApiUpload},{DocumentSource.ConsumeFolder},{DocumentSource.MailFetch}", filter_path="*/samples/*", + ) + action1 = WorkflowAction.objects.create( assign_title="Doc from {correspondent}", assign_correspondent=self.c, assign_document_type=self.dt, ) - ct1.assign_tags.add(self.t1) - ct1.assign_tags.add(self.t2) - ct1.assign_view_users.add(self.user2) - ct1.save() - ct2 = ConsumptionTemplate.objects.create( - name="Template 2", + action1.assign_tags.add(self.t1) + action1.assign_tags.add(self.t2) + action1.assign_view_users.add(self.user2) + action1.save() + + w1 = Workflow.objects.create( + name="Workflow 1", 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}", filter_filename="*simple*", + ) + action2 = WorkflowAction.objects.create( assign_title="Doc from {correspondent}", assign_correspondent=self.c2, assign_storage_path=self.sp, ) - ct2.assign_tags.add(self.t3) - ct1.assign_view_users.add(self.user3) - ct2.save() + action2.assign_tags.add(self.t3) + action2.assign_view_users.add(self.user3) + 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" @@ -276,33 +313,43 @@ class TestConsumptionTemplates(DirectoriesMixin, FileSystemAssertsMixin, TestCas [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]) - expected_str = f"Document matched template {ct2}" + expected_str = f"Document matched {trigger2} from {w2}" self.assertIn(expected_str, cm.output[1]) @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: - - Existing consumption template + - Existing workflow WHEN: - File that does not match on filename is consumed THEN: - Template overrides are not applied """ - ct = ConsumptionTemplate.objects.create( - name="Template 1", - order=0, + trigger = WorkflowTrigger.objects.create( + type=WorkflowTrigger.WorkflowTriggerType.CONSUMPTION, sources=f"{DocumentSource.ApiUpload},{DocumentSource.ConsumeFolder},{DocumentSource.MailFetch}", filter_filename="*foobar*", filter_path=None, + ) + action = WorkflowAction.objects.create( assign_title="Doc from {correspondent}", assign_correspondent=self.c, assign_document_type=self.dt, assign_storage_path=self.sp, 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" @@ -328,32 +375,42 @@ class TestConsumptionTemplates(DirectoriesMixin, FileSystemAssertsMixin, TestCas self.assertIsNone(overrides["override_change_groups"]) 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]) expected_str = f"Document filename {test_file.name} does not match" self.assertIn(expected_str, cm.output[1]) @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: - - Existing consumption template + - Existing workflow WHEN: - File that does not match on path is consumed THEN: - Template overrides are not applied """ - ct = ConsumptionTemplate.objects.create( - name="Template 1", - order=0, + trigger = WorkflowTrigger.objects.create( + type=WorkflowTrigger.WorkflowTriggerType.CONSUMPTION, sources=f"{DocumentSource.ApiUpload},{DocumentSource.ConsumeFolder},{DocumentSource.MailFetch}", filter_path="*foo/bar*", + ) + action = WorkflowAction.objects.create( assign_title="Doc from {correspondent}", assign_correspondent=self.c, assign_document_type=self.dt, assign_storage_path=self.sp, 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" @@ -379,32 +436,42 @@ class TestConsumptionTemplates(DirectoriesMixin, FileSystemAssertsMixin, TestCas self.assertIsNone(overrides["override_change_groups"]) 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]) expected_str = f"Document path {test_file} does not match" self.assertIn(expected_str, cm.output[1]) @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: - - Existing consumption template + - Existing workflow WHEN: - File that does not match on source is consumed THEN: - Template overrides are not applied """ - ct = ConsumptionTemplate.objects.create( - name="Template 1", - order=0, + trigger = WorkflowTrigger.objects.create( + type=WorkflowTrigger.WorkflowTriggerType.CONSUMPTION, sources=f"{DocumentSource.ApiUpload},{DocumentSource.ConsumeFolder},{DocumentSource.MailFetch}", filter_mailrule=self.rule1, + ) + action = WorkflowAction.objects.create( assign_title="Doc from {correspondent}", assign_correspondent=self.c, assign_document_type=self.dt, assign_storage_path=self.sp, 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" @@ -431,32 +498,42 @@ class TestConsumptionTemplates(DirectoriesMixin, FileSystemAssertsMixin, TestCas self.assertIsNone(overrides["override_change_groups"]) 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]) expected_str = "Document mail rule 99 !=" self.assertIn(expected_str, cm.output[1]) @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: - - Existing consumption template + - Existing workflow WHEN: - File that does not match on source is consumed THEN: - Template overrides are not applied """ - ct = ConsumptionTemplate.objects.create( - name="Template 1", - order=0, + trigger = WorkflowTrigger.objects.create( + type=WorkflowTrigger.WorkflowTriggerType.CONSUMPTION, sources=f"{DocumentSource.ConsumeFolder},{DocumentSource.MailFetch}", filter_path="*", + ) + action = WorkflowAction.objects.create( assign_title="Doc from {correspondent}", assign_correspondent=self.c, assign_document_type=self.dt, assign_storage_path=self.sp, 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" @@ -482,7 +559,7 @@ class TestConsumptionTemplates(DirectoriesMixin, FileSystemAssertsMixin, TestCas self.assertIsNone(overrides["override_change_groups"]) 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]) expected_str = f"Document source {DocumentSource.ApiUpload.name} not in ['{DocumentSource.ConsumeFolder.name}', '{DocumentSource.MailFetch.name}']" self.assertIn(expected_str, cm.output[1]) diff --git a/src/documents/views.py b/src/documents/views.py index e8c6db0de..261a12017 100644 --- a/src/documents/views.py +++ b/src/documents/views.py @@ -76,7 +76,6 @@ from documents.matching import match_correspondents from documents.matching import match_document_types from documents.matching import match_storage_paths from documents.matching import match_tags -from documents.models import ConsumptionTemplate from documents.models import Correspondent from documents.models import CustomField from documents.models import Document @@ -87,6 +86,9 @@ from documents.models import SavedView from documents.models import ShareLink 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 documents.parsers import get_parser_class_for_mime_type from documents.parsers import parse_date_generator from documents.permissions import PaperlessAdminPermissions @@ -98,7 +100,6 @@ from documents.serialisers import AcknowledgeTasksViewSerializer from documents.serialisers import BulkDownloadSerializer from documents.serialisers import BulkEditObjectPermissionsSerializer from documents.serialisers import BulkEditSerializer -from documents.serialisers import ConsumptionTemplateSerializer from documents.serialisers import CorrespondentSerializer from documents.serialisers import CustomFieldSerializer from documents.serialisers import DocumentListSerializer @@ -112,6 +113,9 @@ from documents.serialisers import TagSerializer from documents.serialisers import TagSerializerVersion1 from documents.serialisers import TasksViewSerializer 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 paperless import version from paperless.db import GnuPG @@ -1373,25 +1377,50 @@ class BulkEditObjectPermissionsView(GenericAPIView, PassUserMixin): ) -class ConsumptionTemplateViewSet(ModelViewSet): +class WorkflowTriggerViewSet(ModelViewSet): permission_classes = (IsAuthenticated, PaperlessObjectPermissions) - serializer_class = ConsumptionTemplateSerializer + serializer_class = WorkflowTriggerSerializer 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 = ( - ConsumptionTemplate.objects.prefetch_related( - "assign_tags", - "assign_view_users", - "assign_view_groups", - "assign_change_users", - "assign_change_groups", - "assign_custom_fields", - ) - .all() + Workflow.objects.all() .order_by("order") + .prefetch_related( + "triggers", + "actions", + ) ) diff --git a/src/paperless/urls.py b/src/paperless/urls.py index 72deaeaf3..65dc89e03 100644 --- a/src/paperless/urls.py +++ b/src/paperless/urls.py @@ -15,7 +15,6 @@ from documents.views import AcknowledgeTasksView from documents.views import BulkDownloadView from documents.views import BulkEditObjectPermissionsView from documents.views import BulkEditView -from documents.views import ConsumptionTemplateViewSet from documents.views import CorrespondentViewSet from documents.views import CustomFieldViewSet from documents.views import DocumentTypeViewSet @@ -34,6 +33,9 @@ from documents.views import TagViewSet from documents.views import TasksViewSet from documents.views import UiSettingsView 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.views import FaviconView 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_rules", MailRuleViewSet) 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)